diff --git a/Cargo.lock b/Cargo.lock index 0a1689bff7..ce5569cde9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6855,6 +6855,7 @@ dependencies = [ "serde", "serde_json", "toml", + "vectorcraft-apischema", "vectorcraft-brush", "vectorcraft-cad", "vectorcraft-color", diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 15de79660f..a1122e5a7c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -33,8 +33,8 @@ pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub use shared::Shared; pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType}; pub use symbol_nine_slice::{ - EditedNineSlice, MAX_SLICE_GROWTH, MIN_CENTER_BAND, NineSliceGuide, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, - charge_symbol_art, + EditedNineSlice, MAX_GUIDE_FRACTION, MAX_SLICE_GROWTH, MIN_CENTER_BAND, NineSliceGuide, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, + SymbolNineSlice, charge_symbol_art, }; pub use symbol_overrides::SymbolOverride; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; @@ -265,8 +265,8 @@ pub struct Document { /// File → Document Setup (bleed, transparency grid, paper, type options). #[serde(default, skip_serializing_if = "skip::is_default")] pub setup: DocSetup, - /// Layers panel → Paste Remembers Layers: pasted objects go back into the layers (by name) - /// they were copied from instead of the current layer. + /// Layers panel → Paste Remembers Layers: pasted objects go back into the layers and + /// sublayers they were copied from instead of the current layer. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub paste_remembers_layers: bool, /// File → File Info (its title is [`Document::info_title`]). diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index b4c0f9fdd8..39fe8cbc43 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -55,7 +55,7 @@ use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds, envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, - set_type_ink, smooth_handles, + set_type_ink, smooth_handles, warp_grid, }; pub(crate) use expand::max_outset; pub use expand::{ diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index f197837020..44dc406c1d 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -525,21 +525,106 @@ pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> { doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. }))) } -/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid, -/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to -/// edit (its points and handles). +/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on its +/// [`warp_grid`], a mesh envelope's own grid) or its top object's outline, and for a warp or mesh +/// envelope the grid to edit (its points and handles; none while its contents are edited). pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option)> { - let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; + let NodeKind::Envelope { kind, editing, .. } = &n.kind else { return None }; + let points = |grid: GradientMesh| (!*editing).then_some(grid); match &**kind { - EnvelopeKind::Mesh { rows, cols, points, handles } => { - let grid = envelope_grid(*rows, *cols, points, handles)?; - Some((grid.lines(), Some(grid))) + EnvelopeKind::Mesh { rows, cols, points: at, handles } => { + let grid = envelope_grid(*rows, *cols, at, handles)?; + Some((grid.lines(), points(grid))) } - EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)), + EnvelopeKind::Warp { .. } => match warp_grid(n) { + Some(grid) => Some((grid.lines(), points(grid))), + None => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)), + }, EnvelopeKind::TopObject { path, .. } => Some((path.clone(), None)), } } +/// The grid sizes [`warp_grid`] tries, in order: patches per row and per column. +const WARP_GRIDS: [u32; 3] = [4, 8, 16]; + +/// How far a [`warp_grid`] may draw from the warp it replaces, as a share of the diagonal of the +/// warp's bounds. +const WARP_GRID_TOLERANCE: f64 = 1e-3; + +/// The grid a warp envelope's points and handles are edited on: its surface sampled on the first +/// of 4×4, 8×8 and 16×16 patches whose curves draw within [`WARP_GRID_TOLERANCE`] of the warp, with +/// handles along it, as a gradient mesh with black points. Editing a point makes the envelope a +/// mesh envelope with this grid. `None` for other nodes, for content with no bounds, and for a warp +/// that no such grid follows closely enough or whose grid isn't finite. +/// Built again only when the envelope's content objects, warp, frame or options change, or what +/// type is measured with ([`set_type_ink`]). +pub fn warp_grid(n: &Node) -> Option { + let NodeKind::Envelope { content, kind, frame, options, .. } = &n.kind else { return None }; + if !matches!(**kind, EnvelopeKind::Warp { .. }) { + return None; + } + // `None` until a measure is registered, so a grid kept before then is built again after. + let inputs = TYPE_INK.get().map(|(_, inputs)| inputs()); + let key: Vec = content.iter().map(|c| Arc::as_ptr(c) as usize).collect(); + let same = |k: &KeptGrid| k.kind == **kind && k.frame == **frame && k.options == *options && k.inputs == inputs; + if let Some(grid) = WARP_GRIDS_KEPT.with_borrow(|kept| kept.get(&key).filter(|k| same(k)).map(|k| k.grid.clone())) { + return grid; + } + let grid = build_warp_grid(n); + WARP_GRIDS_KEPT.with_borrow_mut(|kept| { + if kept.len() >= WARP_GRID_KEPT { + // Entries for content that no longer exists go first, then one entry at a time. + kept.retain(|_, k| k.content.iter().all(|c| c.strong_count() > 0)); + if kept.len() >= WARP_GRID_KEPT + && let Some(any) = kept.keys().next().cloned() + { + kept.remove(&any); + } + } + // The weak pointers keep the content's addresses from being reused while the entry is kept. + let kept_content = content.iter().map(Arc::downgrade).collect(); + kept.insert(key, KeptGrid { content: kept_content, kind: (**kind).clone(), frame: **frame, options: *options, inputs, grid: grid.clone() }); + }); + grid +} + +/// A grid [`warp_grid`] built, with what it was built from. +struct KeptGrid { + content: Vec>, + kind: EnvelopeKind, + frame: Affine, + options: EnvelopeOptions, + inputs: Option, + grid: Option, +} + +/// How many grids [`warp_grid`] keeps on one thread. +const WARP_GRID_KEPT: usize = 1024; + +std::thread_local! { + /// The grids [`warp_grid`] built on this thread, by the addresses of their envelopes' content. + static WARP_GRIDS_KEPT: std::cell::RefCell, KeptGrid>> = Default::default(); +} + +/// [`warp_grid`], built from scratch. +fn build_warp_grid(n: &Node) -> Option { + let map = EnvelopeMap::of(n)?; + let bounds = map.bounds()?; + let tol = bounds.width().hypot(bounds.height()) * WARP_GRID_TOLERANCE; + if !tol.is_finite() { + return None; + } + WARP_GRIDS.into_iter().find_map(|k| { + let m = map.surface_mesh(k, k, Color::BLACK); + let finite = m.points.iter().all(|q| q.p.is_finite() && q.handles.iter().all(|h| h.is_finite())); + // Each patch is checked at its corners, the middles of its sides and its middle. + let steps = 2 * k as usize; + let at = |i: usize| i as f64 / steps as f64; + let fits = || (0..=steps).all(|j| (0..=steps).all(|i| mesh_at(&m, at(i), at(j)).distance(map.at(at(i), at(j))) <= tol)); + (finite && fits()).then_some(m) + }) +} + /// The frame an envelope keeps after transform `m` (its frame so far composed with the /// transform): identity when its axes stay square to the page, unflipped, since evaluating on /// the page axes gives the same result then. @@ -760,6 +845,163 @@ mod tests { assert!(content_box(&nested, false, Affine::IDENTITY).is_some()); } + /// A warp envelope of `style` at `bend` with map `version` around a 200×100 rectangle at + /// (100, 50). + fn warp_node(style: &str, bend: f64, h: f64, version: u32) -> Node { + let rect = vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 50.0, 300.0, 150.0)); + let content = Arc::new(Node::path(NodeId(1), rect, crate::Appearance::default_art())); + let kind = EnvelopeKind::Warp { style: style.into(), bend, h, v: 0.0, horizontal: true, version }; + Node::new( + NodeId(2), + NodeKind::Envelope { + content: vec![content], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: EnvelopeOptions::NEW, + frame: Box::new(Affine::IDENTITY), + }, + ) + } + + const STYLES: [&str; 15] = [ + "arc", + "arcLower", + "arcUpper", + "arch", + "bulge", + "shellLower", + "shellUpper", + "flag", + "wave", + "fish", + "rise", + "fisheye", + "inflate", + "squeeze", + "twist", + ]; + + /// Every warp style's Bézier net has a grid to edit: its points lie on the warp, and the grid + /// draws within a thousandth of the warp's diagonal of it, between the grid lines too. The + /// overlay shows that grid's lines and points. + #[test] + fn every_warp_style_has_a_grid_that_keeps_its_shape() { + for style in STYLES { + for (bend, h) in [(50.0, 0.0), (100.0, 0.0), (-100.0, 60.0)] { + let n = warp_node(style, bend, h, crate::live::WARP_NET); + let grid = warp_grid(&n).unwrap_or_else(|| panic!("{style} {bend} {h}: no grid")); + assert!(WARP_GRIDS.contains(&grid.rows) && grid.rows == grid.cols, "{style}: {}×{}", grid.rows, grid.cols); + let map = EnvelopeMap::of(&n).unwrap(); + let b = map.bounds().unwrap(); + let tol = b.width().hypot(b.height()) * 1e-3; + let k = grid.cols as usize; + for (i, q) in grid.points.iter().enumerate() { + let want = map.at((i % (k + 1)) as f64 / k as f64, (i / (k + 1)) as f64 / k as f64); + assert!(q.p.distance(want) < 1e-9, "{style}: point {i} at {:?}, the warp at {want:?}", q.p); + } + for j in 0..=20 { + for i in 0..=20 { + let (u, v) = (i as f64 / 20.0, j as f64 / 20.0); + let off = mesh_at(&grid, u, v).distance(map.at(u, v)); + assert!(off <= tol, "{style} {bend} {h}: {off} off at ({u}, {v})"); + } + } + let (lines, shown) = envelope_overlay(&n).unwrap(); + assert_eq!(shown.as_ref(), Some(&grid), "{style}"); + assert_eq!(lines, grid.lines(), "{style}"); + } + } + } + + /// A kept warp grid is built again when the content, the warp, the frame or the options change, + /// and is the grid built from scratch otherwise. + #[test] + fn a_kept_warp_grid_follows_changes() { + let n = warp_node("arc", 50.0, 0.0, crate::live::WARP_NET); + let first = warp_grid(&n); + assert!(first.is_some()); + assert_eq!(warp_grid(&n), first); + assert_eq!(build_warp_grid(&n), first); + let changed = |f: &dyn Fn(&mut Node)| { + let mut m = n.clone(); + f(&mut m); + let grid = warp_grid(&m); + assert_eq!(grid, build_warp_grid(&m)); + assert_ne!(grid, first); + }; + changed(&|m| { + if let NodeKind::Envelope { content, .. } = &mut m.kind { + Arc::make_mut(&mut content[0]).transform(Affine::translate((40.0, 0.0)), true); + } + }); + changed(&|m| { + if let NodeKind::Envelope { kind, .. } = &mut m.kind + && let EnvelopeKind::Warp { bend, .. } = &mut **kind + { + *bend = -30.0; + } + }); + changed(&|m| { + if let NodeKind::Envelope { frame, .. } = &mut m.kind { + **frame = Affine::rotate(0.3); + } + }); + changed(&|m| { + if let NodeKind::Envelope { options, .. } = &mut m.kind { + options.bounds_margin = [0.1; 4]; + } + }); + } + + /// The table of kept warp grids stays bounded: full of envelopes that still exist, it drops one + /// entry at a time instead of emptying, and it drops the entries of envelopes that are gone + /// first. + #[test] + fn the_kept_warp_grids_stay_bounded() { + let nodes: Vec = (0..WARP_GRID_KEPT + 10).map(|i| warp_node("arc", (i % 100) as f64, 0.0, crate::live::WARP_NET)).collect(); + for n in &nodes { + assert!(warp_grid(n).is_some()); + } + assert_eq!(WARP_GRIDS_KEPT.with_borrow(|kept| kept.len()), WARP_GRID_KEPT); + // Once the envelopes are gone, their entries are the ones dropped. + drop(nodes); + assert!(warp_grid(&warp_node("arc", 10.0, 0.0, crate::live::WARP_NET)).is_some()); + assert_eq!(WARP_GRIDS_KEPT.with_borrow(|kept| kept.len()), 1); + } + + /// A warp drawn by the old point map that no grid of up to 16×16 patches follows closely + /// enough, content without bounds and other envelopes have no warp grid; the overlay still + /// draws the old warp's lines. While an envelope's contents are edited, it shows no points. + #[test] + fn a_warp_no_grid_follows_has_no_grid_to_edit() { + let n = warp_node("fisheye", 100.0, 0.0, 0); + assert!(warp_grid(&n).is_none()); + let (lines, grid) = envelope_overlay(&n).unwrap(); + assert!(grid.is_none()); + assert!(lines.anchor_count() > 0); + // The same style on its Bézier net has one. + assert!(warp_grid(&warp_node("fisheye", 100.0, 0.0, crate::live::WARP_NET)).is_some()); + let mut empty = warp_node("arc", 50.0, 0.0, crate::live::WARP_NET); + if let NodeKind::Envelope { content, .. } = &mut empty.kind { + content.clear(); + } + assert!(warp_grid(&empty).is_none()); + let mut mesh = warp_node("arc", 50.0, 0.0, crate::live::WARP_NET); + if let NodeKind::Envelope { kind, .. } = &mut mesh.kind { + **kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(0.0, 0.0, 1.0, 1.0), 1, 1), handles: vec![] }; + } + assert!(warp_grid(&mesh).is_none()); + // While the contents are edited, a warp's and a mesh envelope's lines show without points. + for mut n in [warp_node("arc", 50.0, 0.0, crate::live::WARP_NET), mesh] { + if let NodeKind::Envelope { editing, .. } = &mut n.kind { + *editing = true; + } + let (lines, grid) = envelope_overlay(&n).unwrap(); + assert!(grid.is_none() && lines.anchor_count() > 0); + } + } + /// A table that fills up is emptied instead of growing, and what is looked up after that is /// measured again. #[test] diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 80db3d923a..ba79f7c280 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -5,7 +5,7 @@ //! tint (`Paint::Solid.swatch` / `.tint`, `GradientStop.swatch` / `.tint`); a tint swatch is a //! swatch whose own paint links to its base that way. [`Document::map_solid_paints`] rewrites //! those links (and colours) wherever art and swatches hold them, copying only the nodes that -//! change. +//! change; [`Document::map_paints`] replaces whole paints of any kind in the same places. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -124,6 +124,52 @@ impl Document { /// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are /// copied. Returns the number of changed paints. pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize { + let mut n = self.map_art_paints(&mut relink(f)); + for gs in &mut self.graphic_styles { + for it in &mut gs.appearance.items { + n += usize::from(item_paint_mut(it).map_links(f)); + } + } + for w in self.swatches_iter_mut() { + n += usize::from(w.paint.map_links(f)); + } + let mut g = relink(f); + for def in &mut self.char_styles { + for key in ["fill", "stroke"] { + n += usize::from(map_style_paint(&mut def.attrs, key, &mut g)); + } + } + n + } + + /// Visit every paint in the places [`Document::map_solid_paints`] visits (fills, strokes, text + /// runs, image colorize paints, dynamic symbol instances' own paints and graphs' stored series and + /// label paints in the art, opacity masks included, symbol definitions, graph designs, pattern tiles and the tile that pattern + /// editing's Cancel puts back; graphic styles; the swatches; character styles' fill and + /// stroke), whatever its kind. `f(paint)` gives the paint to put in its place, or `None` to keep + /// it. Only nodes holding a changed paint (and the paths to them) are copied. Returns the number + /// of changed paints. + pub fn map_paints(&mut self, f: &mut PaintFn) -> usize { + let mut n = self.map_art_paints(f); + for gs in &mut self.graphic_styles { + for it in &mut gs.appearance.items { + n += usize::from(replace_paint(item_paint_mut(it), f)); + } + } + for w in self.swatches_iter_mut() { + n += usize::from(replace_paint(&mut w.paint, f)); + } + for def in &mut self.char_styles { + for key in ["fill", "stroke"] { + n += usize::from(map_style_paint(&mut def.attrs, key, f)); + } + } + n + } + + /// [`Document::map_paints`] limited to the node trees: the art, symbol definitions, graph + /// designs, pattern tiles and the tile that pattern editing's Cancel puts back. + fn map_art_paints(&mut self, f: &mut PaintFn) -> usize { let mut n = map_trees(&mut self.layers, f); for s in &mut self.symbols { n += map_tree(&mut s.art, f); @@ -134,19 +180,6 @@ impl Document { for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) { n += map_trees(&mut p.art, f); } - for gs in &mut self.graphic_styles { - for it in &mut gs.appearance.items { - n += usize::from(item_paint_mut(it).map_links(f)); - } - } - for w in self.swatches_iter_mut() { - n += usize::from(w.paint.map_links(f)); - } - for def in &mut self.char_styles { - for key in ["fill", "stroke"] { - n += usize::from(map_style_paint(&mut def.attrs, key, f)); - } - } n } @@ -156,9 +189,10 @@ impl Document { let roots: Vec = ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect(); let mut n = 0; + let mut g = relink(f); for id in roots { let Some(node) = self.node(id) else { continue }; - if let Some((new, count)) = map_node(node, f) + if let Some((new, count)) = map_node(node, &mut g) && let Some(slot) = self.node_mut(id) { *slot = new; @@ -176,7 +210,7 @@ impl Document { pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) { let mut tree = |n: &Node| { walk_with_masks(n, &mut |m| { - node_paints(m).chain(override_paints(m)).for_each(&mut *f); + node_paints(m).chain(override_paints(m)).chain(graph_paints(m)).for_each(&mut *f); if let NodeKind::Mesh(mesh) = &m.kind { mesh.points.iter().for_each(|p| f(&Paint::solid(p.color))); } @@ -258,14 +292,39 @@ impl FreeSwatchNames { /// true when it changed them. pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool + 'a; -/// Apply `f` to the paint a character style's attribute `key` holds, as [`Paint::map_links`] -/// does, and store the result in the attribute. True when it changed. -fn map_style_paint(attrs: &mut serde_json::Map, key: &str, f: &mut LinkFn) -> bool { - let Some(value) = attrs.get_mut(key) else { return false }; - let Ok(mut paint) = serde_json::from_value::(value.clone()) else { return false }; - if !paint.map_links(f) { - return false; +/// What [`Document::map_paints`] applies to each paint: the paint to put in its place, or `None` +/// to keep it. +pub type PaintFn<'a> = dyn FnMut(&Paint) -> Option + 'a; + +/// `f` as a [`PaintFn`]: a solid or gradient paint with `f` applied ([`Paint::map_links`]) when +/// `f` changed it, `None` for every other paint. +fn relink<'a>(f: &'a mut LinkFn<'_>) -> impl FnMut(&Paint) -> Option + 'a { + move |p| match p { + Paint::Solid { .. } | Paint::Gradient(_) => { + let mut q = p.clone(); + q.map_links(f).then_some(q) + } + _ => None, } +} + +/// Put `f(p)` in place of `p` when it gives a paint. True when it did. +fn replace_paint(p: &mut Paint, f: &mut PaintFn) -> bool { + match f(p) { + Some(q) => { + *p = q; + true + } + None => false, + } +} + +/// Apply `f` to the paint a character style's attribute `key` holds and store the result in the +/// attribute. An attribute that is not a paint stays as it is. True when it changed. +fn map_style_paint(attrs: &mut serde_json::Map, key: &str, f: &mut PaintFn) -> bool { + let Some(value) = attrs.get_mut(key) else { return false }; + let Ok(paint) = serde_json::from_value::(value.clone()) else { return false }; + let Some(paint) = f(&paint) else { return false }; match serde_json::to_value(&paint) { Ok(changed) => { *value = changed; @@ -323,6 +382,23 @@ pub fn override_paints(n: &Node) -> impl Iterator { n.extras().symbol_overrides.iter().flat_map(|o| [o.fill.as_ref(), o.stroke.as_ref()]).flatten() } +/// The paints a graph keeps for its series and its labels ([`crate::graph::GraphSpec`]), which its +/// marks and labels take each time it is drawn again and which [`node_paints`] leaves out. +pub fn graph_paints(n: &Node) -> impl Iterator { + let g = n.extras().graph.as_deref(); + let series = g.into_iter().flat_map(|g| &g.series_paints).flat_map(|s| [s.fill.as_ref(), s.stroke.as_ref()]).flatten(); + let labels = g.into_iter().flat_map(|g| [&g.label_styles.values, &g.label_styles.categories, &g.label_styles.legend]).flatten(); + series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])) +} + +/// Mutable [`graph_paints`] of graph `g`, in the same order. +fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator { + let series = g.series_paints.iter_mut().flat_map(|s| [s.fill.as_mut(), s.stroke.as_mut()]).flatten(); + let l = &mut g.label_styles; + let labels = [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten(); + series.chain(labels.flat_map(|st| [&mut st.fill, &mut st.stroke])) +} + /// Mutable [`node_paints`], in the same order. pub fn node_paints_mut(n: &mut Node) -> impl Iterator { let runs = match &mut n.kind { @@ -333,20 +409,11 @@ pub fn node_paints_mut(n: &mut Node) -> impl Iterator { n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])).chain(colorize) } -/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then -/// nothing was copied). Also returns the number of changed paints. -fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { - // Try each own solid or gradient paint on a scratch copy first, so unchanged nodes are never - // cloned. - let mut changes: Vec<(usize, Paint)> = vec![]; - for (i, p) in node_paints(n).enumerate() { - if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) { - let mut q = p.clone(); - if q.map_links(f) { - changes.push((i, q)); - } - } - } +/// `n` with `f` applied to its subtree's paints, or `None` when nothing changed (then nothing was +/// copied). Also returns the number of changed paints. +fn map_node(n: &Node, f: &mut PaintFn) -> Option<(Node, usize)> { + // `f` gives each own paint's replacement first, so unchanged nodes are never cloned. + let changes: Vec<(usize, Paint)> = node_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect(); let mut count = changes.len(); let mut out: Option = None; if !changes.is_empty() { @@ -366,15 +433,29 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { let mut overrides = n.extras().symbol_overrides.clone(); let mut k = 0; for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() { - if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) { - k += 1; - } + k += usize::from(replace_paint(p, f)); } if k > 0 { out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides; count += k; } } + // A graph's series and label paints, which its marks and labels take when it is drawn again. + let graph: Vec<(usize, Paint)> = graph_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect(); + if !graph.is_empty() { + count += graph.len(); + let m = out.get_or_insert_with(|| n.clone()); + if let Some(spec) = m.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + let mut graph = graph.into_iter().peekable(); + for (i, p) in graph_paints_mut(spec).enumerate() { + if graph.peek().is_some_and(|c| c.0 == i) + && let Some((_, q)) = graph.next() + { + *p = q; + } + } + } + } if let Some(ch) = n.children() { for (i, c) in ch.iter().enumerate() { if let Some((new, k)) = map_node(c, f) { @@ -415,6 +496,13 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) { /// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only /// the nodes holding a changed paint are copied. Returns the number of changed paints. pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize { + map_node_paints(n, &mut relink(f)) +} + +/// [`Document::map_paints`] for art a document keeps outside its trees, such as the art of a brush: +/// `f` applied to every paint of `n` and its subtree (opacity masks included). Only the nodes +/// holding a changed paint are copied. Returns the number of changed paints. +pub fn map_node_paints(n: &mut Node, f: &mut PaintFn) -> usize { match map_node(n, f) { Some((new, count)) => { *n = new; @@ -424,7 +512,7 @@ pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize { } } -fn map_tree(n: &mut Arc, f: &mut LinkFn) -> usize { +fn map_tree(n: &mut Arc, f: &mut PaintFn) -> usize { match map_node(n, f) { Some((new, count)) => { *n = Arc::new(new); @@ -434,7 +522,7 @@ fn map_tree(n: &mut Arc, f: &mut LinkFn) -> usize { } } -fn map_trees(v: &mut [Arc], f: &mut LinkFn) -> usize { +fn map_trees(v: &mut [Arc], f: &mut PaintFn) -> usize { v.iter_mut().map(|n| map_tree(n, f)).sum() } @@ -593,6 +681,35 @@ mod tests { assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 0, "nothing left to change"); } + #[test] + fn mapping_reaches_the_series_and_label_paints_a_graph_keeps() { + let (mut d, a, _) = doc(); + let red = linked(Color::rgb(1.0, 0.0, 0.0), "Brand"); + let spec = crate::graph::GraphSpec { + series_paints: vec![crate::SeriesPaint { fill: Some(red.clone()), stroke: Some(red.clone()), ..Default::default() }], + label_styles: crate::graph::LabelStyles { + legend: Some(crate::CharStyle { fill: red.clone(), stroke: Paint::None, ..Default::default() }), + ..Default::default() + }, + ..Default::default() + }; + d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec)); + let mut seen = 0; + d.visit_paints(&mut |p| seen += usize::from(*p == red)); + assert_eq!(seen, 4, "the fill and the graph's series fill, series stroke and legend fill"); + let blue = Color::rgb(0.0, 0.0, 1.0); + let mut to_blue = |c: &mut Color, s: &mut Option, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue; + assert_eq!(d.map_solid_paints(&mut to_blue), 4); + let g = d.node(a).unwrap().extras().graph.clone().unwrap(); + assert_eq!(g.series_paints[0].fill, Some(linked(blue, "Brand"))); + assert_eq!(g.series_paints[0].stroke, Some(linked(blue, "Brand"))); + let legend = g.label_styles.legend.unwrap(); + assert_eq!((legend.fill, legend.stroke), (linked(blue, "Brand"), Paint::None)); + let mut cleared = |p: &Paint| (*p == linked(blue, "Brand")).then_some(Paint::None); + assert_eq!(d.map_paints(&mut cleared), 4, "the fill and the graph's three"); + assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none)); + } + #[test] fn mapping_within_ids_visits_each_paint_once() { let (mut d, a, b) = doc(); diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 6949aa86fb..53f84e869c 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -10,9 +10,19 @@ //! The cells are sized along the symbol's own axes, and the instance's rotation, shear, reflection //! and position then apply to them. //! -//! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left -//! and top edges and from the right and bottom edges, so they survive resizing the art. Stored -//! under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry. +//! A guide may lie outside the art, and the instance still scales, by the slices the art falls in +//! ([the help page](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), +//! Use 9-slice scaling section). VectorCraft's own rules for that: a guide past an edge of the art +//! cuts the art at that edge, so the outer cell on its side holds no art; with both guides of an +//! axis past opposite edges the art scales as a whole along it; with both past the same edge the +//! art lies in the outer cell at the other edge and keeps its size, from that edge of the instance, +//! or shrinks to the instance's width or height when the instance is smaller than the art. +//! +//! The guides are stored as fractions of the symbol's natural size, measured from the left and top +//! edges and from the right and bottom edges, so they survive resizing the art. A fraction below 0 +//! puts a guide past its own edge, and one above 1 past the opposite edge; each lies within +//! ±[`MAX_GUIDE_FRACTION`], and the two guides of an axis keep their order. Stored under +//! [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry. //! //! [`NineSliceMap`] places such an instance's art for the canvas, bounds, Break Link, Expand and //! the exporters ([`Document::place_symbol_art`]). @@ -49,11 +59,16 @@ impl Default for SymbolNineSlice { } } +/// The farthest a guide may lie from its edge, as a fraction of the symbol's width or height, in +/// either direction (VectorCraft's own rule). +pub const MAX_GUIDE_FRACTION: f64 = 16.0; + impl SymbolNineSlice { - /// Guides from `[left, top, right, bottom]`; `None` unless each is finite, within 0..1, and the - /// two on each axis leave a gap between them. + /// Guides from `[left, top, right, bottom]`; `None` unless each is finite and within + /// ±[`MAX_GUIDE_FRACTION`], and the two on each axis leave a gap between them + /// (`left + right < 1`, `top + bottom < 1`). pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Option { - let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v); + let ok = |v: f64| v.is_finite() && (-MAX_GUIDE_FRACTION..=MAX_GUIDE_FRACTION).contains(&v); (ok(left) && ok(top) && ok(right) && ok(bottom) && left + right < 1.0 && top + bottom < 1.0).then_some(Self { left, top, right, bottom }) } @@ -102,7 +117,8 @@ pub struct EditedNineSlice { } impl EditedNineSlice { - /// Guide `g` as a line across the art's box, from one edge to the other, in the document. + /// Guide `g` as a line across the art's box, from one edge to the other, in the document. A + /// guide outside the art spans the art's height or width beside it. pub fn line(&self, g: NineSliceGuide) -> (Point, Point) { let (b, s) = (self.art, self.guides); let (a, c) = match g { @@ -126,14 +142,17 @@ impl EditedNineSlice { }) } - /// The guides with guide `g` moved across its axis to document point `p`. The guide stays - /// within the art's box, and [`MIN_CENTER_BAND`] short of the opposite guide, or where it is - /// when the guides are already closer than that. A point that is not finite leaves the guides - /// as they are. + /// The guides with guide `g` moved across its axis to document point `p`. The guide may pass + /// either edge of the art, up to [`MAX_GUIDE_FRACTION`] from its own edge, and stops + /// [`MIN_CENTER_BAND`] short of the opposite guide, or where it is when the guides are already + /// closer than that. A point that is not finite, or that gives a fraction that is not, leaves + /// the guides as they are. pub fn moved(&self, g: NineSliceGuide, p: Point) -> SymbolNineSlice { let (b, mut out) = (self.art, self.guides); let q = self.to_doc.inverse() * p; - let fit = |v: f64, other: f64, now: f64| if v.is_finite() { v.clamp(0.0, (1.0 - other - MIN_CENTER_BAND).max(now)) } else { now }; + let fit = |v: f64, other: f64, now: f64| { + if v.is_finite() { v.clamp(-MAX_GUIDE_FRACTION, (1.0 - other - MIN_CENTER_BAND).min(MAX_GUIDE_FRACTION).max(now)) } else { now } + }; match g { NineSliceGuide::Left => out.left = fit((q.x - b.x0) / b.width(), out.right, out.left), NineSliceGuide::Right => out.right = fit((b.x1 - q.x) / b.width(), out.left, out.right), @@ -181,20 +200,27 @@ const MAX_SLICE_DEPTH: u32 = 64; /// One axis of a 9-slice map: the three cells between the cuts and where each one goes. #[derive(Clone, Copy, Debug, PartialEq)] struct AxisMap { - /// The cuts in the art at its natural size: its start, the two guides, its end. + /// The cuts in the art at its natural size: its start, the two guides, its end. A guide + /// outside the art cuts it at the nearer end, so a cell can be empty. src: [f64; 4], /// Where the cuts go. The outer cells keep their length unless the scaled length is shorter - /// than both together; then both shrink in proportion and the middle cell is empty. + /// than both together; then both shrink in proportion and the middle cell is empty. An empty + /// cell's start is where the cell before it ends, so the map stays continuous. dst: [f64; 4], } impl AxisMap { - /// The axis from `lo` to `hi` with guides `near` and `far` (fractions of its length in from - /// each end), scaled by `k` (above 0). + /// The axis from `lo` to `hi` (`lo < hi`) with guides `near` and `far` (fractions of its + /// length in from each end, `near + far < 1`; either may lie past an end), scaled by `k` + /// (above 0). With both guides past the far end the art keeps its length from the scaled + /// start, and with both past the near end it keeps its length up to the scaled end; when the + /// scaled length is shorter than the art, the art shrinks to the scaled length. fn new(lo: f64, hi: f64, near: f64, far: f64, k: f64) -> Self { let len = hi - lo; - let (a, b) = (near * len, far * len); - let src = [lo, lo + a, hi - b, hi]; + let c1 = (lo + near * len).clamp(lo, hi); + let c2 = (hi - far * len).clamp(c1, hi); + let src = [lo, c1, c2, hi]; + let (a, b) = (c1 - lo, hi - c2); let (t0, t3) = (k * lo, k * hi); let room = t3 - t0; let (a, b) = if a + b <= room { @@ -203,9 +229,15 @@ impl AxisMap { let f = (room / (a + b)).max(0.0); (a * f, b * f) }; - let t1 = t0 + a; - let t2 = (t3 - b).max(t1); - Self { src, dst: [t0, t1, t2, t3] } + let dst = if c2 > c1 { + let t1 = t0 + a; + [t0, t1, (t3 - b).max(t1), t3] + } else if a > 0.0 { + [t0, t0 + a, t0 + a, t3] + } else { + [t3 - b, t3 - b, t3 - b, t3] + }; + Self { src, dst } } /// The cell `v` lies in; values outside the art belong to the outer cells. @@ -279,10 +311,11 @@ pub struct NineSliceMap { impl NineSliceMap { /// The map for an instance placed by `xf` that shows art with natural-size transform - /// `natural` and geometric bounds `art` (in symbol space). `None` when the art or the - /// transform is degenerate, or when the instance is at its natural size on both axes (then - /// `xf` places the art as it is). + /// `natural` and geometric bounds `art` (in symbol space). `None` when the guides are not ones + /// [`SymbolNineSlice::new`] accepts, when the art or the transform is degenerate, or when the + /// instance is at its natural size on both axes (then `xf` places the art as it is). pub fn new(guides: SymbolNineSlice, art: Rect, natural: Affine, xf: Affine) -> Option { + let guides = SymbolNineSlice::new(guides.left, guides.top, guides.right, guides.bottom)?; let b = natural.transform_rect_bbox(art); if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= 1e-9 || b.height() <= 1e-9 { return None; @@ -894,6 +927,113 @@ mod tests { assert!(got.iter().all(|b| finite(*b)), "{got:?}"); } + /// Guides may lie outside the art, up to 16 times its size from their edge, as long as the + /// two on each axis keep their order; values that are not finite are refused. + #[test] + fn guides_outside_the_art_are_accepted_in_order_and_within_the_limit() { + let ok = |l, t, r, b| SymbolNineSlice::new(l, t, r, b).is_some(); + assert!(ok(-0.25, 0.25, 0.25, 0.25)); + assert!(ok(1.25, 0.25, -0.5, 0.25), "both past the right edge"); + assert!(ok(0.25, -16.0, 0.25, 16.0) && ok(-16.0, 0.25, -16.0, 0.25)); + assert!(!ok(-16.5, 0.25, 0.25, 0.25) && !ok(0.25, 0.25, 0.25, 17.0)); + assert!(!ok(f64::NAN, 0.25, 0.25, 0.25) && !ok(0.25, f64::INFINITY, 0.25, 0.25) && !ok(0.25, 0.25, f64::NEG_INFINITY, 0.25)); + assert!(!ok(1.2, 0.25, -0.2, 0.25), "the left guide on the right one"); + assert!(!ok(0.25, 0.6, 0.25, 0.6), "crossed"); + let g = SymbolNineSlice::from_value(&json!([-0.25, 1.5, 0.5, -1.0])).unwrap(); + assert_eq!(g, SymbolNineSlice { left: -0.25, top: 1.5, right: 0.5, bottom: -1.0 }); + assert_eq!(SymbolNineSlice::from_value(&g.to_value()), Some(g)); + assert!(SymbolNineSlice::from_value(&json!([-0.25, 0.1, 1.5])).is_none()); + assert!(SymbolNineSlice::from_value(&json!([-20.0, 0.1, 0.5, 0.1])).is_none()); + // Guides built without the check are not used to place art. + let bad = SymbolNineSlice { left: f64::NAN, ..SymbolNineSlice::default() }; + assert!(NineSliceMap::new(bad, Rect::new(0.0, 0.0, 100.0, 100.0), Affine::IDENTITY, Affine::scale(2.0)).is_none()); + // Art without width or height has no map, whatever the guides. + let out = SymbolNineSlice::new(-0.25, 1.25, 0.25, -0.5).unwrap(); + for art in [Rect::new(0.0, 0.0, 0.0, 100.0), Rect::new(0.0, 0.0, 100.0, 0.0)] { + assert!(NineSliceMap::new(out, art, Affine::IDENTITY, Affine::scale(2.0)).is_none(), "{art:?}"); + } + } + + /// At 200 × 150, a left guide 25 % outside the art leaves the art in the center and right + /// columns: the left column holds no art, the center one (0..75) stretches to 0..175 and the + /// right one keeps its 25. Both guides outside on opposite sides scale the art as a whole on + /// that axis; both past the same side keep the art at its size in the outer cell at the other + /// side, from that side of the instance. + #[test] + fn art_scales_by_the_cells_it_falls_in_when_guides_lie_outside_it() { + let xf = Affine::scale_non_uniform(2.0, 1.5); + let d = markers(SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25)); + let got = boxes(&d, xf); + let k = 175.0 / 75.0; + assert!(near(got[0], Rect::new(0.0, 0.0, 200.0, 150.0)), "{:?}", got[0]); + assert!(near(got[1], Rect::new(0.0, 0.0, 10.0 * k, 10.0)), "the top-left square stretches: {:?}", got[1]); + assert!(near(got[2], Rect::new(190.0, 0.0, 200.0, 10.0)), "the top-right corner keeps its size: {:?}", got[2]); + assert!(near(got[5], Rect::new(45.0 * k, 65.0, 55.0 * k, 85.0)), "{:?}", got[5]); + // Both vertical guides outside, on opposite sides: the art scales as a whole across. + let d = markers(SymbolNineSlice::new(-0.25, 0.25, -1.0, 0.25)); + let got = boxes(&d, xf); + assert!(near(got[1], Rect::new(0.0, 0.0, 20.0, 10.0)) && near(got[2], Rect::new(180.0, 0.0, 200.0, 10.0)), "{got:?}"); + assert!(near(got[5], Rect::new(90.0, 65.0, 110.0, 85.0)), "{:?}", got[5]); + // Both past the right edge: the art keeps its width from the instance's left edge. + let d = markers(SymbolNineSlice::new(1.2, 0.25, -0.5, 0.25)); + let got = boxes(&d, xf); + assert!(near(got[0], Rect::new(0.0, 0.0, 100.0, 150.0)), "{:?}", got[0]); + assert!(near(got[2], Rect::new(90.0, 0.0, 100.0, 10.0)) && near(got[5], Rect::new(45.0, 65.0, 55.0, 85.0)), "{got:?}"); + // Both past the left edge: the art keeps its width up to the instance's right edge. + let d = markers(SymbolNineSlice::new(-0.5, 0.25, 1.2, 0.25)); + let got = boxes(&d, xf); + assert!(near(got[0], Rect::new(100.0, 0.0, 200.0, 150.0)), "{:?}", got[0]); + assert!(near(got[1], Rect::new(100.0, 0.0, 110.0, 10.0)), "{:?}", got[1]); + // The same rules apply to rows: both horizontal guides above the art keep its height up to + // the instance's bottom edge. + let d = markers(SymbolNineSlice::new(0.25, -0.5, 0.25, 1.2)); + let got = boxes(&d, xf); + assert!(near(got[0], Rect::new(0.0, 50.0, 200.0, 150.0)), "{:?}", got[0]); + assert!(near(got[3], Rect::new(0.0, 140.0, 10.0, 150.0)), "{:?}", got[3]); + // Smaller than the art, an art-covered outer cell shrinks in proportion. + let d = markers(SymbolNineSlice::new(1.2, 0.25, -0.5, 0.25)); + let got = boxes(&d, Affine::scale_non_uniform(0.5, 1.5)); + assert!(near(got[0], Rect::new(0.0, 0.0, 50.0, 150.0)) && near(got[1], Rect::new(0.0, 0.0, 5.0, 10.0)), "{got:?}"); + } + + /// With guides outside the art, the map stays finite, never runs backward, and does not jump + /// at the art's edges or at the guides; objects keep to the cells that hold art. + #[test] + fn the_map_with_guides_outside_the_art_is_continuous_and_monotone() { + let cases = [(-0.25, 0.25), (-0.25, -1.0), (1.2, -0.5), (-0.5, 1.2), (0.0, -16.0), (-16.0, 16.0), (0.25, 0.25)]; + for (l, r) in cases { + let d = markers(SymbolNineSlice::new(l, 0.25, r, 0.25)); + for sx in [0.3, 2.0, 1e6] { + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(sx, 1.5)).unwrap(); + let at = |x: f64| m.map_point(Point::new(x, 40.0)).x; + let mut last = f64::NEG_INFINITY; + for i in 0..=1200 { + let x = -10.0 + i as f64 * 0.1; + let v = at(x); + assert!(v.is_finite() && v >= last - 1e-12 * last.abs().max(1.0), "{l} {r} {sx}: {x} → {v} after {last}"); + last = v; + } + for edge in [0.0, 75.0, 100.0] { + let jump = (at(edge + 1e-9) - at(edge - 1e-9)).abs(); + assert!(jump < 1e-6 * sx.max(1.0), "{l} {r} {sx}: a jump of {jump} at {edge}"); + } + } + } + // A gradient bar across the art with the left guide outside is drawn in the two columns + // that hold art, not three. + let mut d = markers(SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25)); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + assert_eq!(m.place(&bar, &d).children().map(Vec::len), Some(2)); + assert_eq!(m.most_placed(&bar, &d), 7); + // With both guides outside on opposite sides, the bar lies in one cell and is moved whole. + let d = markers(SymbolNineSlice::new(-0.25, 0.25, -0.25, 0.25)); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + assert!(matches!(m.place(&bar, &d).kind, NodeKind::Path { .. })); + assert_eq!(m.most_placed(&bar, &d), 1); + } + /// A negative scale reflects the sized cells; a rotation turns them, the corners keeping /// their size along the symbol's own axes. #[test] @@ -1456,27 +1596,37 @@ mod tests { assert!(d.edited_nine_slice().is_none()); } - /// Moving a guide changes that guide alone, follows the pointer across its axis only, stays - /// within the art's box and stops short of the opposite guide. + /// Moving a guide changes that guide alone, follows the pointer across its axis only, may pass + /// either edge of the art up to [`MAX_GUIDE_FRACTION`], and stops short of the opposite guide. #[test] - fn a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide() { + fn a_moved_guide_passes_the_arts_edges_and_stops_short_of_the_opposite_guide() { let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0); let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap(); let q = SymbolNineSlice { left: 0.25, top: 0.25, right: 0.25, bottom: 0.25 }; assert_eq!(e.moved(NineSliceGuide::Left, Point::new(30.0, 999.0)), SymbolNineSlice { left: 0.1, ..q }); assert_eq!(e.moved(NineSliceGuide::Bottom, Point::new(-50.0, 180.0)), SymbolNineSlice { bottom: 0.2, ..q }); - assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, -500.0)), SymbolNineSlice { top: 0.0, ..q }); - assert_eq!(e.moved(NineSliceGuide::Right, Point::new(1e6, 0.0)), SymbolNineSlice { right: 0.0, ..q }); + // Past the art's near edges: the art spans 10..210 across and 20..220 down. + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-40.0, 50.0)), SymbolNineSlice { left: -0.25, ..q }); + assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, -500.0)), SymbolNineSlice { top: -2.6, ..q }); + assert_eq!(e.moved(NineSliceGuide::Right, Point::new(1e6, 0.0)), SymbolNineSlice { right: -MAX_GUIDE_FRACTION, ..q }); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-1e300, 0.0)), SymbolNineSlice { left: -MAX_GUIDE_FRACTION, ..q }); let crossed = e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)); assert!((crossed.left - (0.75 - MIN_CENTER_BAND)).abs() < 1e-12, "{crossed:?}"); assert!(SymbolNineSlice::new(crossed.left, crossed.top, crossed.right, crossed.bottom).is_some()); assert_eq!(e.moved(NineSliceGuide::Top, Point::new(f64::NAN, f64::NAN)), q); + assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, f64::INFINITY)), q); + // With the right guide past the art's right edge, the left one may pass that edge too. + let wide = SymbolNineSlice { right: -0.5, ..q }; + let e = editing(Some(wide), xf).edited_nine_slice().unwrap(); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(260.0, 50.0)), SymbolNineSlice { left: 1.25, ..wide }); + assert!(near_point(e.line(NineSliceGuide::Right).0, Point::new(310.0, 20.0))); + assert!(near_point(e.line(NineSliceGuide::Right).1, Point::new(310.0, 220.0))); // Saved guides closer than the minimum center band keep their place when a drag does not // move them closer. let narrow = SymbolNineSlice::new(0.00005, 0.25, 0.9999, 0.25).unwrap(); let e = editing(Some(narrow), xf).edited_nine_slice().unwrap(); assert_eq!(e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)), narrow); - assert_eq!(e.moved(NineSliceGuide::Left, Point::new(0.0, 50.0)), SymbolNineSlice { left: 0.0, ..narrow }); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(0.0, 50.0)), SymbolNineSlice { left: -0.05, ..narrow }); // A mirrored placement measures along the symbol's own axes. let e = editing(Some(SymbolNineSlice::default()), Affine::scale_non_uniform(-1.0, 1.0)).edited_nine_slice().unwrap(); assert!(near_point(e.line(NineSliceGuide::Left).0, Point::new(-25.0, 0.0))); diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index a2c2a66498..45cf181f18 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -508,6 +508,21 @@ pub enum Mojikumi { /// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket, /// comma or full stop ending a line is set half width. LineEndHalf, + /// YakumonoHankaku: every opening and closing bracket, comma and full stop is set half width, + /// a middle dot loses a quarter em on each side, and a quarter em separates Japanese and Latin + /// letters or digits. + YakumonoHankaku, + /// GyomatsuYakumonoHankaku: punctuation keeps its full width, except that an opening bracket + /// starting a line is set flush with the line's start, a closing bracket, comma or full stop + /// ending a line is set half width and a middle dot ending a line loses the quarter em after + /// it; a quarter em separates Japanese and Latin letters or digits. + GyomatsuYakumonoHankaku, + /// GyomatsuYakumonoZenkaku: punctuation keeps its full width at a line's end too; a quarter em + /// separates Japanese and Latin letters or digits. + GyomatsuYakumonoZenkaku, + /// YakumonoZenkaku: punctuation keeps its full width, with no added space between Japanese and + /// Latin text. + YakumonoZenkaku, } impl Mojikumi { diff --git a/crates/doc/tests/warp_grid_type_ink.rs b/crates/doc/tests/warp_grid_type_ink.rs new file mode 100644 index 0000000000..ae000a3f6a --- /dev/null +++ b/crates/doc/tests/warp_grid_type_ink.rs @@ -0,0 +1,49 @@ +//! A warp grid kept before type's measure is registered is built again once it is, even when the +//! registered inputs number is 0. One test in its own process, so nothing has registered the +//! measure before it runs. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_doc::live::{EnvelopeOptions, set_type_ink, warp_grid}; +use vectorcraft_doc::{CharStyle, EnvelopeKind, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Affine, Point, Rect}; + +/// A measure that gives every type the same box, well away from its laid-out box. +fn fixed_ink(_: &TextObject, _: Affine, _: Option<(vectorcraft_geom::Homography, Affine)>) -> Option { + Some(Rect::new(0.0, 0.0, 400.0, 300.0)) +} + +fn no_inputs() -> u64 { + 0 +} + +/// An arc warp around `t`, its content in a new `Arc`. +fn warp_of(t: &TextObject) -> Node { + let content = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())))); + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 1 }; + Node::new( + NodeId(2), + NodeKind::Envelope { + content: vec![content], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: EnvelopeOptions::NEW, + frame: Box::new(Affine::IDENTITY), + }, + ) +} + +#[test] +fn a_warp_grid_kept_before_the_type_measure_is_built_again() { + let t = TextObject::point(Point::new(100.0, 100.0), "Type", CharStyle::default()); + let n = warp_of(&t); + let before = warp_grid(&n); + set_type_ink(fixed_ink, no_inputs); + let fresh = warp_grid(&warp_of(&t)); + assert!(fresh.is_some()); + assert_ne!(fresh, before, "the registered measure moves the grid"); + assert_eq!(warp_grid(&n), fresh, "the grid kept from before the registration is built again"); +} diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index eb227300e0..e9d64864d0 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -1351,7 +1351,7 @@ mod tests { group.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into(); for id in [REVOLVE, crate::EXTRUDE] { assert!(crate::validate_three_d(&group, id, &json!({})).is_ok(), "{id} on the group itself"); - let refused = crate::validate_three_d_at(&group, id, &json!({}), Some((0, 0))).unwrap_err(); + let refused = crate::validate_three_d_at(&group, id, &json!({}), (Some(0), 0)).unwrap_err(); assert!(refused.contains("on the group itself"), "{id}: {refused}"); } } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 91f73049f1..52739983a5 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, PlanarFace, View}; +use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -250,36 +250,64 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// [`validate_solid`] with the symbols of `doc`: a symbol instance, a blend and a clipping group /// build from the art they show ([`resolve`]). pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { - validate_solid_for(doc, n, id, p, None) + validate_solid_for(doc, n, id, p, (None, None)) } -/// [`validate_solid_in`] for the effect on the object (`item` `None`) or on its appearance item -/// `item`, whose shadow is cast by that item's solid alone. -fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option) -> Result<(), String> { +/// [`validate_solid_in`] for the effect at `(item, index)`: index `index` of the effects of the +/// object (`item` `None`) or of its appearance item `item`, whose shadow is cast by that item's +/// solid alone. The index is the effects' length for a new effect. The solids are checked as +/// [`solid_art_in`] draws them: from the shapes after the geometry effects before the 3D effect. +/// On the object (or group) with `index` `None`, that effect is its first visible Extrude & Bevel, +/// Inflate or Rotate, as drawn, or a new one after its other effects when it has none. +fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: (Option, Option)) -> Result<(), String> { validate_solid_params(id, p)?; if let Some(doc) = doc { graphics::validate_symbols(doc, p)?; } let label = label_of(id); let n = &*source::resolve_for(doc, n, label, is_plane(id))?; + let effects = &n.appearance.effects; + let index = match at { + (None, Some(index)) => index, + _ => solid(effects).map_or(effects.len(), |(index, _)| index), + }; + let before = effects.get(..index).unwrap_or(effects); if is_container(n) { - let shapes = members(n, label)?; + let mut shapes = members(n, label)?; + reshape_members(n, before, &mut shapes); let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?; + check_solids(id, p, &shapes)?; return cast_shadow(id, p, &shapes).map(|_| ()); } let (path, rule) = object_profile(n, label)?; - let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; - facets(id, &path, rule, b, p)?; + let anchors = || format!("{label} requires a shape with at least two anchors"); + path.bounds().ok_or_else(anchors)?; + let ctx = GeomContext::of(n); // With its shadow on, the shadow of the object's solids (or of the item's) must fit too. - let items = match item.and_then(|i| n.appearance.items.get(i)) { - Some(item) => vec![item.clone()], - None => solid_paints(n), + let (path, items) = match at.0.and_then(|i| Some((n.appearance.items.get(i)?, at.1.unwrap_or(usize::MAX)))) { + Some((item, index)) => { + let path = geometry(&path, effects, &ctx); + (geometry(&path, item.effects().get(..index).unwrap_or(item.effects()), &ctx.item(item)), vec![item.clone()]) + } + None => (geometry(&path, before, &ctx), solid_paints(n)), }; + let b = path.bounds().ok_or_else(anchors)?; + facets(id, &path, rule, b, p)?; let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; + check_solids(id, p, &shapes)?; cast_shadow(id, p, &shapes).map(|_| ()) } +/// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes` +/// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not +/// counted. +fn check_solids(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> { + let Some(b) = union_bounds(shapes).filter(|_| !is_plane(id)) else { return Ok(()) }; + let (parts, bounds) = solid_parts(shapes, b); + built_solids(id, p, &parts, bounds, MAX_OBJECT_FACES).try_for_each(|r| r.map(|_| ())) +} + /// [`profile`], or the error a command reports for object `n`: type with no outlines to build from /// (empty, or only spaces) is a shape without anchors, an image is traced first, and any other /// object is the wrong kind. @@ -457,26 +485,28 @@ pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) } } -/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of -/// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself -/// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the -/// item's geometry effects before it, together with the object's other items that carry a Revolve. -/// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws. -pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { +/// [`validate_three_d`] for an effect at `(item, index)`: index `index` of the effects of +/// appearance item `item` of `n`, or of the object itself when `item` is `None` (the effects' +/// length for a new effect). An Extrude & Bevel, Inflate or Rotate is checked on the shapes after +/// the geometry effects before that index. A Revolve on a fill or stroke is checked in that item's +/// paint, after the item's geometry effects before it, together with the object's other items that +/// carry a Revolve. A group's or layer's fills and strokes take no 3D effect: only the group's own +/// effect draws. +pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: (Option, usize)) -> Result<(), String> { validate_three_d_at_in(None, n, id, p, at) } /// [`validate_three_d_at`] with the symbols of `doc` ([`validate_solid_in`]). A symbol instance, a /// blend and a clipping group take a 3D effect as groups do: on the object itself. -pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { +pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: (Option, usize)) -> Result<(), String> { graphics::validate(id, p)?; match at { - Some(_) if is_container(n) || source::resolves(n) => { + (Some(_), _) if is_container(n) || source::resolves(n) => { Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) } - Some((item, index)) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index), - Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)), - None => validate_three_d_in(doc, n, id, p), + (Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index), + (None, _) if crate::revolve::is_revolve(id) => crate::revolve::validate_in(doc, n, id, p), + (item, index) => validate_solid_for(doc, n, id, p, (item, Some(index))), } } @@ -596,6 +626,55 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { (parts, bounds) } +#[cfg(test)] +thread_local! { + /// How many solids [`built_solids`] has built on this thread, for the tests. + static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from +/// `parts` ([`solid_parts`]) about `bounds`, one result for each part in order, each built only as +/// the iterator reaches it. A stroke's solid past its own limits is `None` (left out of the art, its +/// faces not counted); a fill's is an error. The faces of the solids kept count together: once they +/// pass `limit` ([`MAX_OBJECT_FACES`] for the art) the result is an error, and +/// no later part is built. An error ends the iterator. +fn built_solids<'a>( + id: &'a str, + p: &'a Value, + parts: &'a [Part<'a>], + bounds: Rect, + limit: usize, +) -> impl Iterator>, String>> + 'a { + // Inflate's eye is placed for every puff together, as far away as the highest needs. + let mut inflated = (id == INFLATE).then(|| { + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_each(&shapes, bounds, inflate_options(p), view_options(p)) + }); + let mut kept = 0usize; + let mut ended = false; + parts.iter().map_while(move |(item, _, area, rule, _, _)| { + if ended { + return None; + } + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + let built = match inflated.as_mut() { + Some(each) => each.next().unwrap_or_else(|| Ok(vec![])), + None => facets(id, area, *rule, bounds, p), + }; + let result = match built { + Ok(f) => { + kept = kept.saturating_add(f.len()); + if kept > limit { Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()) } else { Ok(Some(f)) } + } + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => Ok(None), + Err(e) => Err(e), + }; + ended = result.is_err(); + Some(result) + }) +} + /// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's /// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of /// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its @@ -983,24 +1062,19 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); } let (parts, bounds) = solid_parts(shapes, b); - let built = if fx.id == INFLATE { - // One eye for every puff, as far away as the highest needs. - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) - } else { - parts.iter().map(|(_, _, area, rule, _, _)| facets(&fx.id, area, *rule, bounds, &p)).collect() - }; let mut solids = vec![]; - for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built) { + for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES)) { let item: &AppearanceItem = item; match f { - Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None, - Ok(f) => solids.push((item, *opacity, *scopes, f, *member)), + Ok(Some(f)) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => { + return None; + } + Ok(Some(f)) => solids.push((item, *opacity, *scopes, f, *member)), // A stroke's outline past the limits is left out; the fill's solid still draws. - Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} - // A loaded file or a shape edited past the limits keeps drawing its source path - // instead of failing the frame; commands validate the same evaluator and report its - // errors. + Ok(None) => {} + // A loaded file or a shape edited past the limits (its own, or those of the object's + // solids together) keeps drawing its source path instead of failing the frame; + // commands validate the same evaluator and report its errors. Err(_) => return None, } } @@ -1374,15 +1448,26 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) return Ok(out); } let (parts, bounds) = solid_parts(shapes, b); + // The solids the art draws, past the limits of the object's solids together as it is + // ([`built_solids`]), each built and its faces counted as the loop reaches it, then dropped. + let drawn = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())); // Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel // solid and its mesh are built as the loop reaches them. let mut inflated = (fx.id == INFLATE).then(|| { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) }); - let extrude = extrude_options(&p); - for (item, member, area, rule, _, _) in &parts { + let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; + for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) { let item: &AppearanceItem = item; + if !drawn? { + // A stroke the art leaves out is left out of the meshes too; Inflate's next puff + // still belongs to the next part. + if let Some(built) = inflated.as_mut() { + built.next(); + } + continue; + } let mesh = match inflated.as_mut() { Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 5041f01bb2..b4e012f977 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -353,6 +353,142 @@ fn a_stroke_past_the_limits_is_left_out() { } } +/// Each solid [`built_solids`] gives for effect `id` on `n` within `limit` faces together, as its +/// face count (`None` for a stroke left out) or the error, every result the iterator gives. +fn solid_faces(id: &str, n: &Node, limit: usize) -> Vec, String>> { + let shapes = [Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] }]; + let p = crate::merged_params(id, &json!({})); + let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap()); + built_solids(id, &p, &parts, bounds, limit).map(|r| r.map(|f| f.map(|f| f.len()))).collect() +} + +/// The solids of one object count their faces together: at the limit they all build, one face past +/// it is an error, and the solids after the one that passes it are never built. +#[test] +fn an_objects_solids_count_their_faces_together() { + for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { + let mut n = square_node(); + // A second stroke, so a solid follows the one that passes the limit. + let stroke = n.appearance.items[1].clone(); + n.appearance.items.push(stroke); + let counts: Vec = solid_faces(id, &n, MAX_OBJECT_FACES).into_iter().map(|c| c.unwrap().unwrap()).collect(); + assert!(counts.len() == 3 && counts.iter().all(|c| *c > 0), "{id}: {counts:?}"); + let total: usize = counts.iter().sum(); + assert!(solid_faces(id, &n, total).iter().all(Result::is_ok), "{id}: at the limit"); + let past = solid_faces(id, &n, total - 1); + assert_eq!(past.last(), Some(&Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())), "{id}"); + SOLIDS_BUILT.with(|b| b.set(0)); + let past = solid_faces(id, &n, counts[0] + counts[1] - 1); + assert!(past.len() == 2 && past[1].is_err(), "{id}: {past:?}"); + assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get), 2, "{id}: the second stroke's solid is never built"); + } +} + +/// A convex polygon of `corners` corners about (20,000, 20,000), filled red, with no stroke. +fn polygon_node(id: u64, corners: usize) -> Node { + let points: Vec = (0..corners) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / corners as f64).sin_cos(); + Point::new(20_000.0 + 10_000.0 * c, 20_000.0 + 10_000.0 * s) + }) + .collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + Node::path(NodeId(id), path, Appearance::basic(Paint::solid(RED), Paint::None, 0.0)) +} + +/// A group whose members' solids each fit, but not all of them together, draws its source art, and +/// the commands that check the effect report the error, as do its mesh and its wireframe; one +/// member fewer, within the limit, draws. +#[test] +fn a_groups_solids_together_past_the_limit_draw_the_source() { + let member = polygon_node(1, 16_384); + let mut alone = member.clone(); + alone.appearance.effects.push(fx(EXTRUDE, json!({}))); + let [Ok(Some(each))] = solid_faces(EXTRUDE, &alone, MAX_OBJECT_FACES)[..] else { panic!("one solid") }; + // Members stacked in one place each build the same solid. + let fits = MAX_OBJECT_FACES / each; + let group = |count: usize| { + let members = (0..count) + .map(|k| { + let mut m = member.clone(); + m.id = NodeId(k as u64 + 1); + Arc::new(m) + }) + .collect(); + let mut g = Node::group(NodeId(9999), members); + g.appearance.effects.push(fx(EXTRUDE, json!({}))); + g + }; + let within = group(fits); + assert!(validate_solid(&within, EXTRUDE, &json!({})).is_ok(), "{fits} × {each} faces"); + assert!(solid_art(&within).is_some()); + let past = group(fits + 1); + assert_eq!(validate_solid(&past, EXTRUDE, &json!({})), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())); + assert!(solid_art(&past).is_none(), "the source art draws"); + assert_eq!(three_d_meshes(&past), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())); + assert_eq!(three_d_wireframe_in(None, &past), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())); +} + +/// A fill and strokes whose solids each fit, but not all of them together, draw the source art, +/// and the strokes after the one past the limit are not left out to make room. +#[test] +fn a_fill_and_strokes_together_past_the_limit_draw_the_source() { + let mut n = polygon_node(1, 16_384); + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(BLUE), 20.0)); + n.appearance.items.push(stroke.clone()); + // Strokes alike each build the same solid. + let [Ok(Some(fill)), Ok(Some(each))] = solid_faces(EXTRUDE, &n, MAX_OBJECT_FACES)[..] else { panic!("a fill's solid and a stroke's") }; + let fits = (MAX_OBJECT_FACES - fill) / each; + assert!(fits >= 2, "{fill} + {fits} × {each}"); + n.appearance.items.extend(std::iter::repeat_n(stroke, fits - 1)); + assert!(validate_solid(&n, EXTRUDE, &json!({})).is_ok(), "{fill} + {fits} × {each}"); + let art = solid_art(&n).unwrap(); + assert!(faces(&art).iter().any(|f| blue(f))); + n.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(BLUE), 20.0))); + assert_eq!(validate_solid(&n, EXTRUDE, &json!({})), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())); + assert!(solid_art(&n).is_none(), "the source art draws"); +} + +/// The check builds the solids from the shapes the art draws them from: after the geometry effects +/// before the 3D effect, on an object, on its fill and on a group. Copies a Transform effect before +/// it makes that take the solids past the limit fail the check, as the art then draws the source. +#[test] +fn the_check_counts_the_faces_after_the_geometry_effects_before() { + let mut alone = polygon_node(1, 16_384); + alone.appearance.effects.push(fx(EXTRUDE, json!({}))); + let [Ok(Some(each))] = solid_faces(EXTRUDE, &alone, MAX_OBJECT_FACES)[..] else { panic!("one solid") }; + // Side by side, each copy builds the same solid. + let copies = |k: usize| fx("distort.transform", json!({"moveH": 30_000.0, "copies": k})); + for (k, fits) in [(1, true), (MAX_OBJECT_FACES / each, false)] { + let mut n = polygon_node(1, 16_384); + n.appearance.effects = vec![copies(k), fx(EXTRUDE, json!({}))]; + assert_eq!(validate_solid(&n, EXTRUDE, &json!({})).is_ok(), fits, "on the object, {k} copies"); + assert_eq!(solid_art(&n).is_some(), fits, "on the object, {k} copies"); + let mut f = polygon_node(1, 16_384); + f.appearance.items[0].effects_mut().extend([copies(k), fx(EXTRUDE, json!({}))]); + assert_eq!(validate_three_d_at(&f, EXTRUDE, &json!({}), (Some(0), 1)).is_ok(), fits, "on the fill, {k} copies"); + assert_eq!(solid_art(&f).is_some(), fits, "on the fill, {k} copies"); + let mut g = Node::group(NodeId(9999), vec![Arc::new(polygon_node(1, 16_384))]); + g.appearance.effects = vec![copies(k), fx(EXTRUDE, json!({}))]; + assert_eq!(validate_solid(&g, EXTRUDE, &json!({})).is_ok(), fits, "on a group, {k} copies"); + assert_eq!(solid_art(&g).is_some(), fits, "on a group, {k} copies"); + // A new effect goes after the copies. + let mut new = polygon_node(1, 16_384); + new.appearance.effects = vec![copies(k)]; + assert_eq!(validate_three_d_at(&new, EXTRUDE, &json!({}), (None, 1)).is_ok(), fits, "a new effect, {k} copies"); + } + // A hidden effect checked at its own index counts only the effects before it: copies after it + // copy the drawn art once it is shown again. + let mut hidden = polygon_node(1, 16_384); + let mut extrude = fx(EXTRUDE, json!({})); + extrude.visible = false; + hidden.appearance.effects = vec![extrude, copies(MAX_OBJECT_FACES / each)]; + assert!(validate_three_d_at(&hidden, EXTRUDE, &json!({}), (None, 0)).is_ok()); + hidden.appearance.effects[0].visible = true; + assert!(solid_art(&hidden).is_some()); +} + /// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's, /// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to /// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side. diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index 1ae347c978..aeefafbc77 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -59,6 +59,20 @@ fn the_meshes_of_one_object_share_one_triangle_budget() { assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_err_and(|e| e.contains("too complex"))); } +/// A mesh past the budget stops the export before the solids after it are built. +#[test] +fn the_solids_after_a_mesh_past_the_budget_are_not_built() { + let mut n = square_node(1, 100.0); + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + let first = three_d_meshes(&n).unwrap()[0].mesh.triangles.len(); + let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0, scopes: vec![] }]; + let Some(effect) = n.appearance.effects.first() else { panic!("no effect") }; + let mut total = MAX_MESH_TRIANGLES - first + 1; + SOLIDS_BUILT.with(|b| b.set(0)); + assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_err_and(|e| e.contains("too complex"))); + assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get), 1, "the stroke's solid is never built"); +} + #[test] fn revolve_stops_at_the_first_mesh_past_the_budget() { // The fill revolves into a mesh; the stroke's profile, copied 1000 times, is too complex to @@ -109,6 +123,22 @@ fn an_extruded_mesh_is_as_deep_as_the_effect() { assert!((hi[2] - lo[2] - 30.0).abs() < 1e-9 && (hi[0] - lo[0] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}"); } +/// Extrude & Bevel (Classic) exports the solid it draws: its bevel as wide as it is high. +#[test] +fn a_classic_extrusion_exports_its_own_bevel() { + let mut n = square_node(1, 100.0); + n.appearance.items.truncate(1); + let params = json!({ "bevel": "classic", "bevelHeight": 10.0 }); + n.appearance.effects.push(fx(CLASSIC_EXTRUDE, params.clone())); + let meshes = three_d_meshes(&n).unwrap(); + let p = crate::merged_params(CLASSIC_EXTRUDE, ¶ms); + let path = profile(&n).unwrap().0; + let bounds = path.bounds().unwrap(); + let own = vectorcraft_three_d::extrude_mesh(&path, FillRule::NonZero, bounds, classic_extrude_options(&p), view_options(&p)).unwrap(); + assert_eq!(meshes.len(), 1); + assert_eq!(meshes[0].mesh, own); +} + #[test] fn revolve_gives_a_mesh_in_the_top_fill() { let mut b = BezPath::new(); diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index f1dc324679..00e741bfe6 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -4,13 +4,13 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; use super::{ - INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, - region, reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options, + Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region, + reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, }; use crate::{GeomContext, is_geometry}; @@ -125,7 +125,8 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S /// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and /// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's /// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, -/// as it is left out of the drawn art. +/// as it is left out of the drawn art; past the limits of the object's solids together, the result +/// is an error, as the canvas draws the source art. fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { let label = label_of(&fx.id); let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; @@ -141,18 +142,9 @@ fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> return Ok(out); } let (parts, bounds) = solid_parts(shapes, b); - let built: Vec, String>> = if fx.id == INFLATE { - let shapes: Vec<(&PathData, vectorcraft_geom::FillRule)> = parts.iter().map(|(_, _, area, rule, ..)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) - } else { - parts.iter().map(|(_, _, area, rule, ..)| facets(&fx.id, area, *rule, bounds, &p)).collect() - }; - for ((item, ..), f) in parts.iter().zip(built) { - match f { - Ok(f) => out.extend(f.iter().map(Facet::path)), - Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} - Err(e) => return Err(e), - } + // The solids the art draws, each past the limits of the object's solids together as it is. + for f in built_solids(&fx.id, &p, &parts, bounds, vectorcraft_three_d::MAX_OBJECT_FACES) { + out.extend(f?.iter().flatten().map(Facet::path)); } Ok(out) } diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 5ea4423ab3..792fbdb28a 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -1,6 +1,6 @@ use serde_json::{Value, json}; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Node, NodeId}; +use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId}; use vectorcraft_geom::{BezPath, Rect}; use super::*; diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 347968b2c5..50c63b59cd 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -199,7 +199,7 @@ pub fn specs() -> Vec { "New Symbol…", [], None, - "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a quarter each); an instance of a symbol with guides keeps its corners' size when scaled, its edges stretch along their length and its center in both directions → {name, id, registration, exportType, nineSlice, type}", + "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions of the natural size in from each edge, each -16..16 (below 0 or above 1 puts a guide outside the art), with left + right < 1 and top + bottom < 1 (default a quarter each); an instance of a symbol with guides keeps its corners' size when scaled, its edges stretch along their length and its center in both directions → {name, id, registration, exportType, nineSlice, type}", has_selection, symbol_new ), @@ -208,7 +208,7 @@ pub fn specs() -> Vec { "Symbol Options…", [], None, - "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol (its instances and the graphics mapped onto 3D objects follow the new name) and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", + "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol (its instances and the graphics mapped onto 3D objects follow the new name) and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions in from each edge, each -16..16, with left + right < 1 and top + bottom < 1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", has_doc, symbol_options ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index edd2335f6a..2fb31c5f6b 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -47,21 +47,20 @@ fn export_type_param(p: &Value, cmd: &str) -> Result> { } /// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural -/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it -/// on with guides `g` (the symbol's current ones, else a quarter in from each edge, when none are -/// given). +/// size, below 0 or above 1 for a guide outside the art): `None` leaves the symbol alone, +/// `Some(None)` turns 9-slice off, `Some(Some(g))` turns it on with guides `g` (the symbol's +/// current ones, else a quarter in from each edge, when none are given). fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { let on = match p.get("nineSlice") { None | Some(Value::Null) => None, Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?), }; - let guides = - match p.get("nineSliceGuides") { - None | Some(Value::Null) => None, - Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| { - bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1") - })?), - }; + let guides = match p.get("nineSliceGuides") { + None | Some(Value::Null) => None, + Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| { + bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1") + })?), + }; Ok(match (on, guides) { (Some(false), _) => Some(None), (None, None) => None, diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 9e52454de1..d28bd0e6c9 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -88,8 +88,9 @@ pub struct Clipboard { /// The artboard the objects were copied from (none when they lie on no artboard; the first of /// [`Self::artboards`] when there are any): Paste on All Artboards keeps their offset to it. pub source_artboard: Option, - /// Per object: the name of the layer it was copied from (Paste Remembers Layers). - pub source_layers: Vec>, + /// Per object: the layer or sublayer it was copied from and the layers around it (Paste + /// Remembers Layers); none when that layer has no name. + pub source_layers: Vec>, /// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its /// own resources as they are now. pub source_doc: Option, @@ -101,6 +102,29 @@ pub struct Clipboard { pub guides: Vec, } +/// The layer an object was copied from, as Paste Remembers Layers finds it again: the top-level +/// layer first, down to the layer or sublayer that holds the object. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct SourceLayer { + /// The layers' ids in the source document ([`Clipboard::source_doc`]). + pub ids: Vec, + /// The layers' names (an unnamed layer above the one that holds the object is ""). + pub names: Vec, +} + +impl SourceLayer { + /// The layers around the object `anc` ends with ([`Document::ancestry`]); none when the layer + /// that holds it has no name. + fn of(d: &Document, anc: &[NodeId]) -> Option { + let layers: Vec<&Node> = anc.iter().rev().skip(1).filter_map(|a| d.node(*a).filter(|l| l.is_layer())).collect(); + layers.first()?.name.as_ref()?; + Some(Self { + ids: layers.iter().rev().map(|l| l.id).collect(), + names: layers.iter().rev().map(|l| l.name.clone().unwrap_or_default()).collect(), + }) + } +} + /// Indices of segments selected on `sp` (either explicitly or via incident anchors). pub(crate) fn subpath_selected_segments( sp: &vectorcraft_geom::SubPath, @@ -256,7 +280,7 @@ impl Clipboard { let mut compound_bounds: std::collections::HashMap> = std::collections::HashMap::new(); for id in roots { let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue }; - let layer_name = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()); + let source_layer = SourceLayer::of(d, &anc); let compound_parent = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|p| matches!(p.kind, NodeKind::Compound { .. }))); // Gradients fitted to the compound's bounds are pinned there, as a split pins them. let gradient_box = compound_parent @@ -299,11 +323,11 @@ impl Clipboard { if direct && let Some(pieces) = extract_direct_selection(n, &st.selection) { for piece in pieces { nodes.push(inherit_appearance(piece)); - source_layers.push(layer_name.clone()); + source_layers.push(source_layer.clone()); } } else { nodes.push(inherit_appearance(n.clone())); - source_layers.push(layer_name); + source_layers.push(source_layer); } } // Symbol instances are measured by their art. diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 18d4e26704..16990fc2ea 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -397,7 +397,9 @@ pub(crate) fn commit_new_art_colors(s: &mut Session) -> Result<()> { Ok(()) } -fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> { +/// Record new-art paints `before` and the session's current ones with the step just made (or the +/// interaction or undo group in progress), so Undo puts `before` back and Redo the current ones. +pub(super) fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> { let after = s.paint.clone(); if before != after || s.batch_stash.is_some() || s.doc()?.undo_group.is_some() { let st = s.doc_mut()?; @@ -422,7 +424,9 @@ fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaul Ok(()) } -fn new_art_colors_before(s: &Session) -> Result { +/// The new-art paints an undo step that starts now puts back: those at the start of the undo group +/// or interaction in progress, else the session's current ones. +pub(super) fn new_art_colors_before(s: &Session) -> Result { let st = s.doc()?; Ok(st.undo_group.as_ref().map(|g| &g.paint).or_else(|| st.interaction.as_ref().map(|it| &it.paint)).cloned().unwrap_or_else(|| s.paint.clone())) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 256578f9b2..f3befb3462 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,6 +1,6 @@ //! File (document-level) and Edit commands, plus document queries. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; use serde_json::{Value, json}; @@ -13,7 +13,7 @@ use super::{ 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}; +use crate::{Clipboard, EngineError, SourceLayer, inspect}; /// Register editing commands, including document-to-document artwork drops and their parameter contracts. pub fn specs() -> Vec { @@ -179,7 +179,7 @@ pub fn specs() -> Vec { "Paste", ["Edit"], Some("Cmd+V"), - "{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", + "{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers and sublayers they came from (by the names of the layer and the layers around it in another document; a missing sublayer is made inside its layer; a hidden or locked one is an error and nothing is pasted). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset) ), @@ -1380,6 +1380,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho && inside.is_none() && (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer)); + // Each object's remembered layer, found before anything is pasted: a hidden or locked one + // refuses the whole paste. + let remembered = if remember { remembered_layers(&st.doc, &clip.source_layers, same_doc)? } else { vec![] }; // Front/back: relative to the selection (top-most / bottom-most selected object); with // nothing selected, the top / bottom of the current layer. Art other than paths goes beside // the compound path that holds the selected object, not into it. @@ -1423,7 +1426,8 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho .flat_map(|(all, dv)| all.iter().map(move |a| (a, dv))) .map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv)) .collect(); - let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new(); + // The layers made for remembered layers the document lacks, by parent and name. + let mut made = HashMap::new(); // Objects pasted into each parent so far (keeps their order in front and back pastes). let mut placed: BTreeMap, usize> = BTreeMap::new(); let mut new_ids = vec![]; @@ -1437,8 +1441,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho // including the pattern phase, independently of transform-tool settings. c.transform(xf, sc); } - let layer = match clip.source_layers.get(k) { - Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))), + let layer = match remembered.get(k) { + Some(Some(Remembered::Layer(l))) => Some(*l), + Some(Some(Remembered::Make { parent, names })) => make_layers(d, &mut made, *parent, names)?, _ => None, }; // In a clip group or a clipping layer the bottom child is the clipping path: art pasted @@ -1557,17 +1562,111 @@ fn paste_anchor(doc: &Document, id: NodeId, can_enter_compound: bool) -> NodeId } } -/// The first unlocked layer or sublayer called `name` (topmost first), or a new top layer. -fn layer_named(d: &mut Document, name: &str) -> NodeId { - let mut found = None; - for l in d.layers.iter().rev() { - l.walk(&mut |n| { - if found.is_none() && n.is_layer() && !n.locked && n.name.as_deref() == Some(name) { - found = Some(n.id); +/// Where Paste Remembers Layers puts an object: a layer or sublayer of the document, or new +/// layers `names`, each inside the one before, the first under `parent` (none: on top). +#[derive(Clone, Debug, PartialEq)] +enum Remembered { + Layer(NodeId), + Make { parent: Option, names: Vec }, +} + +/// [`remembered_layer`] for each object's source layer, worked out once for each layer. +fn remembered_layers(d: &Document, sources: &[Option], same_doc: bool) -> Result>> { + let mut found: HashMap<&SourceLayer, Remembered> = HashMap::new(); + sources + .iter() + .map(|src| { + let Some(src) = src else { return Ok(None) }; + if let Some(r) = found.get(src) { + return Ok(Some(r.clone())); } - }); + let r = remembered_layer(d, src, same_doc)?; + found.insert(src, r.clone()); + Ok(Some(r)) + }) + .collect() +} + +/// The layer `src` names in `d`. In the document it was copied from, the layer that held the +/// object is kept while it is there with its name, wherever it was moved; else the layers around +/// it still where they were (by id) are kept. Below them, and in another document from the top, +/// each name is matched among the layers inside the one above (the topmost of the same name whose +/// layers match the most names, a shown and unlocked one first). The names left unmatched are +/// made. A matched layer that is hidden or locked, or inside one that is, is an error. +fn remembered_layer(d: &Document, src: &SourceLayer, same_doc: bool) -> Result { + let held = |id: &NodeId| d.node(*id).is_some_and(|n| n.is_layer() && n.name.as_ref() == src.names.last()); + let kept = if same_doc && src.ids.last().is_some_and(held) { + src.ids.len() + } else if same_doc { + (1..=src.ids.len()) + .rev() + .find(|&j| d.node(src.ids[j - 1]).is_some_and(Node::is_layer) && d.ancestry(src.ids[j - 1]).is_some_and(|a| a == src.ids[..j])) + .unwrap_or(0) + } else { + 0 + }; + let rest = &src.names[kept..]; + let (end, matched, _) = named_chain(d, kept.checked_sub(1).map(|j| src.ids[j]), rest); + if let Some(l) = end + && !d.ancestry(l).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked))) + { + return Err(EngineError::Other("the target layer is locked or hidden".into())); } - found.unwrap_or_else(|| d.add_layer(Some(name))) + Ok(match end { + Some(l) if matched == rest.len() => Remembered::Layer(l), + parent => Remembered::Make { parent, names: rest[matched..].to_vec() }, + }) +} + +/// The deepest chain of layers inside `parent` (none: the top level) named `names` one inside the +/// other: its last layer (`parent` when none matches), how many names it matched and whether all +/// its layers are shown and unlocked. Of chains as deep, the topmost shown and unlocked one wins. +fn named_chain(d: &Document, parent: Option, names: &[String]) -> (Option, usize, bool) { + let mut best = (parent, 0, true); + let Some((name, rest)) = names.split_first() else { return best }; + let siblings = match parent { + None => Some(&d.layers), + Some(p) => d.node(p).and_then(Node::children), + }; + for l in siblings.into_iter().flatten().rev().filter(|l| l.is_layer() && l.name.as_deref() == Some(name.as_str())) { + let (end, n, open) = named_chain(d, Some(l.id), rest); + let chain = (end, n + 1, open && l.visible && !l.locked); + if (chain.1, chain.2) > (best.1, best.2) { + best = chain; + } + } + best +} + +/// Layers `names`, each inside the one before, the first under `parent` (none: on top); `made` +/// keeps the ones a paste has made, by parent and name, so they are made once. The last one, or +/// none when `names` is empty. +fn make_layers( + d: &mut Document, + made: &mut HashMap<(Option, String), NodeId>, + parent: Option, + names: &[String], +) -> Result> { + let mut at = parent; + for name in names { + let key = (at, name.clone()); + let id = match made.get(&key) { + Some(id) => *id, + None => { + let id = match at { + None => d.add_layer(Some(name)), + Some(p) => { + let layer = super::layer::make_layer(d, Some(name)); + d.insert(Some(p), usize::MAX, layer)? + } + }; + made.insert(key, id); + id + } + }; + at = Some(id); + } + Ok(at.filter(|_| !names.is_empty())) } fn duplicate(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 72a72e6411..17fcb63fe2 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -16,7 +16,7 @@ use vectorcraft_render::effects; use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at}; use super::expand::has_seeded; use super::rasterfx; -use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or}; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or}; /// The name of the `svg.filter` effect drawing `xml`: its `` id. An effect names the /// document's filter of that name, so `xml` that differs from the document's filter of the same @@ -76,7 +76,7 @@ fn effect_specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", has_doc, set_params ), @@ -457,9 +457,10 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { let d = &s.doc()?.doc; let n = d.node(target).ok_or(EngineError::NoNode(target))?; - // A new effect goes after the item's others. + // A new effect goes after the item's (or the object's) others. let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); - let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len()))); + let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len()))); + let on = on.unwrap_or((None, n.appearance.effects.len())); effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?; if let Some(name) = effects::missing_symbol(d, &effect.params, None) { return Err(bad(C, format!("no symbol named `{name}`"))); @@ -649,15 +650,28 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { return vec![]; }; let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] }; - // A 3D effect whose type `threeDType` changes takes the new effect's parameters. + // A 3D effect whose type `threeDType` changes, and a Warp whose style `warpStyle` changes, take + // the new effect's parameters. let kind = three_d_type(p, C).ok().flatten(); + let warp = p.get("warpStyle").and_then(Value::as_str).filter(|style| effects::WarpStyle::from_id(style).is_some()); let effect = |&(id, at, index): &(NodeId, Option, usize)| { let id = &st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id; - Some(kind.filter(|_| effects::is_three_d(id)).map_or_else(|| id.clone(), str::to_string)) + Some(edited_effect_id(id, kind, warp)) }; targets.iter().filter_map(effect).collect() } +/// The id effect `id` has once `effect.setParams` sets its 3D type to `kind` or its Warp style to +/// `warp` (each when given and when `id` is an effect of that family). +fn edited_effect_id(id: &str, kind: Option<&str>, warp: Option<&str>) -> String { + let is_warp = id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_some(); + match (kind, warp) { + (Some(to), _) if effects::is_three_d(id) => to.to_string(), + (_, Some(style)) if is_warp => format!("warp.{style}"), + _ => id.to_string(), + } +} + /// The effect `threeDType` in `p` turns a 3D effect into ([`effects::THREE_D_TYPES`]), when given. fn three_d_type(p: &Value, cmd: &str) -> Result> { let key = "threeDType"; @@ -718,6 +732,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { Ok(format!("warp.{style}")) }) .transpose()?; + check_switches(p, &["visible"], C)?; let visible = p.get("visible").and_then(Value::as_bool); let item = item_target(s, p, C)?; let edited = set_params_effects(s, p); @@ -772,7 +787,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no symbol named `{name}`"))); } if visible != Some(false) { - effects::validate_three_d_at_in(Some(d), n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; + effects::validate_three_d_at_in(Some(d), n, to, &prospective, (target, index)).map_err(|e| bad(C, e))?; } else { // Hidden, the effect draws nothing, so its object need not fit. The values given are // checked, and after a change of 3D type so is every value it keeps. diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs index 1801c83492..3365a1b764 100644 --- a/crates/engine/src/cmd/effectcmd/graphics.rs +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -131,7 +131,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result { graphic.scale = frame.fitting_scale(art); } let params = effects::with_graphic(&effects::merged_params(&effect.id, &effect.params), &graphic); - effects::validate_three_d_at_in(Some(d), n, &effect.id, ¶ms, item.map(|i| (i, index))).map_err(|e| bad(C, e))?; + effects::validate_three_d_at_in(Some(d), n, &effect.id, ¶ms, (item, index)).map_err(|e| bad(C, e))?; edits.push((id, item, index, graphic)); } let first = edits.first().map(|(.., g)| g.to_value()); diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 5584073563..306aaa4797 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -169,6 +169,44 @@ fn solid_effects_apply_to_a_group_and_its_members_stay_editable() { assert_eq!(s.doc().unwrap().doc, before, "nothing changed"); } +/// Extrude & Bevel is refused, and nothing changes, on a path whose fill's and strokes' solids each +/// fit but together pass the limit of their faces; with one stroke the effect applies. +#[test] +fn extrude_is_refused_when_its_solids_together_are_too_complex() { + use vectorcraft_doc::color::{Color, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem}; + let (mut s, id) = session_with_rect(); + let points: Vec = (0..16_384) + .map(|k| { + let (sin, cos) = (k as f64 * std::f64::consts::TAU / 16_384.0).sin_cos(); + vectorcraft_geom::Point::new(20_000.0 + 10_000.0 * cos, 20_000.0 + 10_000.0 * sin) + }) + .collect(); + let polygon = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + let painted = |strokes: usize| { + let mut items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items; + let stroke = AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::WHITE), 20.0)); + items.extend(std::iter::repeat_n(stroke, strokes)); + items + }; + let set = |s: &mut Session, strokes: usize| { + let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap(); + let NodeKind::Path { path, live, .. } = &mut n.kind else { panic!("a path") }; + *path = polygon.clone(); + *live = None; + n.appearance.items = painted(strokes); + }; + // The fill's solid and each stroke's fit on their own; sixty strokes' together do not. + set(&mut s, 60); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("The 3D surface is too complex; simplify the shape"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing changed"); + set(&mut s, 1); + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap(); + assert!(effects::solid_art(&node(&s, id)).is_some()); +} + /// A group of a clipping group (a rectangle clipped by an ellipse) and a rectangle: the session, /// the clipping group and the group. fn group_with_a_clipping_group() -> (Session, NodeId, NodeId) { @@ -626,6 +664,50 @@ fn warp_options_non_warp_target_rejection_is_atomic() { assert!(!state.is_dirty()); } +/// `effect.setParams` refuses a `visible` that is not true or false, when called and when a step +/// plays, and the effect, the document and the undo history stay as they were. `null` leaves the +/// visibility as it is. +#[test] +fn set_params_refuses_a_visible_that_is_not_true_or_false() { + let (mut s, id) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": null})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for visible in [json!(1), json!(0), json!("false"), json!([])] { + let p = json!({"index": 0, "item": null, "visible": visible, "params": {"size": 9}}); + for replay in [false, true] { + let r = if replay { s.replay("effect.setParams", &p) } else { s.execute("effect.setParams", &p) }; + let err = r.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("`visible` must be true or false"), "{visible} (replay {replay}): {err}"); + let state = s.doc().unwrap(); + assert_eq!(state.doc, before, "{visible} (replay {replay})"); + assert_eq!(state.history.undo.len(), undo, "{visible} (replay {replay})"); + assert!(!state.is_dirty()); + } + } + s.execute("effect.setParams", &json!({"index": 0, "item": null, "visible": null, "params": {"size": 9}})).unwrap(); + let n = node(&s, id); + assert!(n.appearance.effects[0].visible); + assert_eq!(n.appearance.effects[0].params["size"], 9); +} + +/// The effect `effect.setParams` checks the parameters against is the one it leaves: a Warp's new +/// style and a 3D effect's new type, and the effect as it is otherwise. +#[test] +fn set_params_checks_the_effect_it_leaves() { + use super::edited_effect_id; + assert_eq!(edited_effect_id("warp.arc", None, Some("flag")), "warp.flag"); + assert_eq!(edited_effect_id("warp.arc", None, None), "warp.arc"); + assert_eq!(edited_effect_id("distort.roughen", None, Some("flag")), "distort.roughen"); + assert_eq!(edited_effect_id("threeD.extrude", Some("threeD.revolve"), None), "threeD.revolve"); + assert_eq!(edited_effect_id("warp.arc", Some("threeD.revolve"), None), "warp.arc"); + let (mut s, _) = session_with_rect(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap(); + let p = json!({"index": 0, "item": null, "warpStyle": "flag", "params": {"bend": 25}}); + assert_eq!(super::set_params_effects(&s, &p), ["warp.flag"]); +} + #[test] fn list_remove_and_set_params() { let (mut s, id) = session_with_rect(); diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index b7ff1c7734..333db1c68f 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -13,10 +13,10 @@ //! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend. //! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. //! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`. -//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT). +//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PageMaker). //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. -//! - `metafile`: EMF and WMF export, open and place. +//! - `metafile`: EMF and WMF export, open and place; PICT open and place. //! - `hpgl`: HP-GL export options and plots. //! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no //! built-in format reads). @@ -217,6 +217,8 @@ mod tests_dxfimport; mod tests_hpgl; #[cfg(test)] mod tests_metafile; +#[cfg(test)] +mod tests_pict; #[cfg(test)] mod tests_pdfoutput; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index fe7c9d8f3e..26272d873d 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "File Formats", [], None, - "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", + "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PageMaker, with what to use instead)}", always, formats ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 0fcaa4bfc0..8486cd2226 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -1,6 +1,6 @@ //! DXF export for every DXF path (`document.export {format: dxf}`, `document.exportDxf`, Export As //! → DXF Options): the options parsed from the params, one drawing per artboard with Use -//! Artboards, and the formats that can't be written (DWG, PICT) with what to use instead. +//! Artboards, and the formats that can't be written (DWG, PageMaker) with what to use instead. use std::sync::Arc; @@ -106,12 +106,6 @@ pub const UNSUPPORTED: &[Unsupported] = &[ extensions: &["dwg"], hint: "DWG is not available (no openly licensed writer exists): export DXF instead, which CAD apps open", }, - Unsupported { - id: "pict", - label: "PICT", - extensions: &["pict", "pct", "pic"], - hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first", - }, Unsupported { id: "pmd", label: "PageMaker", diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 3492fd6051..75b202c8ab 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -520,6 +520,8 @@ pub const FORMATS: &[Format] = &[ reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true), // jxl-oxide reads a JPEG XL file's first frame, which is stored as PNG. Exports don't write it. reader("jxl", "JPEG XL", &["jxl"], "image/jxl", true), + // QuickDraw pictures, read by `vectorcraft_metafile::import_pict` into paths, type and images. + reader("pict", "PICT", &["pct", "pic", "pict"], "image/x-pict", false), ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -564,6 +566,9 @@ pub const OPEN_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "pct", + "pic", + "pict", "plt", "hpgl", "hgl", @@ -581,10 +586,10 @@ pub const OPEN_EXTS: &[&str] = &[ /// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of /// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS /// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. -/// The packaging declares no type for HP-GL plot files either. +/// The packaging declares no type for HP-GL plot files or PICT pictures either. pub const UNASSOCIATED_EXTS: &[&str] = &[ "psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc", - "heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", + "heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", "pct", "pic", "pict", ]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares @@ -658,6 +663,9 @@ pub const PLACE_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "pct", + "pic", + "pict", "plt", "hpgl", "hgl", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 00c7808508..e9f6ecf3d4 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -134,6 +134,12 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } + // A version 2 PICT is known by its version opcodes after the picture's frame, at byte 522 of a + // file or byte 10 of a bare picture (version 1 by its file's name), so a format whose + // signature starts the file is checked first. + if vectorcraft_metafile::is_pict(bytes) { + return format("pict"); + } if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) { return format("svg"); } @@ -505,6 +511,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let (doc, warnings) = super::metafile::import(bytes)?; (doc, warnings, false) } + "pict" => { + let (doc, warnings) = super::metafile::import_pict(bytes)?; + (doc, warnings, false) + } "hpgl" => { let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?; (i.document, i.warnings, false) diff --git a/crates/engine/src/cmd/fileio/metafile.rs b/crates/engine/src/cmd/fileio/metafile.rs index 6bc658a4a0..d987d82757 100644 --- a/crates/engine/src/cmd/fileio/metafile.rs +++ b/crates/engine/src/cmd/fileio/metafile.rs @@ -2,6 +2,7 @@ //! `document.export {format: emf|wmf}` and Export As (one picture of an artboard, or one per //! artboard with Use Artboards; raster effects as images, rendered at the document's raster //! effects resolution), `document.open` and File → Place (one artboard, the picture's frame). +//! QuickDraw pictures (PICT) open and place the same way; they are never written. use serde_json::Value; use vectorcraft_doc::Document; @@ -52,3 +53,9 @@ pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec)> { let i = vectorcraft_metafile::import(bytes).map_err(super::err)?; Ok((i.document, i.warnings)) } + +/// A QuickDraw picture's document and the import's warnings. +pub(super) fn import_pict(bytes: &[u8]) -> Result<(Document, Vec)> { + let p = vectorcraft_metafile::import_pict(bytes).map_err(super::err)?; + Ok((p.document, p.warnings)) +} diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 03273d9450..1a3368b56f 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -1,5 +1,5 @@ //! DXF export through the commands: options, artboards, selection, images, and the formats that -//! can't be written (DWG, PICT). +//! can't be written (DWG, PageMaker). use serde_json::{Value, json}; @@ -275,23 +275,24 @@ fn formats_list_dxf_and_what_can_not_be_written() { } assert!(r["writable"].as_array().unwrap().contains(&json!("dxf"))); let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect(); - assert_eq!(unsupported, ["dwg", "pict", "pmd"]); + assert_eq!(unsupported, ["dwg", "pmd"]); } #[test] -fn dwg_and_pict_say_what_to_use_instead() { +fn dwg_and_pagemaker_say_what_to_use_instead() { let mut s = two_boards(); for p in [json!({"format": "dwg"}), json!({"path": "/tmp/plan.DWG"})] { let e = s.execute("document.export", &p).unwrap_err().to_string(); assert!(e.contains("DWG is not available") && e.contains("DXF"), "{p}: {e}"); } + // PICT opens; it isn't written. let e = s.execute("document.export", &json!({"format": "pict"})).unwrap_err().to_string(); - assert!(e.contains("PICT is a retired format"), "{e}"); + assert!(e.contains("PICT files can be opened but not written"), "{e}"); let e = s .execute("document.open", &json!({"name": "old.pct", "dataBase64": vectorcraft_format::base64_encode(&[0u8; 600])})) .unwrap_err() .to_string(); - assert!(e.contains("old.pct") && e.contains("PICT"), "{e}"); + assert!(e.contains("not a PICT file"), "{e}"); let e = s .execute("document.open", &json!({"name": "site.dwg", "dataBase64": vectorcraft_format::base64_encode(b"AC1032\0\0")})) .unwrap_err() diff --git a/crates/engine/src/cmd/fileio/tests_pict.rs b/crates/engine/src/cmd/fileio/tests_pict.rs new file mode 100644 index 0000000000..513adffbef --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pict.rs @@ -0,0 +1,104 @@ +//! PICT pictures through the commands: `document.open` by extension or by content, File › Place as +//! one group clipped to the picture's frame, the dialogs' filters and the format list, and files +//! that aren't pictures, or a PICT export, refused with a message. + +use serde_json::json; +use vectorcraft_doc::NodeKind; + +use super::*; + +fn b64(bytes: &[u8]) -> String { + vectorcraft_format::base64_encode(bytes) +} + +/// A version 2 PICT file of a 200 × 100 pt frame: a red rectangle and a line. +fn picture() -> Vec { + let words = |v: &[i16]| v.iter().flat_map(|x| x.to_be_bytes()).collect::>(); + let mut b = vec![0; 512]; + b.extend(words(&[0, 0, 0, 100, 200])); + b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]); + b.extend(words(&[-1, 0])); + b.extend([0; 20]); + b.extend(words(&[0x1A, -1, 0, 0, 0x31, 10, 20, 50, 80, 0x20, 70, 10, 70, 190, 0xFF])); + b +} + +#[test] +fn document_open_reads_a_picture() { + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"name": "logo.pct", "dataBase64": b64(&picture())})).unwrap(); + assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("pict"), Some("logo.pct")), "{r}"); + assert_eq!(r["warnings"], json!([])); + let st = s.doc().unwrap(); + assert_eq!(st.doc.artboards[0].rect.size(), (200.0, 100.0).into()); + let mut paths = vec![]; + st.doc.walk(|n| { + if let NodeKind::Path { .. } = n.kind { + paths.push(n.clone()); + } + }); + assert_eq!(paths.len(), 2); + let b = paths[0].geometric_bounds().unwrap(); + assert!((b.x0 - 20.0).abs() < 1e-9 && (b.width() - 60.0).abs() < 1e-9, "{b:?}"); + // Opened as artwork: Save asks for a name. + assert!(st.path.is_none()); + + // A picture opens by its content under a name no format reads, and in each of its names. + for name in ["logo.PICT", "logo.pic", "logo.bin", "logo"] { + let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(&picture())})).unwrap(); + assert_eq!(r["format"].as_str(), Some("pict"), "{name}: {r}"); + } + assert_eq!(detect("logo.dat", &picture()).map(|f| f.id), Some("pict")); + assert_eq!(detect("bare.dat", &picture()[512..]).map(|f| f.id), Some("pict")); +} + +#[test] +fn file_place_places_a_picture_as_one_clipped_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let p = json!({"name": "logo.pct", "dataBase64": b64(&picture()), "at": [150, 150]}); + let info = s.execute("file.place.info", &p).unwrap(); + assert_eq!(info["format"], "pict", "{info}"); + assert!((info["width"].as_f64().unwrap() - 200.0).abs() < 1e-9, "{info}"); + let r = s.execute("file.place", &p).unwrap(); + assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("pict"), Some(1)), "{r}"); + let doc = &s.doc().unwrap().doc; + let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + assert_eq!(group.name.as_deref(), Some("logo.pct")); + let NodeKind::Group { clip: true, .. } = &group.kind else { panic!("a clipping group, got {:?}", group.kind) }; + let b = group.geometric_bounds().unwrap(); + assert!((b.width() - 200.0).abs() < 1e-6 && (b.height() - 100.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn files_that_are_not_pictures_are_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + let e = s.execute("document.open", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).expect_err("refused"); + assert!(e.to_string().contains("not a PICT file"), "{e}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), (100.0, 50.0).into()); + assert!(s.execute("file.place", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).is_err()); + // Other formats' files aren't taken for pictures by their content. + // A file that starts with a PNG signature and holds a picture's version opcodes at byte 522. + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(&picture()[8..]); + assert!(detect("x.png", &png).is_none_or(|f| f.id != "pict")); + let e = s.execute("document.export", &json!({"format": "pict"})).expect_err("refused").to_string(); + assert!(e.contains("PICT files can be opened but not written"), "{e}"); +} + +#[test] +fn the_dialogs_and_the_format_list_name_pict() { + let exts = ["pct", "pic", "pict"]; + for e in exts { + assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}"); + assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("pict"), "{e}"); + } + let filter = ("PICT", &exts[..]); + assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter)); + let r = Session::new().execute("document.formats", &json!({})).unwrap(); + let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "pict").unwrap().clone(); + assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)), "{f}"); + assert_eq!(f["mime"], "image/x-pict"); + assert!(writable_format(None, Some("x/logo.pct")).is_err()); +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f93cdca7b9..f816ec7688 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -200,7 +200,7 @@ pub fn specs() -> Vec { "Paste Remembers Layers", ["Window", "Layers"], None, - "{on?} (default: toggle) the document option: on, the Paste commands put objects back into the layers they were copied from (by name; a missing one is made on top), off into the current layer; one undo step when it changes (document.inspect → pasteRemembersLayers) → {on}", + "{on?} (default: toggle) the document option: on, the Paste commands put objects back into the layers or sublayers they were copied from (in that document while they are still there, else by the names of the layer and the layers around it, the topmost match first; a missing sublayer is made inside its layer, a missing top-level layer on top; a hidden or locked one is an error and nothing is pasted), off into the current layer; one undo step when it changes (document.inspect → pasteRemembersLayers) → {on}", has_doc, paste_remembers_layers ), diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 720a906462..74818a7604 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -387,6 +387,26 @@ fn keep_eps_preview(low_res_proxy_eps: bool, g: &Group, d: &Document) -> bool { low_res_proxy_eps && g.document.is_none() && linked_eps(&g.link) && previewing(d, &g.key) } +/// Is the linked file `f` found a DCS file? Only the start of the file is read, unless a binary EPS +/// header puts its PostScript further on; a DCS file read in full stays in `f`. A file that can't +/// be read is not one. +fn is_dcs_file(f: &mut Found) -> bool { + use super::place::dcs; + if let Some(b) = &f.bytes { + return dcs::is_dcs(b); + } + let Ok(head) = fileio::read_file_head(&f.path, dcs::HEAD_BYTES) else { return false }; + if let Some(is) = dcs::head_is_dcs(&head, f.size) { + return is; + } + let Ok(b) = f.read() else { return false }; + let is = dcs::is_dcs(&b); + if is { + f.bytes = Some(b); + } + is +} + /// Does group `g` of `d` show something other than its file: an image its preview, a placed /// document nothing (its file's bytes are gone)? fn stale(d: &Document, g: &Group) -> bool { @@ -609,9 +629,13 @@ fn clip_shape((frame, room): Clip) -> PathData { enum ClipTo { /// It stays as it is. Keep, - /// It is clipped to this path: the clipping path of the clip group holding just it changes, - /// else it goes into a new clip group. + /// It is clipped to this path (Placement Options' clip): the clipping path of the clip group + /// holding just it changes, else it goes into a new clip group. Path(PathData), + /// It is clipped to this path, the clipping path its DCS file draws it through: as [`ClipTo::Path`], + /// except that a plain group holding just it (as [`ClipTo::Off`] leaves one) becomes a clip group + /// again. + File(PathData), /// It goes into a new clip group of this path, inside the clip group that holds it. Nest(PathData), /// The clipping path of the clip group holding just it goes (a DCS file's clipping path that @@ -673,7 +697,7 @@ fn clip_to( match img.clip.as_ref().map(|path| path.transformed(new_xf)) { // A clipping mask made in the document stays, with the file's clip group inside it. Some(path) if held.is_some() && !file_held => ClipTo::Nest(path), - Some(path) => ClipTo::Path(path), + Some(path) => ClipTo::File(path), None if file_held => ClipTo::Off, None => ClipTo::Keep, } @@ -722,7 +746,7 @@ impl Nested<'_> { let mut new = Arc::new(new); self.update(&mut new, true); *n = match clip { - ClipTo::Path(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)), + ClipTo::Path(path) | ClipTo::File(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)), ClipTo::Keep | ClipTo::Off => new, }; } @@ -738,12 +762,16 @@ impl Nested<'_> { let mut slots: Vec<(usize, Arc)> = vec![]; // The object a clip group that loses its clipping path holds alone. let mut declipped = None; + // The clipping path and object of a plain group holding just the object, which becomes a + // clip group again. + let mut reclipped = None; if let Some(children) = n.children() { // A clip group holding just the object: its clipping path changes. let clip_path = match (&n.kind, &children[..]) { (NodeKind::Group { clip: true, .. }, [c, _]) if matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c), _ => None, }; + let plain = matches!((&n.kind, &children[..]), (NodeKind::Group { clip: false, .. }, [_])); for (i, c) in children.iter().enumerate() { if !(nested && (self.pick)(c)) { let mut new = Arc::clone(c); @@ -762,14 +790,15 @@ impl Nested<'_> { self.update(&mut new, true); match (clip, clip_path) { (ClipTo::Keep, _) => slots.push((i, new)), - (ClipTo::Path(clip), Some(path)) if i == 1 => { + (ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => { let mut path = Node::clone(path); if let NodeKind::Path { path: p, .. } = &mut path.kind { *p = clip; } slots.extend([(0, Arc::new(path)), (1, new)]); } - (ClipTo::Path(clip) | ClipTo::Nest(clip), _) => { + (ClipTo::File(clip), None) if plain => reclipped = Some((clip, new)), + (ClipTo::Path(clip) | ClipTo::File(clip) | ClipTo::Nest(clip), _) => { let group = self.clip_group(new, clip); slots.push((i, Arc::new(group))); } @@ -778,13 +807,21 @@ impl Nested<'_> { } } } - if mask.is_none() && slots.is_empty() && declipped.is_none() { + if mask.is_none() && slots.is_empty() && declipped.is_none() && reclipped.is_none() { return; } + let reclipped = reclipped.map(|(clip, object)| (Arc::new(self.clipping_path(clip)), object)); let n = Arc::make_mut(n); if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) { m.art = art; } + // A plain group of just the object that becomes a clip group again. + if let Some((path, object)) = reclipped { + if let NodeKind::Group { children, clip } = &mut n.kind { + (*children, *clip) = (vec![path, object], true); + } + return; + } // A clip group that stays as a plain group of just the object. if let Some(object) = declipped { if let NodeKind::Group { children, clip } = &mut n.kind { @@ -803,15 +840,18 @@ impl Nested<'_> { /// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers. fn clip_group(&mut self, object: Arc, clip: PathData) -> Node { - let (path_id, group_id) = (NodeId(self.next), NodeId(self.next + 1)); - self.next += 2; - let none = vectorcraft_color::Paint::None; - let mut path = Node::path(path_id, clip, Appearance::basic(none.clone(), none, 0.0)); - if let NodeKind::Path { clipping, .. } = &mut path.kind { - *clipping = true; - } + let path = self.clipping_path(clip); + let group_id = NodeId(self.next); + self.next += 1; Node::new(group_id, NodeKind::Group { children: vec![Arc::new(path), object], clip: true }) } + + /// A clipping path of `clip`, with the next node id. + fn clipping_path(&mut self, clip: PathData) -> Node { + let id = NodeId(self.next); + self.next += 1; + clipping_path(id, clip) + } } /// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its @@ -944,15 +984,41 @@ fn clip_image_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> { new_clip_group(d, id, path) } +/// Clip object `id` of `d` to `path`, the clipping path its DCS file draws it through: the clipping +/// path of the clip group holding just it changes, a plain group holding just it (as [`unclip`] +/// leaves one) becomes a clip group again, else it goes into a new clip group. +fn clip_file_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> { + if set_clip_path(d, id, path.clone()) { + return Ok(()); + } + let plain = d.parent_of(id).filter(|g| match d.node(*g).map(|n| &n.kind) { + Some(NodeKind::Group { children, clip: false }) => matches!(&children[..], [o] if o.id == id), + _ => false, + }); + let Some(g) = plain else { return new_clip_group(d, id, path) }; + let clip = clipping_path(d.alloc_id(), path); + d.insert(Some(g), 0, clip)?; + if let Some(Node { kind: NodeKind::Group { clip, .. }, .. }) = d.node_mut(g) { + *clip = true; + } + Ok(()) +} + +/// A clipping path `id` of `path`, unpainted. +fn clipping_path(id: NodeId, path: PathData) -> Node { + let none = vectorcraft_color::Paint::None; + let mut clip = Node::path(id, path, Appearance::basic(none.clone(), none, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + clip +} + /// Put object `id` of `d` into a new clip group of `path`, where it is. fn new_clip_group(d: &mut Document, id: NodeId, path: PathData) -> Result<()> { let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; let image = d.remove(id)?; - let none = vectorcraft_color::Paint::None; - let mut clip = Node::path(d.alloc_id(), path, Appearance::basic(none.clone(), none, 0.0)); - if let NodeKind::Path { clipping, .. } = &mut clip.kind { - *clipping = true; - } + let clip = clipping_path(d.alloc_id(), path); let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), image], clip: true }); d.insert(par, index, group)?; Ok(()) @@ -1017,6 +1083,7 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio clips.into_iter().try_for_each(|(id, clip)| match clip { ClipTo::Keep => Ok(()), ClipTo::Path(path) => clip_image_to(d, id, path), + ClipTo::File(path) => clip_file_to(d, id, path), ClipTo::Nest(path) => new_clip_group(d, id, path), ClipTo::Off => unclip(d, id), }) @@ -1159,6 +1226,9 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps } (Status::Ok, Some(mut f)) => { let preview = previewing(d, &g.key); + // A DCS file is read in full even with the proxy: its plates print and separate on + // their own inks. + let eps_preview = eps_preview && !is_dcs_file(&mut f); if let Some((blob, hash)) = read_images.get(&f.path).filter(|_| preview && !eps_preview).cloned() { store_image(d, &g.key, blob, true); let link = f.link_hashed(hash, &g.link); diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 0c835c7b5d..8a92d759f9 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -244,7 +244,7 @@ pub fn specs() -> Vec { "Move Envelope Mesh Point", [], None, - "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh envelope (its handles follow), or with `handle` place that handle end at (x, y); object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too", + "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh or warp envelope (its handles follow), or with `handle` place that handle end at (x, y); a warp's points are those of its grid (4×4 patches, or 8×8 or 16×16 where 4×4 draws more than 0.1% of its diagonal away from the warp), and its first edit makes it a mesh envelope on that grid in the same undo step (a warp no such grid follows is refused: Reset with Mesh first); object.mesh.movePoint, movePoints, addLine and deletePoint edit mesh and warp envelopes too", has_doc, env_set_mesh_point ), @@ -263,7 +263,7 @@ pub fn specs() -> Vec { "Set Mesh Point Color", [], None, - "{id, index | points: [index…] | items: [{id, index | points}…], color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1} colour and/or opacity of gradient mesh points (one undo step; an index out of range changes nothing)", + "{id, index | points: [index…] | items: [{id, index | points}…], color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1} colour and/or opacity of gradient mesh points (one undo step; an index out of range changes nothing, and so does a call that changes no point, with no undo step)", has_doc, mesh_set_color ), @@ -272,7 +272,7 @@ pub fn specs() -> Vec { "Move Mesh Point", [], None, - "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or mesh envelope (its handles follow), or with `handle` place that handle end at (x, y)", + "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or a mesh or warp envelope (its handles follow; a warp becomes a mesh envelope, see object.envelope.setMeshPoint), or with `handle` place that handle end at (x, y)", has_doc, mesh_move_point ), @@ -281,7 +281,7 @@ pub fn specs() -> Vec { "Move Mesh Points", [], None, - "{id, points: [index…] | items: [{id, index | points}…], dx, dy} move points of gradient meshes or mesh envelopes by (dx, dy), their handles with them (one undo step; an index out of range changes nothing)", + "{id, points: [index…] | items: [{id, index | points}…], dx, dy} move points of gradient meshes or mesh or warp envelopes by (dx, dy), their handles with them (warps become mesh envelopes, see object.envelope.setMeshPoint; one undo step; an index out of range changes nothing, and so does a call that moves no point, with no undo step)", has_doc, mesh_move_points ), @@ -290,7 +290,7 @@ pub fn specs() -> Vec { "Add Mesh Line", [], None, - "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge; at a point that is already a mesh point it adds no line, and records no undo step unless `color` changes a gradient mesh's point (a mesh envelope's points have no color) → {index}", + "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh or warp envelope (a warp becomes a mesh envelope, see object.envelope.setMeshPoint); a point outside within `tolerance` of the edge adds them through the nearest point of the edge; at a point that is already a mesh point it adds no line, and records no undo step unless `color` changes a gradient mesh's point (an envelope's points have no color) → {index}", has_doc, mesh_add_line ), @@ -299,7 +299,7 @@ pub fn specs() -> Vec { "Delete Mesh Point", [], None, - "{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope", + "{id, index} delete the mesh lines through a point of a gradient mesh or a mesh or warp envelope (a warp becomes a mesh envelope, see object.envelope.setMeshPoint)", has_doc, mesh_delete_point ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index b9a315325b..5357ea51c0 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -1,6 +1,6 @@ use super::super::object::choice; use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wrap}; -use super::mesh::{handle_param, index_param, whole}; +use super::mesh::{envelope_edit_grid, handle_param, index_param, mesh_envelope, warp_point_stays, 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, json, live, object_param, ok, switch_or, text_param, @@ -79,8 +79,7 @@ fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> Envelop return EnvelopeKind::Mesh { rows, cols, points: map.grid(rows, cols), handles: vec![] }; } // The surface's points with handles along it: the grid lines keep the current shape. - let m = map.surface_mesh(rows, cols, Color::BLACK); - EnvelopeKind::Mesh { rows, cols, points: m.points.iter().map(|q| q.p).collect(), handles: m.points.iter().map(|q| q.handles).collect() } + mesh_envelope(&map.surface_mesh(rows, cols, Color::BLACK)) } pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result { @@ -428,7 +427,20 @@ pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { 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)?; + // A warp whose point or handle stays where it is stays a warp. + if warp_point_stays(&s.doc()?.doc, id, index, handle, q, C)? { + return ok(); + } s.edit("Move Envelope Point", |d, _| { + // A warp's first edited point makes it a mesh envelope on its grid, in the same step. + if let Some(n) = d.node(id) + && matches!(&n.kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. })) + { + let grid = envelope_edit_grid(n, C).ok_or_else(|| bad(C, "not a mesh envelope point"))??; + if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + **kind = mesh_envelope(&grid); + } + } let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(bad(C, "not a mesh envelope point")); }; diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 2290b56af4..97b6160718 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -119,31 +119,90 @@ fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: b s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f)) } -/// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a mesh envelope's grid too (its -/// points and handles are written back: editing fills in the handles). -fn edit_grid(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { - edit_kind(&mut d.node_mut(id).ok_or(EngineError::NoNode(id))?.kind, cmd, envelopes, f) +/// The grid an edit of envelope `n`'s points starts from: a mesh envelope's own grid, or a warp's +/// grid ([`live::warp_grid`]), which the edit turns into a mesh envelope. `None` for other nodes. +pub(super) fn envelope_edit_grid(n: &Node, cmd: &str) -> Option> { + let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; + match &**kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => { + Some(live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(cmd, "the envelope's mesh is malformed"))) + } + EnvelopeKind::Warp { .. } => { + Some(live::warp_grid(n).ok_or_else(|| bad(cmd, "this warp's points can't be edited: use Reset with Mesh first"))) + } + EnvelopeKind::TopObject { .. } => None, + } } -/// [`edit_grid`] on a node's kind. -fn edit_kind(k: &mut NodeKind, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { - match k { - NodeKind::Mesh(m) => f(m), - NodeKind::Envelope { kind, .. } if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { - let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { - return Err(bad(cmd, "not a gradient mesh or mesh envelope")); - }; - let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(cmd, "the envelope's mesh is malformed"))?; - let r = f(&mut m)?; - (*rows, *cols) = (m.rows, m.cols); - *points = m.points.iter().map(|q| q.p).collect(); - *handles = m.points.iter().map(|q| q.handles).collect(); - Ok(r) - } - _ => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), +/// The mesh envelope kind of grid `m`, with its points' handles. +pub(super) fn mesh_envelope(m: &GradientMesh) -> EnvelopeKind { + EnvelopeKind::Mesh { + rows: m.rows, + cols: m.cols, + points: m.points.iter().map(|q| q.p).collect(), + handles: m.points.iter().map(|q| q.handles).collect(), } } +/// Does placing point `index` of warp envelope `id` of `d`, or that point's `handle` end, at `q` +/// leave it where it is? Such a placement is no edit. `false` for other objects and for an index +/// out of range, whose edits run as before. +pub(super) fn warp_point_stays(d: &Document, id: NodeId, index: usize, handle: Option, q: Point, cmd: &str) -> Result { + let Some(n) = d.node(id).filter(|n| matches!(&n.kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. }))) else { + return Ok(false); + }; + let grid = envelope_grid_to_edit(n, cmd, true)?; + Ok(grid.points.get(index).is_some_and(|pt| match handle { + // The caller sends a handle's end as point plus handle; `q - p` can round away from it. + Some(h) => pt.handles.get(h).is_some_and(|v| pt.p + *v == q), + None => pt.p == q, + })) +} + +/// The grid of envelope `n` that [`edit_grid`] edits (only `envelopes`), or the error it fails with. +fn envelope_grid_to_edit(n: &Node, cmd: &str, envelopes: bool) -> Result { + match envelope_edit_grid(n, cmd).filter(|_| envelopes) { + Some(grid) => grid, + None => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), + } +} + +/// The grid of mesh `id` of `d` that [`edit_grid`] edits, as it is now. +fn grid_to_edit(d: &Document, id: NodeId, cmd: &str, envelopes: bool) -> Result { + match d.node(id) { + Some(Node { kind: NodeKind::Mesh(m), .. }) => Ok(m.clone()), + Some(n) => envelope_grid_to_edit(n, cmd, envelopes), + None => Err(EngineError::NoNode(id)), + } +} + +/// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a warp or mesh envelope's grid too +/// (see [`edit_node`]). +fn edit_grid(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { + edit_node(d.node_mut(id).ok_or(EngineError::NoNode(id))?, cmd, envelopes, f) +} + +/// [`edit_grid`] on a node: with `envelopes`, a warp or mesh envelope's grid +/// ([`envelope_edit_grid`]) is edited too, and its points and handles are written back as a mesh +/// envelope (editing fills in the handles, and makes a warp a mesh envelope). +fn edit_node(n: &mut Node, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { + if let NodeKind::Mesh(m) = &mut n.kind { + return f(m); + } + let start = envelope_grid_to_edit(n, cmd, envelopes)?; + let mut m = start.clone(); + let r = f(&mut m)?; + // A warp stays a warp when the edit changed nothing. + let warp = matches!(&n.kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { .. })); + if warp && m == start { + return Ok(r); + } + if let NodeKind::Envelope { kind, .. } = &mut n.kind { + **kind = mesh_envelope(&m); + } + Ok(r) +} + /// Most mesh points one command names, over all its meshes. const MAX_POINTS: usize = 1 << 20; @@ -178,7 +237,8 @@ fn point_items(p: &Value, cmd: &str) -> Result)>> { } /// Run `f` on each mesh point `items` names, as one undo step `label` (an index out of range -/// fails it, changing nothing); with `envelopes`, on mesh envelopes' points too. +/// fails it, changing nothing); with `envelopes`, on warp and mesh envelopes' points too. When `f` +/// changes no point, nothing changes and no undo step is recorded. fn with_points( s: &mut Session, items: &[(NodeId, BTreeSet)], @@ -187,6 +247,20 @@ fn with_points( envelopes: bool, mut f: impl FnMut(&mut live::MeshPoint) -> Result<()>, ) -> Result<()> { + let doc = &s.doc()?.doc; + let mut changes = false; + for (id, points) in items { + let mut m = grid_to_edit(doc, *id, cmd, envelopes)?; + for i in points { + let q = m.points.get_mut(*i).ok_or_else(|| bad(cmd, "index out of range"))?; + let before = q.clone(); + f(q)?; + changes |= *q != before; + } + } + if !changes { + return Ok(()); + } s.edit(label, |d, _| { for (id, points) in items { edit_grid(d, *id, cmd, envelopes, |m| { @@ -255,6 +329,10 @@ pub(super) fn mesh_move_point(s: &mut Session, p: &Value) -> Result { 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)?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; + if warp_point_stays(&s.doc()?.doc, id, index, handle, q, C)? { + return ok(); + } with_mesh(s, p, C, "Move Mesh Point", true, |m| { let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; match handle { @@ -298,12 +376,12 @@ pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result { } // The lines are added to a copy of the object. A point that is already a mesh point adds none: // when its color stays too, nothing changes and no undo step is recorded. The grid is compared, - // not the node: a mesh envelope stored without handles takes them in any edit, and keeps no - // colors, so only its points and handles count. - let orig = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.kind.clone(); - let colored = matches!(orig, NodeKind::Mesh(_)); - let mut kind = orig.clone(); - let (i, same) = edit_kind(&mut kind, C, true, |m| { + // not the node: a mesh envelope stored without handles takes them in any edit, a warp becomes + // a mesh envelope, and neither keeps colors, so only their points and handles count. + let orig = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.clone(); + let colored = matches!(orig.kind, NodeKind::Mesh(_)); + let mut node = orig.clone(); + let (i, same) = edit_node(&mut node, C, true, |m| { let before = m.clone(); let i = add(m)?; let shape = |g: &GradientMesh| (g.rows, g.cols, g.points.iter().map(|q| (q.p, q.handles)).collect::>()); @@ -315,10 +393,10 @@ pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result { let index = s.edit("Add Mesh Line", move |d, _| { let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; // Settling typed values before the edit may have changed the object: add the lines again. - if n.kind != orig { - return edit_kind(&mut n.kind, C, true, add); + if n.kind != orig.kind { + return edit_node(n, C, true, add); } - n.kind = kind; + n.kind = node.kind; Ok(i) })?; Ok(json!({ "index": index })) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 1ab5707d58..5910435a75 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\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), 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), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), 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; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{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\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: 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\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), 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), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), 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; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -622,7 +622,16 @@ fn set_format(s: &mut Session, p: &Value) -> Result { Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Mojikumi::None, Some("lineEndHalf") => vectorcraft_doc::Mojikumi::LineEndHalf, - _ => return Err(bad(C, "`mojikumi` must be \"none\" or \"lineEndHalf\"")), + Some("yakumonoHankaku") => vectorcraft_doc::Mojikumi::YakumonoHankaku, + Some("gyomatsuYakumonoHankaku") => vectorcraft_doc::Mojikumi::GyomatsuYakumonoHankaku, + Some("gyomatsuYakumonoZenkaku") => vectorcraft_doc::Mojikumi::GyomatsuYakumonoZenkaku, + Some("yakumonoZenkaku") => vectorcraft_doc::Mojikumi::YakumonoZenkaku, + _ => { + return Err(bad( + C, + "`mojikumi` must be \"none\", \"yakumonoHankaku\", \"gyomatsuYakumonoHankaku\", \"gyomatsuYakumonoZenkaku\", \"yakumonoZenkaku\" or \"lineEndHalf\"", + )); + } }), }; let kinsoku = match p.get("kinsoku") { diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 60fa90c32c..c38854ae76 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -15,8 +15,8 @@ //! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a //! misspelled `widht` no longer runs the command with its default. So are the `params` of an //! effect (#1592): `effect.apply` and -//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its -//! default, with a parameter error ([`check_effect_params`]). +//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than the +//! effect reads, with a parameter error ([`check_effect_params`]). //! //! A command with a parameter schema (`CommandSpec::schema`) is also checked against the schema //! before it runs ([`crate::schemas::check`]): the schema refuses a value of the wrong type or out @@ -263,15 +263,15 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError } /// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the -/// effect takes (those of its dialog defaults and of its params description), each of its -/// default's type, a `color` the effect can draw ([`check_effect_color`]) and a Levels or Curves -/// `channel` the effect has ([`check_effect_channel`]). Numbers out of range pass: the effect -/// clamps them when it draws. An effect plug-in resolves its own against its manifest, and an -/// unknown effect is the command's to report. +/// effect takes (those of its dialog defaults and of its params description), each of the type +/// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`]) +/// and a Levels or Curves `channel` the effect has ([`check_effect_channel`]). Numbers out of range +/// pass: the effect clamps them when it draws. An installed effect plug-in's are checked against its +/// manifest, and an unknown effect is the command's to report. pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> { let Value::Object(given) = params else { return Ok(()) }; if effect.starts_with(vectorcraft_plugins::effect::PREFIX) { - return Ok(()); + return check_effect_types(id, effect, params); } let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) }; let defaults = info.defaults.as_object(); @@ -282,18 +282,10 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> keys.push(key); } } - for (key, v) in given { - if !keys.contains(&key.as_str()) { - return Err(unknown(id, key, &keys)); - } - match defaults.and_then(|d| d.get(key)) { - Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))), - Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))), - // (A colour is also taken as `[r, g, b]` or as a colour object.) - Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))), - _ => {} - } + if let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) { + return Err(unknown(id, key, &keys)); } + check_effect_types(id, effect, params)?; // An SVG filter's XML is one drawable element. if effect == vectorcraft_render::effects::SVG_FILTER && let Some(xml) = given.get("xml") @@ -329,6 +321,59 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> check_effect_channel(id, effect, params) } +/// The type of the values in `params` of effect `effect`, as command `id` was given them: a key +/// the effect has a dialog default for takes a value of its default's type, a key only the +/// effect's params description names takes the type the effect reads ([`undefaulted_type`]), and +/// an installed effect plug-in's take the types its manifest gives them. The `style` of Crop Marks +/// is `roman` or `japanese`. A number given as text (`"10 pt"`), `true` for a number, `1` for a +/// switch and `null` for either are refused. +fn check_effect_types(id: &str, effect: &str, params: &Value) -> Result<()> { + let Value::Object(given) = params else { return Ok(()) }; + if effect.starts_with(vectorcraft_plugins::effect::PREFIX) { + return match vectorcraft_plugins::effect::installed(effect) { + Some(plugin) => plugin.manifest().resolve_params(params).map(drop).map_err(|why| bad(id, why.to_string())), + None => Ok(()), + }; + } + let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) }; + for (key, v) in given { + let want = info.defaults.get(key).cloned().or_else(|| undefaulted_type(effect, key)); + match want { + Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))), + Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))), + // (A colour is also taken as `[r, g, b]` or as a colour object.) + Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))), + _ => {} + } + } + // Crop Marks draw one of two styles. + if effect == vectorcraft_render::effects::CROP_MARKS + && let Some(style) = given.get("style") + && style.as_str().and_then(vectorcraft_doc::marks::MarkStyle::parse).is_none() + { + return Err(bad(id, "`style` must be roman or japanese")); + } + Ok(()) +} + +/// A value of the type effect `effect` reads for `key`, a parameter its params description names +/// without a dialog default: the earlier 3D Perspective and light (`perspective`, `lightAzimuth`, +/// `lightElevation`) and Scribble `variation` are numbers, an Outline Stroke's `width` is a number +/// and a Gaussian Blur's `linear` is true or false. A Glass or Texturizer `textureImage`, an SVG +/// filter's `name` and `xml`, a Warp's `version` and the `style` of Crop Marks have checks of their +/// own; they aren't listed. +pub(crate) fn undefaulted_type(effect: &str, key: &str) -> Option { + use vectorcraft_render::effects; + let number = || Some(Value::from(0.0)); + match key { + "perspective" | "lightAzimuth" | "lightElevation" if effects::is_three_d(effect) => number(), + "variation" if effect == "stylize.scribble" => number(), + "width" if effect == "path.outlineStroke" => number(), + "linear" if effect == "blur.gaussian" => Some(Value::Bool(false)), + _ => None, + } +} + /// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`, /// `#rrggbbaa`, black, white, red, green, blue), `[r, g, b]` whose first three entries are numbers /// (read on a 0..1 scale) or a color object (`{"model": "rgb", "r": …}`)? The effect draws its diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 4075676ca6..2800e8e674 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint}; +use vectorcraft_doc::swatches::PaintFn; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Point, Rect}; @@ -54,7 +55,15 @@ pub fn specs() -> Vec { in_pattern_edit, pattern_done ), - cmd!("object.pattern.cancel", "Cancel", [], None, "{} leave pattern editing mode, discarding changes", in_pattern_edit, pattern_cancel), + cmd!( + "object.pattern.cancel", + "Cancel", + [], + None, + "{} leave pattern editing mode, discarding changes; a pattern made for this edit is removed with its swatches, and every paint of it, new art's fill and stroke included, becomes none", + in_pattern_edit, + pattern_cancel + ), cmd!( "object.pattern.saveCopy", "Save a Copy", @@ -78,7 +87,7 @@ pub fn specs() -> Vec { "Delete Pattern", [], None, - "{name} delete a pattern swatch (objects painted with it lose that paint)", + "{name} delete a pattern and its swatches; every paint of it, new art's fill and stroke included, becomes none → {unpainted: the number of document paints cleared}", has_doc, pattern_delete ), @@ -403,26 +412,96 @@ pub(crate) fn finish_edit(d: &mut Document, sel: &mut Selection) -> Result Result { - s.edit("Cancel Pattern Editing", |d, sel| { + let changed = s.edit("Cancel Pattern Editing", |d, sel| { let (pe, _) = leave_edit(d, sel)?; match pe.original { - Some(orig) => { + Some(mut orig) => { + if orig.name != pe.pattern && (d.pattern(&orig.name).is_some() || swatch_name_clash(d, &pe.pattern, &orig.name)) { + orig.name = pe.pattern.clone(); + } + let back = (orig.name != pe.pattern).then(|| orig.name.clone()); if let Some(def) = d.pattern_mut(&pe.pattern) { *def = orig; } + if let Some(old) = &back { + rename_pattern_uses(d, &pe.pattern, old); + } + Ok(back.map(|old| (pe.pattern, Some(old)))) } None => { - d.patterns.retain(|p| p.name != pe.pattern); - d.swatches.retain(|sw| !matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == pe.pattern)); + forget_pattern(d, &pe.pattern); + Ok(Some((pe.pattern, None))) } } - Ok(()) })?; after_edit_mode(s, None)?; + if let Some((name, to)) = changed { + map_new_art_paints(s, &mut pattern_paints(&name, to.as_deref()))?; + } ok() } +/// A [`PaintFn`] that puts a paint of pattern `to` in place of each paint of pattern `name`, with +/// its placement kept, or no paint for `to` `None`. +fn pattern_paints<'a>(name: &'a str, to: Option<&'a str>) -> impl FnMut(&Paint) -> Option + 'a { + move |p| match p { + Paint::Pattern { pattern, xf } if pattern == name => Some(match to { + Some(n) => Paint::Pattern { pattern: n.to_string(), xf: *xf }, + None => Paint::None, + }), + _ => None, + } +} + +/// Remove pattern `name` from `d`: its definition and every swatch, grouped or not, whose paint is +/// the pattern. Every paint of the pattern left anywhere in the document (the art, opacity masks, +/// type, symbol definitions, graph designs, graphs' stored series and label paints, other patterns' +/// tiles, graphic and character styles and the art of brushes) becomes no paint. Returns the number of such paints. +fn forget_pattern(d: &mut Document, name: &str) -> usize { + d.patterns.retain(|p| p.name != name); + let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if pattern == name); + d.swatches.retain(|sw| !is_it(sw)); + for g in &mut d.swatch_groups { + g.swatches.retain(|sw| !is_it(sw)); + } + super::swatch::map_all_paints(d, &mut pattern_paints(name, None)) +} + +/// Apply `f` to the fill and stroke new art takes and to the paints of its template appearance. A +/// change is recorded with the step just made, so Undo and Redo put these paints back with the +/// document. +fn map_new_art_paints(s: &mut Session, f: &mut PaintFn) -> Result<()> { + let start = super::colormgmt::new_art_colors_before(s)?; + let old = s.paint.clone(); + let pd = &mut s.paint; + let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, + vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, + }); + for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) { + if let Some(q) = f(p) { + *p = q; + } + } + if s.paint != old { + super::colormgmt::record_new_art_colors(s, start)?; + // The step takes the paints the command finally leaves, after the selection's appearance + // is inherited. + let st = s.doc()?; + if st.interaction.is_none() { + let uid = st.uid; + s.batch_paint_docs.push(uid); + } + } + Ok(()) +} + fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let name = s.edit("Save a Copy", |d, _| { @@ -524,6 +603,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; + if let Some(nn) = &new_name { + map_new_art_paints(s, &mut pattern_paints(&name, Some(nn)))?; + } Ok(json!({ "name": out_name })) } @@ -552,47 +634,19 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool { } /// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the -/// pattern's own take the new name. +/// pattern's own take the new name. The paints are renamed wherever [`forget_pattern`] clears them. fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) { - let fix = |p: &mut Paint| { - if let Paint::Pattern { pattern, .. } = p - && pattern == old - { - *pattern = new.to_string(); - } - }; - for sw in d.swatches.iter_mut().chain(d.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) { + for sw in d.swatches_iter_mut() { if sw.name == old && pattern_name(&sw.paint).is_some_and(|p| p == old) { sw.name = new.to_string(); } - fix(&mut sw.paint); } if let Some(pe) = &mut d.pattern_edit && pe.pattern == old { pe.pattern = new.to_string(); } - let ids = nodes_using(d, Some(old)); - for id in ids { - if let Some(n) = d.node_mut(id) { - for it in n.appearance.items.iter_mut() { - match it { - vectorcraft_doc::AppearanceItem::Fill(f) => fix(&mut f.paint), - vectorcraft_doc::AppearanceItem::Stroke(s) => fix(&mut s.paint), - } - } - } - } -} - -fn nodes_using(d: &Document, name: Option<&str>) -> Vec { - let mut ids = vec![]; - d.walk(|n| { - if pattern::uses_pattern(n, name) { - ids.push(n.id); - } - }); - ids + super::swatch::map_all_paints(d, &mut pattern_paints(old, Some(new))); } fn pattern_delete(s: &mut Session, p: &Value) -> Result { @@ -604,29 +658,8 @@ fn pattern_delete(s: &mut Session, p: &Value) -> Result { if editing(s).is_some_and(|e| e.pattern == name) { return Err(bad(C, "finish editing the pattern first")); } - let n = s.edit("Delete Pattern", |d, _| { - d.patterns.retain(|p| p.name != name); - let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == name); - d.swatches.retain(|sw| !is_it(sw)); - for g in &mut d.swatch_groups { - g.swatches.retain(|sw| !is_it(sw)); - } - let ids = nodes_using(d, Some(&name)); - for id in &ids { - if let Some(n) = d.node_mut(*id) { - for it in n.appearance.items.iter_mut() { - let p = match it { - vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, - vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, - }; - if matches!(p, Paint::Pattern { pattern, .. } if *pattern == name) { - *p = Paint::None; - } - } - } - } - Ok(ids.len()) - })?; + let n = s.edit("Delete Pattern", |d, _| Ok(forget_pattern(d, &name)))?; + map_new_art_paints(s, &mut pattern_paints(&name, None))?; Ok(json!({ "unpainted": n })) } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index f6c048a1d0..36444878ef 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -6,8 +6,8 @@ //! `link` ([`super::links`]). //! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options //! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`). -//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by -//! its art (`crop: "bounding"`). +//! - An EMF, WMF or PICT picture becomes one group, clipped to its frame (`crop: "crop"`) or +//! bounded by its art (`crop: "bounding"`). //! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by //! its art: the document an EPS file of ours carries, else what its PostScript draws, else its //! preview ([`fileio::load`]). @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), diff --git a/crates/engine/src/cmd/place/dcs.rs b/crates/engine/src/cmd/place/dcs.rs index b540221a23..69a397c5d1 100644 --- a/crates/engine/src/cmd/place/dcs.rs +++ b/crates/engine/src/cmd/place/dcs.rs @@ -39,6 +39,55 @@ pub(crate) fn is_dcs(bytes: &[u8]) -> bool { vectorcraft_eps::dcs::read(bytes).is_some() } +/// The most bytes read from the start of a file to look at its DCS header. +pub(crate) const HEAD_BYTES: usize = 1 << 20; + +/// Does the header of the PostScript in file `head` (the first bytes of a file `size` bytes long, +/// at most [`HEAD_BYTES`]) name DCS plates? `None` when `head` can't tell: that PostScript doesn't +/// start within it (a binary EPS header that puts it further on), or its header goes on past it. A +/// `head` that holds the whole file is read as [`is_dcs`] reads it, and a binary EPS header whose +/// PostScript or TIFF preview ends past `size` names none, as [`is_dcs`] finds for the whole file. +pub(crate) fn head_is_dcs(head: &[u8], size: u64) -> Option { + const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6]; + if head.len() < HEAD_BYTES || head.len() as u64 >= size { + return Some(is_dcs(head)); + } + let (ps, whole) = if head.starts_with(&DOS_MAGIC) { + let word = |at: usize| head.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize); + let fits = |at: usize| Some((word(at)? as u64).checked_add(word(at + 4)? as u64).is_some_and(|end| end <= size)); + if !fits(4)? || (word(24)? != 0 && !fits(20)?) { + return Some(false); + } + let (start, len) = (word(4)?, word(8)?); + let ps = head.get(start..).filter(|ps| !ps.is_empty())?; + (ps.get(..len).unwrap_or(ps), start.checked_add(len).is_some_and(|end| end <= head.len())) + } else { + (head, false) + }; + if is_dcs(ps) { + return Some(true); + } + (whole || header_ended(ps)).then_some(false) +} + +/// Does `ps`, the start of some PostScript, hold all of the header [`vectorcraft_eps::dcs::read`] +/// looks at (up to `%%EndComments`, a line that isn't a comment, or its 10,000th line)? Its last +/// line may be cut off, so it doesn't count. +fn header_ended(ps: &[u8]) -> bool { + let mut lines = ps.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty()).peekable(); + let mut n = 0; + while let Some(line) = lines.next() { + if lines.peek().is_none() { + return false; + } + n += 1; + if line.starts_with(b"%%EndComments") || !line.starts_with(b"%") || n >= 10_000 { + return true; + } + } + false +} + /// DCS file `bytes`, read from `path` (when there is one: plate files of their own are found in its /// folder), with every plate. pub(crate) fn read(bytes: &[u8], path: Option<&str>) -> Result { diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index c605cd39f3..a73485f4fa 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -212,6 +212,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("fontNamesInEnglish", "Type", "Options", "Show Font Names in English", bool), p!("autoSizeAreaType", "Type", "Options", "Auto Size New Area Type", bool), p!("fontPreview", "Type", "Options", "Enable in-menu font previews", bool), + p!("fontSamplesOnWhite", "Type", "Options", "Show samples on white", bool), p!("fontPreviewSize", "Type", "Options", "Font Preview Size", choice(&[("small", "Small"), ("medium", "Medium"), ("large", "Large")])), p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)), p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool), diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 55245b63a5..22c1c0c929 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent}; use vectorcraft_doc::pattern::pattern_paint; -use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors}; +use vectorcraft_doc::swatches::{LinkFn, PaintFn, color_name, map_node_paints, map_node_solid_paints, node_colors}; use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; @@ -681,32 +681,16 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) { /// paint changed is merged into its entry ([`merge_stored`]), so fields the brush definition does /// not read are kept at every level. pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { - use vectorcraft_brush::{Brush, BrushKind, Colorization}; - let mut n = d.map_solid_paints(f); - // Each stored entry, its brush when it parses, the brush as written before the edit (only - // when its paint changed), and whether its paint changed. - let mut lib: Vec<(Value, Option, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) { - Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(), - None => { - vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect() - } - }; - let mut in_brushes = 0; - for (_, b, base, changed) in lib.iter_mut() { - let Some(b) = b else { continue }; - let before_edit = serde_json::to_value(&*b).unwrap_or_default(); + use vectorcraft_brush::{BrushKind, Colorization}; + let n = d.map_solid_paints(f); + n + edit_brush_library(d, &mut |b| { let key = match b.kind.colorization() { Colorization::HueShift { key } => Some(key), _ => None, }; let before = key.map(|_| art_paint_colors(b.art())); - let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, f)).sum::(); - *changed = in_brush > 0; - if *changed { - *base = before_edit; - } - in_brushes += in_brush; - let (Some(key), Some(before)) = (key, before) else { continue }; + let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, &mut *f)).sum::(); + let (Some(key), Some(before)) = (key, before) else { return in_brush }; // Colors are compared by hex value, as `art_colors` (Brush Options' key colors) lists them. let same = |a: &Color, b: &Color| a.to_hex() == b.to_hex(); let after = art_paint_colors(b.art()); @@ -719,10 +703,47 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { BrushKind::Art(a) => &mut a.colorization, BrushKind::Scatter(s) => &mut s.colorization, BrushKind::Pattern(p) => &mut p.colorization, - BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => continue, + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return in_brush, }; *c = Colorization::HueShift { key }; } + in_brush + }) +} + +/// Apply a paint rewrite ([`Document::map_paints`]) to the document and to the art of the brushes +/// in its library ([`edit_brush_library`]). Returns the number of changed paints. +pub(super) fn map_all_paints(d: &mut Document, f: &mut PaintFn) -> usize { + let n = d.map_paints(f); + n + edit_brush_library(d, &mut |b| b.art_mut().into_iter().map(|art| map_node_paints(art, &mut *f)).sum()) +} + +/// Apply `edit` to each brush of the document's library, the stored entries or, with none stored, +/// the built-in library; `edit` returns how many paints of the brush's art it changed. The library +/// is stored again only when a brush's paint changed. Stored entries are stored again as they were +/// read, except that a brush whose paint changed is merged into its entry ([`merge_stored`]), so +/// fields the brush definition does not read are kept at every level. Returns the number of +/// changed paints. +fn edit_brush_library(d: &mut Document, edit: &mut dyn FnMut(&mut vectorcraft_brush::Brush) -> usize) -> usize { + use vectorcraft_brush::Brush; + // Each stored entry, its brush when it parses, the brush as written before the edit (only + // when its paint changed), and whether its paint changed. + let mut lib: Vec<(Value, Option, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) { + Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(), + None => { + vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect() + } + }; + let mut in_brushes = 0; + for (_, b, base, changed) in lib.iter_mut() { + let Some(b) = b else { continue }; + let before_edit = serde_json::to_value(&*b).unwrap_or_default(); + let in_brush = edit(b); + *changed = in_brush > 0; + if *changed { + *base = before_edit; + } + in_brushes += in_brush; } if in_brushes > 0 { let lib = lib @@ -733,9 +754,8 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { }) .collect(); d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib)); - n += in_brushes; } - n + in_brushes } /// `new` (a definition as written after an edit) with the parts of `raw` (its stored entry) that diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index f5b79aaa84..e71948086f 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -2108,6 +2108,60 @@ fn new_type_is_composed_with_line_end_half_width_punctuation() { assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none()); } +/// `text.setFormat {mojikumi}` takes each predefined Mojikumi set as one undo step, refuses any +/// other value without changing the type, and each set survives a native save and reopen and a +/// paragraph style that captures it. +#[test] +fn every_predefined_mojikumi_set_is_set_saved_and_carried_by_styles() { + use vectorcraft_doc::Mojikumi; + 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": 50, "text": "「雅楽」。"})).unwrap()["id"].as_u64().unwrap(); + let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.para.clone(), + _ => panic!("text"), + }; + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let sets = [ + ("yakumonoHankaku", Mojikumi::YakumonoHankaku), + ("gyomatsuYakumonoHankaku", Mojikumi::GyomatsuYakumonoHankaku), + ("gyomatsuYakumonoZenkaku", Mojikumi::GyomatsuYakumonoZenkaku), + ("yakumonoZenkaku", Mojikumi::YakumonoZenkaku), + ("none", Mojikumi::None), + ("lineEndHalf", Mojikumi::LineEndHalf), + ]; + for (key, set) in sets { + s.execute("text.setFormat", &json!({"mojikumi": key})).unwrap(); + assert_eq!(para(&s).mojikumi, set, "{key}"); + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + match &back.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => assert_eq!(t.para.mojikumi, set, "{key} saved"), + _ => panic!("text"), + } + let style = format!("Spacing {key}"); + s.execute("paraStyle.new", &json!({"name": style, "attrs": {"mojikumi": key}})).unwrap(); + s.execute("text.setFormat", &json!({"mojikumi": if set == Mojikumi::None { "lineEndHalf" } else { "none" }})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": style, "id": id})).unwrap(); + assert_eq!(para(&s).mojikumi, set, "{key} from a paragraph style"); + } + // One undo step back to the set before. + s.execute("text.setFormat", &json!({"mojikumi": "yakumonoHankaku"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf); + // A value that is no set, or not a string, is refused and leaves the type as it was. + s.execute("text.setFormat", &json!({"mojikumi": "gyomatsuYakumonoZenkaku"})).unwrap(); + for bad in [json!("YakumonoHankaku"), json!("lineEnd"), json!(""), json!(1)] { + assert!(s.execute("text.setFormat", &json!({"mojikumi": bad, "leftIndent": 12})).is_err(), "{bad}"); + let p = para(&s); + assert_eq!((p.mojikumi, p.left_indent), (Mojikumi::GyomatsuYakumonoZenkaku, 0.0), "{bad}"); + } + // A null value leaves the set as it is. + s.execute("text.setFormat", &json!({"mojikumi": null, "leftIndent": 12})).unwrap(); + let p = para(&s); + assert_eq!((p.mojikumi, p.left_indent), (Mojikumi::GyomatsuYakumonoZenkaku, 12.0)); +} + #[test] fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 617d9d944c..1e01c8f62e 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -4,12 +4,12 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::{Node, NodeKind, Scaling}; +use vectorcraft_doc::{Node, NodeKind, OrientedBox, Scaling}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::edit::{outermost, selected_roots}; use super::{ - CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or, + CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or, f64_req, find_command, finite_numbers, has_doc, index, }; use crate::EngineError; @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Free Distort", [], None, - "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays", + "{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays", has_doc, distort ), @@ -62,11 +62,14 @@ pub fn specs() -> Vec { pub struct Projective { m: [f64; 8], src: Rect, + /// Page → the frame `src` is measured in. + pre: Affine, } impl Projective { - /// Map `src`'s corners (TL, TR, BR, BL) onto `q`. - pub fn from_rect(src: Rect, q: [Point; 4]) -> Self { + /// Map the corners (TL, TR, BR, BL) of `src`, a box that may be turned, onto the page points `q`. + pub fn from_box(bx: OrientedBox, q: [Point; 4]) -> Self { + let src = bx.rect; let [p0, p1, p2, p3] = q; let (dx1, dx2, dx3) = (p1.x - p2.x, p3.x - p2.x, p0.x - p1.x + p2.x - p3.x); let (dy1, dy2, dy3) = (p1.y - p2.y, p3.y - p2.y, p0.y - p1.y + p2.y - p3.y); @@ -80,11 +83,12 @@ impl Projective { let b = p3.x - p0.x + h * p3.x; let d = p1.y - p0.y + g * p1.y; let e = p3.y - p0.y + h * p3.y; - Self { m: [a, b, p0.x, d, e, p0.y, g, h], src } + Self { m: [a, b, p0.x, d, e, p0.y, g, h], src, pre: bx.to_doc().inverse() } } pub fn apply(&self, p: Point) -> Point { let [a, b, c, d, e, f, g, h] = self.m; + let p = self.pre * p; let u = if self.src.width().abs() > 1e-12 { (p.x - self.src.x0) / self.src.width() } else { 0.0 }; let v = if self.src.height().abs() > 1e-12 { (p.y - self.src.y0) / self.src.height() } else { 0.0 }; let w = g * u + h * v + 1.0; @@ -152,17 +156,29 @@ fn distort(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "nothing to distort")); } + let angle = p.get("angle").filter(|v| !v.is_null()).map(|_| f64_req(p, "angle", C)).transpose()?.map_or(0.0, vectorcraft_geom::normalize_deg); let src = match p.get("from").filter(|v| !v.is_null()) { - None => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?, + None if angle == 0.0 => OrientedBox::aligned(s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?), + None => { + // The selection's bounds square to the turned frame. + let doc = &s.doc()?.doc; + let (symbols, to_local) = (doc.symbol_boxes(), OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse()); + let rect = ids + .iter() + .filter_map(|id| doc.node(*id)) + .fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.bounds_in(&symbols, to_local, false))); + OrientedBox { angle, rect: rect.ok_or_else(|| bad(C, "nothing to distort"))? } + } Some(v) => { let [x0, y0, x1, y1] = finite_numbers(v).ok_or_else(|| bad(C, "from must be [x0, y0, x1, y1]"))?; - Rect::new(x0, y0, x1, y1) + OrientedBox { angle, rect: Rect::new(x0, y0, x1, y1) } } }; - if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 { + let r = src.rect; + if !r.width().is_finite() || !r.height().is_finite() || r.width().abs() < 1e-9 || r.height().abs() < 1e-9 { return Err(bad(C, "cannot distort a zero-size or non-finite bounding box")); } - let pr = Projective::from_rect(src, [tl, tr, br, bl]); + let pr = Projective::from_box(src, [tl, tr, br, bl]); s.edit("Free Distort", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index cc545b179f..3ab6a7dcb8 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -27,7 +27,8 @@ pub struct HistoryEntry { pub(crate) named: Option, /// A number no other step takes ([`History::step`]). serial: u64, - /// Original and converted new-art colors, only for steps that change document color mode. + /// Original and changed new-art paints, only for steps that change them: a document color mode + /// change, and a pattern Cancel, Delete or rename that clears or renames a pattern paint. pub(crate) color_mode_paints: Option<(vectorcraft_tools::PaintDefaults, vectorcraft_tools::PaintDefaults)>, /// Owned new-art snapshots included in both directional estimates. color_mode_bytes: usize, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0a5eeaa65c..3c3460ecb2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,7 +26,7 @@ use vectorcraft_doc::{Document, NodeId, NodeKind, Selection}; use vectorcraft_geom::Affine; use vectorcraft_tools::{PaintDefaults, Tool}; -pub use cmd::clipboard::Clipboard; +pub use cmd::clipboard::{Clipboard, SourceLayer}; pub use cmd::create::constrain_click; pub use cmd::distortcmds::perspective_click; pub use cmd::params::{check_keys, check_params}; @@ -217,9 +217,10 @@ pub struct Session { /// groups: for each step, the documents ([`DocState::uid`]) whose undo groups it belongs to, and /// the index of the document it left active when it changed the active document. pub(crate) batch_steps: Option, Option)>>, - /// After `command.batch` kept a color-mode step's new-art colors, until the top-level command - /// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest - /// history step takes the colors the batch finally left. + /// After `command.batch` kept a color-mode step's new-art colors, or a pattern command recorded + /// the new-art paints it changed, until the top-level command has inherited the selection's + /// appearance: the documents ([`DocState::uid`]) whose newest history step takes the colors the + /// command finally left. pub(crate) batch_paint_docs: Vec, /// The composite fonts (lowercase names) that the open documents defined when the font /// database was last brought in step with them ([`cmd::compositefonts::sync`]). diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 52c4b7a4f2..05ef5e84f5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -104,6 +104,9 @@ pub struct Prefs { pub font_names_in_english: bool, pub auto_size_area_type: bool, pub font_preview: bool, + /// Type › Show samples on white: the font menus draw each family's sample in dark type on a + /// white area in every brightness (off by default). + pub font_samples_on_white: bool, pub font_preview_size: String, pub recent_fonts_count: u32, pub missing_glyph_protection: bool, @@ -427,6 +430,7 @@ impl Default for Prefs { font_names_in_english: true, auto_size_area_type: false, font_preview: true, + font_samples_on_white: false, font_preview_size: s("medium"), recent_fonts_count: 10, missing_glyph_protection: true, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index a1468f4612..3d0d9ba1c6 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -290,7 +290,10 @@ impl Session { st.revision += 1; } st.doc = it.doc.clone(); - let restore_paint = cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch"); + // A batch, or a preview that changed the new-art paints (a pattern rename), started from + // the paints the interaction began with. + let restore_paint = + cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some(); let paint = it.paint.clone(); it.preview_paint = None; it.color_mode_paints = None; @@ -423,7 +426,7 @@ impl Session { if let Some(paint) = self .active() .and_then(|st| st.interaction.as_ref()) - .filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch")) + .filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some()) .map(|it| it.paint.clone()) { self.paint = paint; @@ -433,6 +436,9 @@ impl Session { st.undo_interaction(); // Guides locked and slices locked or hidden during the interaction stay deselected. deselect_hidden(&menu, &st.doc, &mut st.selection); + // With New Art Has Basic Appearance off, new art takes the appearance of the selection as + // the document has it again, not as a preview left it. + self.inherit_new_art(); Ok(()) } diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 0d9188243c..8907b21cc0 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -104,6 +104,92 @@ fn handles_turn_with_the_object_and_scale_along_its_axes() { assert!(near(selection_box(&s).rect.width(), 100.0)); } +/// Presses, drags and lets go with the current tool along `pts`, holding `mods`. +fn drag_with(s: &mut Session, pts: &[Point], mods: Mods) { + let v = ViewInfo::default(); + let kinds = std::iter::once(PointerKind::Down).chain(std::iter::repeat_n(PointerKind::Drag, pts.len().saturating_sub(2))); + for (kind, p) in kinds.chain([PointerKind::Up]).zip(pts) { + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap(); + } +} + +/// The anchors of path `id`, on the page. +fn anchors(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect() +} + +fn has_point(pts: &[Point], p: Point) -> bool { + pts.iter().any(|q| q.distance(p) < 1e-6) +} + +#[test] +fn free_transform_handles_turn_with_the_object_and_scale_along_its_axes() { + let mut s = session(); + let a = turned_rect(&mut s); + let b = selection_box(&s); + // The Right handle of the turned box, dragged 50 pt further out along the object's own x axis. + let right = b.to_doc() * Handle::Right.pos(b.rect); + let to = b.to_doc() * (Handle::Right.pos(b.rect) + vectorcraft_geom::Vec2::new(50.0, 0.0)); + let n = undo_len(&s); + s.select_tool("freeTransform", ViewInfo::default()).unwrap(); + drag_with(&mut s, &[right, to, to], Mods::default()); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Scale")); + // 150 × 50 along its own axes, still at exactly 45°, the left side where it was. + assert_eq!(angle(&s, a), b.angle); + let nb = selection_box(&s); + assert!(near(nb.rect.width(), 150.0) && near(nb.rect.height(), 50.0), "{nb:?}"); + let left = |b: &OrientedBox| b.to_doc() * Handle::Left.pos(b.rect); + assert!(left(&nb).distance(left(&b)) < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(selection_box(&s).rect.width(), 100.0)); +} + +#[test] +fn free_transform_shears_along_the_turned_side() { + let mut s = session(); + let a = turned_rect(&mut s); + let b = selection_box(&s); + let [tl, tr, br, bl] = b.corners(); + // Dragging the Top handle 20 pt along the object's own top side with Cmd held once the drag + // started (a Cmd press is the temporary Selection tool) slides that side along itself; the + // bottom side stays. + let top = b.to_doc() * Handle::Top.pos(b.rect); + let along = b.to_doc() * (Handle::Top.pos(b.rect) + vectorcraft_geom::Vec2::new(20.0, 0.0)); + let shift = along - top; + let v = ViewInfo::default(); + s.select_tool("freeTransform", v).unwrap(); + let cmd = Mods { cmd: true, ..Default::default() }; + for (kind, p, m) in [(PointerKind::Down, top, Mods::default()), (PointerKind::Drag, along, cmd), (PointerKind::Up, along, cmd)] { + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(m), v).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Shear")); + let pts = anchors(&s, a); + for p in [tl + shift, tr + shift, br, bl] { + assert!(has_point(&pts, p), "{p:?} not in {pts:?}"); + } +} + +#[test] +fn free_transform_distort_starts_from_the_turned_box() { + let mut s = session(); + let a = turned_rect(&mut s); + let b = selection_box(&s); + let [tl, tr, br, bl] = b.corners(); + s.select_tool("freeTransform", ViewInfo::default()).unwrap(); + s.set_tool_option("mode", &json!("distort")); + let to = br + vectorcraft_geom::Vec2::new(10.0, 20.0); + let n = undo_len(&s); + drag_with(&mut s, &[br, to, to], Mods::default()); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort")); + // The dragged corner of the turned box lands under the pointer; the other three stay. + let pts = anchors(&s, a); + for p in [tl, tr, to, bl] { + assert!(has_point(&pts, p), "{p:?} not in {pts:?}"); + } +} + #[test] fn transform_panel_sizes_follow_the_turned_box() { let mut s = session(); diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 16ce8d8c77..d0e003ec49 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -1094,7 +1094,8 @@ fn paste_remembers_layers_restores_the_source_layers() { let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap()); let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); copy(&mut s, &[a, b]); - assert_eq!(s.clipboard.source_layers, [Some("Layer 1".to_string()), Some("Art".to_string())]); + let names: Vec<_> = s.clipboard.source_layers.iter().map(|l| l.as_ref().map(|l| l.names.clone())).collect(); + assert_eq!(names, [Some(vec!["Layer 1".to_string()]), Some(vec!["Art".to_string()])]); let other = id_of(&s.execute("layer.new", &json!({"name": "Other"})).unwrap()); assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({})).unwrap()["on"], true); assert!(doc(&s).paste_remembers_layers); @@ -1121,6 +1122,157 @@ fn paste_remembers_layers_restores_the_source_layers() { assert!(ids.iter().all(|id| doc(&s).parent_of(*id) == Some(other))); } +/// A sublayer `name` made inside `parent`; it becomes the current layer. +fn sublayer(s: &mut Session, parent: NodeId, name: &str) -> NodeId { + id_of(&s.execute("layer.newSublayer", &json!({"parent": parent.0, "name": name})).unwrap()) +} + +/// The names of the layers around `id`, the top-level layer first. +fn layer_path(s: &Session, id: NodeId) -> Vec { + let d = doc(s); + d.ancestry(id).unwrap().iter().filter_map(|a| d.node(*a).filter(|n| n.is_layer())).map(|l| l.name.clone().unwrap()).collect() +} + +/// Art from two sublayers of the same name in different layers pastes back into each one; in +/// another document a missing sublayer is made inside its layer, and a missing layer on top with +/// the sublayer inside, in the paste's one undo step. +#[test] +fn paste_remembers_layers_keeps_same_named_sublayers_apart() { + let mut s = session(); + let first = doc(&s).layers[0].id; + let sub1 = sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let second = id_of(&s.execute("layer.new", &json!({"name": "Layer 2", "top": true})).unwrap()); + let sub2 = sublayer(&mut s, second, "Sub"); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + copy(&mut s, &[a, b]); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + s.execute("layer.setCurrent", &json!({"id": first.0})).unwrap(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + let parents: Vec<_> = ids.iter().map(|id| doc(&s).parent_of(*id)).collect(); + assert_eq!(parents, [Some(sub1), Some(sub2)]); + new_doc(&mut s); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let before = doc(&s).clone(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + let paths: Vec<_> = ids.iter().map(|id| layer_path(&s, *id)).collect(); + assert_eq!(paths, [["Layer 1", "Sub"], ["Layer 2", "Sub"]]); + assert_eq!(doc(&s).layers.len(), 2, "Layer 1 is kept; Layer 2 is new"); + assert_eq!(doc(&s).layers[0].children().unwrap().len(), 1, "Sub is made inside Layer 1"); + let after = doc(&s).clone(); + // Pasting again finds the layers made. + let again = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + let parents: Vec<_> = again.iter().map(|id| doc(&s).parent_of(*id)).collect(); + assert_eq!(parents, ids.iter().map(|id| doc(&s).parent_of(*id)).collect::>()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*doc(&s), after); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*doc(&s), before, "one undo step takes the art and the layers made for it"); +} + +/// Art whose sublayer was deleted after the copy pastes into a new sublayer of that name inside +/// the same layer, not into a sublayer of that name in another layer. +#[test] +fn paste_remembers_layers_makes_a_deleted_sublayer_again_inside_its_layer() { + let mut s = session(); + let first = doc(&s).layers[0].id; + let sub = sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let second = id_of(&s.execute("layer.new", &json!({"name": "Layer 2", "top": true})).unwrap()); + let elsewhere = sublayer(&mut s, second, "Sub"); + copy(&mut s, &[a]); + s.execute("layer.delete", &json!({"id": sub.0})).unwrap(); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + let made = doc(&s).parent_of(ids[0]).unwrap(); + assert_ne!(made, elsewhere); + assert_eq!(doc(&s).parent_of(made), Some(first)); + assert_eq!(layer_path(&s, ids[0]), ["Layer 1", "Sub"]); +} + +/// Art whose sublayer was moved into another layer after the copy pastes into that sublayer where +/// it is now; no sublayer is made where it was. +#[test] +fn paste_remembers_layers_follows_a_moved_sublayer() { + let mut s = session(); + let first = doc(&s).layers[0].id; + let sub = sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let second = id_of(&s.execute("layer.new", &json!({"name": "Layer 2", "top": true})).unwrap()); + copy(&mut s, &[a]); + s.execute("layer.move", &json!({"id": sub.0, "target": second.0})).unwrap(); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(doc(&s).parent_of(ids[0]), Some(sub)); + assert_eq!(layer_path(&s, ids[0]), ["Layer 2", "Sub"]); + assert!(node(&s, first).children().unwrap().is_empty(), "no Sub is made in Layer 1"); +} + +/// In another document with layers of the same names, art goes into the topmost chain of layers +/// that matches the most names, a shown and unlocked one before a hidden or locked one. +#[test] +fn paste_remembers_layers_takes_the_topmost_open_match_of_repeated_names() { + let mut s = session(); + let first = doc(&s).layers[0].id; + sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + copy(&mut s, &[a]); + new_doc(&mut s); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let lower = doc(&s).layers[0].id; + let lower_sub = sublayer(&mut s, lower, "Sub"); + s.execute("layer.new", &json!({"name": "Sub", "top": true})).unwrap(); + let upper = id_of(&s.execute("layer.new", &json!({"name": "Layer 1", "top": true})).unwrap()); + // The upper Layer 1 has no Sub, and a top-level Sub is not inside a Layer 1: the lower chain + // matches both names. + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(doc(&s).parent_of(ids[0]), Some(lower_sub)); + let upper_sub = sublayer(&mut s, upper, "Sub"); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(doc(&s).parent_of(ids[0]), Some(upper_sub), "the topmost of two full matches"); + s.execute("layer.setProps", &json!({"id": upper_sub.0, "locked": true})).unwrap(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(doc(&s).parent_of(ids[0]), Some(lower_sub), "an unlocked match before a locked one"); +} + +/// A remembered layer or sublayer that is hidden or locked, or inside a layer that is, refuses the +/// whole paste: the document, the selection and the undo history stay as they were. +#[test] +fn paste_remembers_layers_refuses_a_hidden_or_locked_layer() { + let (locked, hidden) = (json!({"locked": true}), json!({"visible": false})); + for (on_sub, props) in [(true, &locked), (true, &hidden), (false, &locked), (false, &hidden)] { + let mut s = session(); + let first = doc(&s).layers[0].id; + let sub = sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let other = id_of(&s.execute("layer.new", &json!({"name": "Other", "top": true})).unwrap()); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + copy(&mut s, &[b, a]); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let mut p = props.clone(); + p["id"] = json!(if on_sub { sub.0 } else { first.0 }); + s.execute("layer.setProps", &p).unwrap(); + s.execute("layer.setCurrent", &json!({"id": other.0})).unwrap(); + let before = s.doc().unwrap().clone(); + assert!(s.execute("edit.paste", &json!({})).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc, before.doc, "{p}"); + assert_eq!(s.doc().unwrap().selection, before.selection, "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len(), "{p}"); + } + // In another document, a locked layer of the copied layer's name is refused too. + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + copy(&mut s, &[a]); + new_doc(&mut s); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let locked = doc(&s).layers[0].id; + id_of(&s.execute("layer.new", &json!({"name": "Flyer"})).unwrap()); + s.execute("layer.setProps", &json!({"id": locked.0, "locked": true})).unwrap(); + let before = doc(&s).clone(); + assert!(s.execute("edit.paste", &json!({})).is_err()); + assert_eq!(*doc(&s), before, "no second Layer 1 is made"); +} + #[test] fn paste_remembers_layers_survives_save_and_old_files_load_without_it() { let mut s = session(); diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 2801432a0a..6c17f04c0d 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -357,6 +357,42 @@ fn a_file_that_lost_its_clipping_path_unclips_the_image() { ); } +#[test] +fn a_file_that_gains_its_clipping_path_again_clips_the_group_that_stayed() { + let dir = temp_dir("update-regained-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let placed = NodeId(r["ids"][0].as_u64().unwrap()); + let id = image_of(&s, &r["ids"][0]).id; + let other = s.execute("file.place", &json!({ "path": path, "at": [200, 100] })).unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [other["ids"][0]] })).unwrap(); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + // The symbol's art: how deep the image is. + let depth = |s: &Session| { + fn find(n: &Node, at: usize) -> Option { + if matches!(n.kind, NodeKind::Image(_)) { + return Some(at); + } + n.children().and_then(|ch| ch.iter().find_map(|c| find(c, at + 1))) + } + find(&s.doc().unwrap().doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art, 0) + }; + let clipped_depth = depth(&s); + for _ in 0..2 { + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + std::fs::write(&path, two_plates()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.parent_of(id), Some(placed), "the placed group holds the image itself"); + let NodeKind::Group { children, clip: true } = &doc.node(placed).unwrap().kind else { panic!("a clip group again") }; + assert!(matches!(&children[..], [c, im] if matches!(c.kind, NodeKind::Path { clipping: true, .. }) && im.id == id)); + assert_eq!(depth(&s), clipped_depth, "the symbol's image is not nested deeper"); + assert!(symbol_clip_width(&s, "Pic").is_some(), "the symbol's image is clipped again"); + } +} + /// The kind of group `g` is, as (clip, the kinds of its children are just one image). fn plain_group_of_image(g: &Node) -> Option<(bool, bool)> { match &g.kind { @@ -632,6 +668,86 @@ fn a_saved_document_reads_the_plates_again_and_prints_the_spot_ink() { assert_eq!(inks, ["Cyan", "Magenta", "Yellow", "Black", "PANTONE 185 C"]); } +#[test] +fn a_saved_document_reads_the_plates_again_with_the_low_resolution_eps_proxy() { + let dir = temp_dir("saved-proxy"); + // Plates wider than a preview, so the saved document keeps a preview of the image. + let wide = |gray: u8| { + let hex = format!("{gray:02x}").repeat(300); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n20 10 scale\n300 1 8 [300 0 0 -1 0 1] <{hex}> image\nshowpage\n%%Trailer\n%%EOF\n" + ) + }; + let file = single_file(&[("Cyan", wide(0)), ("PANTONE 185 C", wide(128))], "%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n"); + let path = write(&dir, "pic.eps", &file); + let saved = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); + let mut s = session(); + s.execute("file.place", &json!({ "path": path })).unwrap(); + s.execute("document.save", &json!({ "path": saved })).unwrap(); + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "lowResProxyEps", "value": true})).unwrap(); + s.execute("document.open", &json!({ "path": saved })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(list["links"][0]["format"], "DCS", "{list}"); + let id = NodeId(list["links"][0]["id"].as_u64().unwrap()); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + assert!(s.doc().unwrap().doc.images[&im.key].separated().is_some(), "the plates, read from the file again"); +} + +#[test] +fn a_dcs_header_is_found_at_the_start_of_a_file_or_behind_a_binary_eps_header() { + use crate::cmd::place::dcs::{HEAD_BYTES, head_is_dcs, is_dcs}; + // A whole file, as `is_dcs` reads it. + let whole = |b: &[u8]| { + let is = head_is_dcs(b, b.len() as u64); + assert_eq!(is, Some(is_dcs(b))); + is + }; + let ps = two_plates(); + assert_eq!(whole(&ps), Some(true)); + assert_eq!(whole(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n"), Some(false)); + let binary = |start: u32| { + let mut b = vec![0xC5, 0xD0, 0xD3, 0xC6]; + b.extend(start.to_le_bytes()); + b.extend((ps.len() as u32).to_le_bytes()); + b.resize(start as usize, 0); + b.extend(&ps); + b + }; + assert_eq!(whole(&binary(30)), Some(true)); + // A binary header whose PostScript or TIFF preview ends past the end of the file names none. + let mut cut = binary(30); + cut[8..12].copy_from_slice(&(64u32 << 20).to_le_bytes()); + assert_eq!(whole(&cut), Some(false)); + let mut tiff = binary(30); + tiff[20..24].copy_from_slice(&30u32.to_le_bytes()); + tiff[24..28].copy_from_slice(&(64u32 << 20).to_le_bytes()); + assert_eq!(whole(&tiff), Some(false)); + // A file longer than the bytes read, its PostScript running past them: its header is read from + // the part there is, unless the header puts the PostScript or the preview past the file's end. + let mut big = binary(30); + big.resize(HEAD_BYTES, b' '); + big[8..12].copy_from_slice(&(64u32 << 20).to_le_bytes()); + assert_eq!(head_is_dcs(&big, 30 + (64 << 20)), Some(true)); + assert_eq!(head_is_dcs(&big, 30 + (64 << 20) - 1), Some(false)); + big[20..24].copy_from_slice(&30u32.to_le_bytes()); + big[24..28].copy_from_slice(&(65u32 << 20).to_le_bytes()); + assert_eq!(head_is_dcs(&big, 30 + (64 << 20)), Some(false)); + // PostScript that starts past the bytes read: the whole file is needed. + let far = binary(HEAD_BYTES as u32 + 10); + assert_eq!(head_is_dcs(&far[..HEAD_BYTES], far.len() as u64), None); + // A header that goes on past the bytes read may name its plates further on. + let mut long = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + while long.len() < HEAD_BYTES { + long.extend(format!("%%Title: {}\n", "x".repeat(190)).as_bytes()); + } + long.truncate(HEAD_BYTES); + assert_eq!(head_is_dcs(&long, HEAD_BYTES as u64 + 1), None); + // One that ended within them names none. + long[30..45].copy_from_slice(b"\n%%EndComments\n"); + assert_eq!(head_is_dcs(&long, HEAD_BYTES as u64 + 1), Some(false)); +} + #[test] fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() { let dir = temp_dir("transparency"); diff --git a/crates/engine/src/tests/envelope_edit.rs b/crates/engine/src/tests/envelope_edit.rs index 7b87bef2cb..ccad701312 100644 --- a/crates/engine/src/tests/envelope_edit.rs +++ b/crates/engine/src/tests/envelope_edit.rs @@ -151,6 +151,239 @@ fn mesh_handles_turn_with_the_envelope_and_round_trip() { assert!(matches!(n.kind, NodeKind::Envelope { kind, .. } if matches!(&*kind, EnvelopeKind::Mesh { handles, .. } if handles.is_empty()))); } +/// The kind of envelope `e`. +fn kind(s: &Session, e: NodeId) -> EnvelopeKind { + match node(s, e).kind { + NodeKind::Envelope { kind, .. } => *kind, + k => panic!("{k:?}"), + } +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Moving a point of a warp envelope makes it a mesh envelope on the warp's grid with that point +/// moved, as one undo step: the other points and every handle stay where the warp had them, and +/// the shape away from the moved point stays the warp's. Undo gives back the warp, Redo the edit. +#[test] +fn moving_a_warp_point_makes_it_a_mesh_envelope_in_one_step() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50})); + let warp = node(&s, e); + let grid = live::warp_grid(&warp).unwrap(); + assert_eq!((grid.rows, grid.cols), (4, 4)); + let before = EnvelopeMap::of(&warp).unwrap(); + let n = undo_len(&s); + // An interior point. + s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": 6, "x": 160, "y": 120})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + let (rows, cols, points, handles) = mesh(&s, e); + assert_eq!((rows, cols, points.len(), handles.len()), (4, 4, 25, 25)); + for (i, q) in grid.points.iter().enumerate() { + let want = if i == 6 { Point::new(160.0, 120.0) } else { q.p }; + assert!(points[i].distance(want) < 1e-9, "point {i}: {:?} vs {want:?}", points[i]); + assert_eq!(handles[i], q.handles, "handles of point {i}"); + } + let moved = node(&s, e); + let after = EnvelopeMap::of(&moved).unwrap(); + let b = before.src(); + let tol = b.width().hypot(b.height()) * 1e-3; + for (u, v) in [(0.9, 0.9), (0.6, 0.875), (1.0, 0.5), (0.0, 1.0)] { + assert!(after.at(u, v).distance(before.at(u, v)) <= tol, "({u}, {v}): {:?} vs {:?}", after.at(u, v), before.at(u, v)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, e), warp, "Undo gives back the warp, style and bend included"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(mesh(&s, e).2[6], Point::new(160.0, 120.0)); + // A corner of a warp, and a handle with object.envelope.setMeshPoint. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [0], "dx": -10, "dy": -5})).unwrap(); + assert!(mesh(&s, e).2[0].distance(grid.points[0].p + Vec2::new(-10.0, -5.0)) < 1e-9); + s.execute("edit.undo", &json!({})).unwrap(); + let end = grid.points[12].p + Vec2::new(30.0, -30.0); + s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 12, "x": end.x, "y": end.y, "handle": 0})).unwrap(); + let (_, _, points, handles) = mesh(&s, e); + assert!((points[12] + handles[12][live::H_RIGHT]).distance(end) < 1e-9); + assert_eq!(handles[12][live::H_LEFT], grid.points[12].handles[live::H_LEFT]); + assert_eq!(undo_len(&s), n + 1); +} + +/// After a warp's point is edited, the envelope keeps its content, options and fidelity; it saves +/// and opens again as it is; Reset with Mesh with Maintain Shape keeps the edited shape and Reset +/// with Warp gives the chosen style; Release gives back the edited mesh as its shape and Expand +/// keeps the edited distortion. +#[test] +fn an_edited_warp_saves_resets_releases_and_expands_as_edited() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40})); + s.execute("object.envelope.options", &json!({"fidelity": 80, "distortAppearance": false})).unwrap(); + let warp = node(&s, e); + // The middle of the bottom edge, 30 down. + s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [22], "dx": 0, "dy": 30})).unwrap(); + let edited = node(&s, e); + let (NodeKind::Envelope { content: c0, options: o0, fidelity: f0, .. }, NodeKind::Envelope { content: c1, options: o1, fidelity: f1, .. }) = + (&warp.kind, &edited.kind) + else { + panic!("envelopes") + }; + assert_eq!((c0, o0, f0), (c1, o1, f1), "content, options and fidelity stay"); + let d = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + assert_eq!(back.node(e), d.node(e), "a save opens again as edited"); + // Reset with Mesh, keeping the shape: the edited points. + let (_, _, points, _) = mesh(&s, e); + s.execute("object.envelope.resetWithMesh", &json!({"maintainShape": true})).unwrap(); + let (rows, cols, kept, _) = mesh(&s, e); + assert_eq!((rows, cols), (4, 4)); + for (p, q) in points.iter().zip(&kept) { + assert!(p.distance(*q) < 1e-6, "{p:?} vs {q:?}"); + } + // Reset with Warp: the chosen style replaces the edit. + s.execute("object.envelope.resetWithWarp", &json!({"style": "bulge", "bend": 30})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { style, bend, .. } if style == "bulge" && bend == 30.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, e), edited); + // Release: the content and the edited mesh as the envelope's shape. + s.execute("select.set", &json!({"ids": [e.0]})).unwrap(); + let ids = s.execute("object.envelope.release", &json!({})).unwrap(); + let ids: Vec = ids["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + let shape = ids.iter().find_map(|id| match node(&s, *id).kind { + NodeKind::Mesh(m) => Some(m), + _ => None, + }); + let shape = shape.expect("the envelope's shape"); + assert_eq!(shape.points.iter().map(|q| q.p).collect::>(), points); + s.execute("edit.undo", &json!({})).unwrap(); + // Expand: the distorted content follows the moved point, unlike the warp's. + let expanded = |s: &mut Session| { + s.execute("select.set", &json!({"ids": [e.0]})).unwrap(); + s.execute("object.envelope.expand", &json!({})).unwrap(); + let b = node(s, e).geometric_bounds().unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + b + }; + let edited_bounds = expanded(&mut s); + s.execute("edit.undo", &json!({})).unwrap(); + let warp_bounds = expanded(&mut s); + assert!(edited_bounds.y1 > warp_bounds.y1 + 5.0, "{edited_bounds:?} vs {warp_bounds:?}"); +} + +/// An edit of a warp's points that fails (an index out of range, here on the second of two +/// warps) changes neither warp and adds no undo step; a warp that no grid follows closely enough +/// shows no points and is refused, and so is a top object envelope. +#[test] +fn a_warp_point_edit_that_fails_changes_nothing() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50})); + let (_, f) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "wave", "bend": 50})); + let (we, wf, n) = (node(&s, e), node(&s, f), undo_len(&s)); + let r = s.execute("object.mesh.movePoints", &json!({"items": [{"id": e.0, "points": [0]}, {"id": f.0, "points": [3, 99999]}], "dx": 5, "dy": 5})); + assert!(r.is_err(), "{r:?}"); + let r = s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 25, "x": 0, "y": 0})); + assert!(r.is_err(), "{r:?}"); + assert_eq!((node(&s, e), node(&s, f), undo_len(&s)), (we, wf, n), "nothing changed"); + // The old point map's fisheye: no grid of up to 16×16 patches follows it. + let (_, g) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "fisheye", "bend": 100, "version": 0})); + let old = node(&s, g); + assert!(live::warp_grid(&old).is_none()); + assert!(live::envelope_overlay(&old).unwrap().1.is_none(), "no points show"); + let n = undo_len(&s); + for (cmd, p) in [ + ("object.mesh.movePoint", json!({"id": g.0, "index": 0, "x": 0, "y": 0})), + ("object.envelope.setMeshPoint", json!({"id": g.0, "index": 0, "x": 0, "y": 0})), + ("object.mesh.addLine", json!({"id": g.0, "x": 200, "y": 150})), + ] { + let r = s.execute(cmd, &p); + assert!( + matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "this warp's points can't be edited: use Reset with Mesh first"), + "{cmd}: {r:?}" + ); + } + assert_eq!((node(&s, g), undo_len(&s)), (old, n)); +} + +/// An edit that moves no point of a warp (no points named, a move by nothing, a point placed where +/// it is, a line added through one of its points) leaves it a warp and records no undo step. +#[test] +fn a_warp_edit_that_moves_nothing_leaves_the_warp() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50})); + let (warp, n) = (node(&s, e), undo_len(&s)); + let grid = live::warp_grid(&warp).unwrap(); + s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [], "dx": 10, "dy": 0})).unwrap(); + s.execute("object.mesh.movePoints", &json!({"id": e.0, "points": [3, 6], "dx": 0, "dy": 0})).unwrap(); + assert_eq!((node(&s, e), undo_len(&s)), (warp.clone(), n)); + let q = grid.points[6].clone(); + s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": 6, "x": q.p.x, "y": q.p.y})).unwrap(); + s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 6, "x": q.p.x, "y": q.p.y})).unwrap(); + // A line added through a point of the warp's grid adds none. + let r = s.execute("object.mesh.addLine", &json!({"id": e.0, "x": q.p.x, "y": q.p.y})).unwrap(); + assert_eq!(r["index"], json!(6)); + assert_eq!((node(&s, e), undo_len(&s)), (warp, n)); + // An unknown mesh still fails. + assert!(s.execute("object.mesh.movePoints", &json!({"id": 99999, "points": [], "dx": 1, "dy": 0})).is_err()); +} + +/// A handle placed at its own end, sent as point plus handle, leaves a warp a warp, even where +/// `(p + v) - p` rounds to a value other than `v`. +#[test] +fn a_warp_handle_placed_at_its_end_leaves_the_warp() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50})); + let (warp, n) = (node(&s, e), undo_len(&s)); + let grid = live::warp_grid(&warp).unwrap(); + let mut rounds = 0; + for (index, pt) in grid.points.iter().enumerate() { + for (h, v) in pt.handles.iter().enumerate() { + let end = pt.p + *v; + if end - pt.p != *v { + rounds += 1; + } + for cmd in ["object.mesh.movePoint", "object.envelope.setMeshPoint"] { + s.execute(cmd, &json!({"id": e.0, "index": index, "x": end.x, "y": end.y, "handle": h})).unwrap(); + assert_eq!((node(&s, e), undo_len(&s)), (warp.clone(), n), "{cmd}: point {index}, handle {h}"); + } + } + } + assert!(rounds > 0, "the grid has a handle whose end does not give the handle back"); +} + +/// With Direct Selection, a selected warp's point drags as `object.mesh.movePoint`: the preview +/// makes it a mesh envelope, Escape gives back the warp with no undo step, and a finished drag is +/// one undo step. While its contents are edited, its points don't take the press. +#[test] +fn direct_selection_drags_a_warps_point_and_escape_gives_it_back() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50})); + let warp = node(&s, e); + let corner = live::warp_grid(&warp).unwrap().points[24].p; + s.execute("select.set", &json!({"ids": [e.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("directSelection", v).unwrap(); + let n = undo_len(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, corner.x, corner.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, corner.x + 20.0, corner.y + 10.0), v).unwrap(); + assert!(mesh(&s, e).2[24].distance(corner + Vec2::new(20.0, 10.0)) < 1e-9, "the preview moves the point"); + s.tool_key(vectorcraft_tools::ToolKey::Escape, vectorcraft_tools::Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, corner.x + 20.0, corner.y + 10.0), v).unwrap(); + assert_eq!((node(&s, e), undo_len(&s)), (warp.clone(), n), "Escape gives back the warp"); + s.pointer(&PointerEvent::new(PointerKind::Down, corner.x, corner.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, corner.x + 20.0, corner.y + 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, corner.x + 20.0, corner.y + 10.0), v).unwrap(); + assert_eq!(undo_len(&s), n + 1); + assert!(mesh(&s, e).2[24].distance(corner + Vec2::new(20.0, 10.0)) < 1e-9); + // Edit Contents: the press doesn't take the envelope's point. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + s.execute("select.set", &json!({"ids": [e.0]})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, corner.x, corner.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, corner.x + 20.0, corner.y + 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, corner.x + 20.0, corner.y + 10.0), v).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { .. }), "{:?}", kind(&s, e)); +} + #[test] fn reset_with_mesh_keeps_the_warp_shape_along_the_grid() { let mut s = session(); diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index a3c7a72aee..9787401285 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -576,6 +576,42 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s)); } +/// An image that Placement Options clip, alone in a group the user made, goes into a new clip group +/// inside that group when its file grows, in the layers and in a symbol's art; the user's group +/// stays a plain group. +#[test] +fn placement_options_clip_an_image_alone_in_a_group_inside_that_group() { + let dir = Folder::new("clip-in-group"); + let photo = dir.file("photo.png"); + write(&photo, &png(20, 10, RED)); + let mut s = session(); + let mut groups = vec![]; + for _ in 0..2 { + let id = place(&mut s, &photo); + s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({"ids": [id.0]})).unwrap()["id"].as_u64().unwrap()); + groups.push((g, id)); + } + let (in_symbol, _) = groups[1]; + s.execute("symbol.new", &json!({"name": "Mark", "ids": [in_symbol.0]})).unwrap(); + write(&photo, &png(60, 20, BLUE)); + s.execute("links.update", &json!({})).unwrap(); + // A plain group holding just a clip group of a clipping path and an image. + let plain_of_clip = |n: &vectorcraft_doc::Node| match &n.kind { + NodeKind::Group { children, clip: false } => matches!(&children[..], [c] if matches!(&c.kind, + NodeKind::Group { children, clip: true } if matches!(&children[..], [p, im] + if matches!(p.kind, NodeKind::Path { clipping: true, .. }) && matches!(im.kind, NodeKind::Image(_))))), + _ => false, + }; + let d = &s.doc().unwrap().doc; + let (g, id) = groups[0]; + assert!(plain_of_clip(d.node(g).unwrap()), "{:?}", d.node(g)); + assert_ne!(d.parent_of(id), Some(g), "the image is in a new clip group"); + let mut found = false; + d.symbols[0].art.walk(&mut |n| found |= plain_of_clip(n)); + assert!(found, "{:?}", d.symbols[0].art); +} + #[test] fn a_missing_link_renders_its_preview_and_relinks() { let dir = Folder::new("missing"); diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 7beb512ed0..10939d4b2a 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -1136,7 +1136,12 @@ fn mesh_points_move_and_colour_together() { assert!(s.execute("object.mesh.movePoints", &p).is_err(), "{p}"); } assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [0, 9], "color": "#000000"})).is_err()); - assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before, n), "nothing changed"); + assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before.clone(), n), "nothing changed"); + // A call that changes no point records no undo step. + s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [], "dx": 1, "dy": 1})).unwrap(); + s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [0, 1], "dx": 0, "dy": 0})).unwrap(); + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [2, 6], "color": "#0000ff", "opacity": 0.25})).unwrap(); + assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before, n), "no change, no undo step"); } #[test] diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 3cbdb5df9f..e72f607030 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -600,6 +600,105 @@ fn effect_params_are_the_effects_own() { assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok()); } +/// An effect parameter that only the effect's params description names, with no dialog default, +/// is held to the type the effect reads: the earlier 3D Perspective and light, Scribble's +/// `variation` and an Outline Stroke's `width` are numbers, and a Gaussian Blur's `linear` is true +/// or false. The `style` of Crop Marks is `roman` or `japanese`. Every such key of every effect is +/// listed, or has a check of its own. A key left out keeps its default. +#[test] +fn an_effect_parameter_without_a_default_is_held_to_its_type() { + use crate::cmd::params::undefaulted_type; + use vectorcraft_render::effects::effect_catalog; + // Read by checks of their own: a stored texture or a texture to load, an SVG filter, a Warp's + // map and the style of Crop Marks. + let own_check = |effect: &str, key: &str| match key { + "textureImage" | "name" | "xml" => true, + "version" => effect.starts_with("warp."), + "style" => effect == "cropMarks", + _ => false, + }; + let mut unlisted = vec![]; + for e in effect_catalog() { + for key in doc_keys(e.params).unwrap().keys { + if e.defaults.get(key).is_none() && undefaulted_type(e.id, key).is_none() && !own_check(e.id, key) { + unlisted.push(format!("{}.{key}", e.id)); + } + } + } + assert!(unlisted.is_empty(), "parameters without a default or a listed type: {unlisted:?}"); + let apply = |effect: &str, params| json!({"effect": effect, "params": params}); + for (effect, params, why) in [ + ("threeD.revolve", json!({"perspective": true}), "`perspective` must be a number"), + ("threeD.extrude", json!({"lightAzimuth": "30"}), "`lightAzimuth` must be a number"), + ("threeD.rotate", json!({"lightElevation": null}), "`lightElevation` must be a number"), + ("threeD.inflate", json!({"perspective": "50%"}), "`perspective` must be a number"), + ("path.outlineStroke", json!({"width": "10 pt"}), "`width` must be a number"), + ("stylize.scribble", json!({"variation": false}), "`variation` must be a number"), + ("blur.gaussian", json!({"radius": 2, "linear": 1}), "`linear` must be true or false"), + ("cropMarks", json!({"style": "bogus"}), "`style` must be roman or japanese"), + ("cropMarks", json!({"style": 1}), "`style` must be roman or japanese"), + ("cropMarks", json!({"style": null}), "`style` must be roman or japanese"), + ] { + assert_eq!(refusal("effect.apply", apply(effect, params.clone())), why, "{effect} {params}"); + assert_eq!(refusal("appearance.addEffect", apply(effect, params.clone())), why, "{effect} {params}"); + } + for (effect, params) in [ + ("threeD.revolve", json!({"perspective": 40, "lightAzimuth": 30.5, "lightElevation": 0})), + ("path.outlineStroke", json!({"width": 10})), + ("stylize.scribble", json!({"variation": 2})), + ("blur.gaussian", json!({"linear": true})), + ("blur.gaussian", json!({})), + ("cropMarks", json!({"style": "japanese"})), + ("cropMarks", json!({})), + ] { + assert!(check_params("effect.apply", &apply(effect, params.clone())).is_ok(), "{effect} {params}"); + } +} + +/// `effect.apply`, `appearance.addEffect` and `effect.setParams` refuse such a parameter of the +/// wrong type when they run, and so does a step of a `command.batch` whose effect a step before it +/// added, with the document and its undo history as they were. A saved step still plays with it, +/// as with a key the effect no longer takes. +#[test] +fn effect_commands_refuse_an_undefaulted_parameter_of_the_wrong_type() { + 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 state = |s: &Session| { + let st = s.doc().unwrap(); + (st.history.undo.len(), st.doc.clone()) + }; + let before = state(&s); + let apply = |effect: &str, params| json!({"effect": effect, "params": params}); + for (id, params, why) in [ + ("effect.apply", apply("path.outlineStroke", json!({"width": "10 pt"})), "`width` must be a number"), + ("appearance.addEffect", apply("blur.gaussian", json!({"linear": 1})), "`linear` must be true or false"), + ("effect.apply", apply("threeD.revolve", json!({"perspective": "50"})), "`perspective` must be a number"), + ] { + let e = s.execute(id, ¶ms).unwrap_err().to_string(); + assert!(e.contains(why), "{id} {params}: {e}"); + assert_eq!(state(&s), before, "{id} {params}"); + } + let batch = json!({"commands": [ + {"command": "effect.apply", "params": apply("path.outlineStroke", json!({"width": 3}))}, + {"command": "effect.setParams", "params": {"index": 0, "params": {"width": "10 pt"}}}, + ]}); + let e = s.execute("command.batch", &batch).unwrap_err().to_string(); + assert!(e.contains("`width` must be a number"), "{e}"); + assert_eq!(state(&s), before); + s.execute("effect.apply", &apply("path.outlineStroke", json!({"width": 3}))).unwrap(); + let applied = state(&s); + for params in [json!({"width": "10 pt"}), json!({"width": null}), json!({"width": true})] { + let e = s.execute("effect.setParams", &json!({"index": 0, "params": params.clone()})).unwrap_err().to_string(); + assert!(e.contains("`width` must be a number"), "{params}: {e}"); + assert_eq!(state(&s), applied, "{params}"); + } + s.execute("effect.setParams", &json!({"index": 0, "params": {"width": 4}})).unwrap(); + s.replay("effect.setParams", &json!({"index": 0, "params": {"retired": "x", "width": "10 pt"}})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers[0].children().unwrap()[0].appearance.effects[0].params["width"], json!("10 pt")); +} + /// The Transform effect (`distort.transform`) is held to its params description: `effect.apply` /// and `effect.setParams` refuse each of its on/off options given as text, such as `reflectX: /// "false"`, and each of its numbers given as text, such as `copies: "0"`, and leave the effect diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index 0b948242db..0f29befd80 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType}; +use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType, pattern_paint}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -212,6 +212,186 @@ fn cancel_new_pattern_removes_it() { assert_eq!(doc(&s).layers.len(), 1); } +fn doc_mut(s: &mut Session) -> &mut vectorcraft_doc::Document { + Arc::make_mut(&mut s.doc_mut().unwrap().doc) +} + +fn is_pattern(p: &Paint, name: &str) -> bool { + matches!(p, Paint::Pattern { pattern, .. } if pattern == name) +} + +/// How many paints of pattern `name` the document holds: in the art and the definitions +/// ([`vectorcraft_doc::Document::visit_paints`]), the swatches, the character styles and the +/// stored brush library. +fn pattern_uses(d: &vectorcraft_doc::Document, name: &str) -> usize { + let mut n = 0; + d.visit_paints(&mut |p| n += usize::from(is_pattern(p, name))); + n += d.swatches_iter().filter(|w| is_pattern(&w.paint, name)).count(); + let styled = d.char_styles.iter().flat_map(|c| ["fill", "stroke"].map(|k| c.attrs.get(k).cloned())).flatten(); + n += styled.filter(|v| serde_json::from_value::(v.clone()).is_ok_and(|p| is_pattern(&p, name))).count(); + let needle = format!("\"pattern\":\"{name}\""); + n + d.unknown.get(vectorcraft_brush::DOC_KEY).map_or(0, |v| v.to_string().matches(&needle).count()) +} + +/// Canceling a new pattern removes every swatch of it, a color group's included, and the fills and +/// strokes of it on art outside the editing layer become no paint. The object's other paints, and +/// a swatch that only shares the pattern's name, stay. +#[test] +fn cancel_new_pattern_forgets_group_swatch_and_outside_fills_and_strokes() { + let mut s = session(); + let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setStroke", &json!({"ids": [other.0], "color": "#00ff00"})).unwrap(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap(); + let d = doc_mut(&mut s); + let solid = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + d.swatch_groups.push(vectorcraft_color::SwatchGroup { + name: "Mine".into(), + swatches: vec![ + vectorcraft_color::Swatch { name: "Grouped".into(), paint: pattern_paint("Tiles"), global: false, spot: false }, + vectorcraft_color::Swatch { name: "Tiles".into(), paint: solid.clone(), global: false, spot: false }, + ], + }); + assert!(pattern_uses(doc(&s), "Tiles") >= 3); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern("Tiles").is_none()); + assert_eq!(pattern_uses(d, "Tiles"), 0); + assert!(d.swatch("Grouped").is_none(), "the group's pattern swatch goes"); + assert_eq!(d.swatch("Tiles").map(|w| &w.paint), Some(&solid), "a color swatch named like the pattern stays"); + let n = node(&s, other); + assert!(n.appearance.fill_paint().is_none()); + assert!(matches!(n.appearance.stroke_paint(), Paint::Solid { .. }), "the stroke stays"); +} + +/// Save a Copy keeps the copy as its own pattern when the new pattern is canceled, and the copy's +/// tile art loses its paints of the canceled pattern. +#[test] +fn cancel_new_pattern_forgets_its_paints_in_a_saved_copy() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + let layer = doc(&s).pattern_edit.as_ref().unwrap().layer; + let tile = doc(&s).node(layer).unwrap().children().unwrap()[0].id; + s.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Tiles"})).unwrap(); + assert_eq!(s.execute("object.pattern.saveCopy", &json!({})).unwrap()["name"], "Tiles copy"); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + let copy = d.pattern("Tiles copy").expect("the copy stays"); + assert_eq!(copy.art.len(), 1); + assert!(copy.art[0].appearance.fill_paint().is_none()); + assert_eq!(pattern_uses(d, "Tiles"), 0); +} + +/// Canceling a new pattern also clears its paints in type, opacity mask art, symbol definitions, +/// graph designs, graphic and character styles and brush art, and in the fill, stroke and +/// appearance new art takes, so new art does not take a paint of a pattern that is gone. +#[test] +fn cancel_new_pattern_forgets_hidden_paints_and_new_art_defaults() { + let mut s = session(); + let masked = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let m = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, m.0]})).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap()); + let band = rect(&mut s, 200.0, 200.0, 40.0, 10.0); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [band.0], "swatch": "Tiles"})).unwrap(); + s.execute("brush.new", &json!({"type": "art", "name": "Band", "ids": [band.0]})).unwrap(); + let p = pattern_paint("Tiles"); + let d = doc_mut(&mut s); + let art = d.node(band).unwrap().clone(); + d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false }); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Bar".into(), art: Arc::new(art.clone()) }); + d.graphic_styles.push(vectorcraft_doc::GraphicStyle { + name: "Style".into(), + appearance: (*art.appearance).clone(), + id: 0, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Default::default(), + unit_box: true, + }); + let attrs = json!({"fill": p, "stroke": p, "size": 12}); + d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() }); + if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) { + text.runs[0].style.fill = p.clone(); + } + let mask = d.node_mut(masked).and_then(|n| n.extras_if_any()).and_then(|e| e.mask.as_deref_mut()).expect("the mask"); + Arc::make_mut(&mut mask.art).appearance = vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0).into(); + s.paint.fill = p.clone(); + s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(Paint::None, p.clone(), 1.0)); + assert!(pattern_uses(doc(&s), "Tiles") >= 10, "{}", pattern_uses(doc(&s), "Tiles")); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + assert_eq!(pattern_uses(doc(&s), "Tiles"), 0); + assert!(doc(&s).char_styles[0].attrs["size"] == json!(12), "the style's other attributes stay"); + assert!(s.paint.fill.is_none()); + assert!(s.paint.appearance.as_ref().unwrap().items.iter().all(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => !is_pattern(&f.paint, "Tiles"), + vectorcraft_doc::AppearanceItem::Stroke(k) => !is_pattern(&k.paint, "Tiles"), + })); + let fresh = rect(&mut s, 300.0, 300.0, 20.0, 20.0); + let n = node(&s, fresh); + assert!(!is_pattern(&n.appearance.fill_paint(), "Tiles") && !is_pattern(&n.appearance.stroke_paint(), "Tiles")); +} + +/// Cancel is one undo step: Undo gives the pattern, its paints and the fill new art takes back, and +/// Redo clears them again. +#[test] +fn cancel_new_pattern_is_one_undo_step() { + let mut s = session(); + let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap(); + s.paint.fill = pattern_paint("Tiles"); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Tiles")); + assert!(doc(&s).pattern("Tiles").is_some() && doc(&s).pattern_edit.is_some()); + assert!(is_pattern(&node(&s, other).appearance.fill_paint(), "Tiles")); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none()); + assert_eq!(pattern_uses(doc(&s), "Tiles"), 0); +} + +/// Canceling the edit of a pattern that Pattern Options renamed while it was edited gives the +/// pattern its old name back, with its swatch and the paints of it, so nothing is left painted +/// with a name no pattern has. +#[test] +fn cancel_after_renaming_an_edited_pattern_restores_its_name_and_paints() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap(); + assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots")); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_none()); + assert!(d.swatch("Dots").is_some_and(|w| is_pattern(&w.paint, "Dots"))); + assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Dots")); + assert_eq!(pattern_uses(d, "Spots"), 0); + // When Save a Copy has taken the old name since, the pattern keeps its new name and paints. + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap(); + s.execute("object.pattern.saveCopy", &json!({"name": "Dots"})).unwrap(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_some()); + assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots")); +} + #[test] fn edit_then_cancel_restores_definition() { let mut s = session(); @@ -381,6 +561,187 @@ fn delete_pattern_unpaints_objects() { assert!(s.execute("pattern.delete", &json!({"name": "Dots"})).is_err()); } +/// Puts paints of pattern `name` where the layers do not reach: a symbol definition, another +/// pattern's tile, a graphic style, a character style, a type run, a color group's swatch and the +/// fill new art takes. +fn paint_outside_the_layers(s: &mut Session, name: &str) { + let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap()); + let p = pattern_paint(name); + let d = doc_mut(s); + let shape = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)); + let art = Node::path(d.alloc_id(), shape, vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0)); + d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false }); + d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Other", vec![Arc::new(art.clone())])); + d.graphic_styles.push(vectorcraft_doc::GraphicStyle { + name: "Style".into(), + appearance: (*art.appearance).clone(), + id: 0, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Default::default(), + unit_box: true, + }); + let attrs = json!({"fill": p}); + d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() }); + if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) { + text.runs[0].style.fill = p.clone(); + } + d.swatch_groups.push(vectorcraft_color::SwatchGroup { + name: "Mine".into(), + swatches: vec![vectorcraft_color::Swatch { name: "Grouped".into(), paint: p.clone(), global: false, spot: false }], + }); + s.paint.fill = p; +} + +/// Delete Pattern clears the pattern's paints outside the layers too, and in the fill new art takes. +#[test] +fn delete_pattern_forgets_paints_outside_the_layers() { + let mut s = session(); + make_dots(&mut s); + paint_outside_the_layers(&mut s, "Dots"); + assert_eq!(pattern_uses(doc(&s), "Dots"), 7); + let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); + assert_eq!(v["unpainted"], 5, "the symbol, the other tile, the graphic style, the character style and the type"); + assert_eq!(pattern_uses(doc(&s), "Dots"), 0); + assert!(doc(&s).swatch("Grouped").is_none() && doc(&s).pattern("Other").is_some()); + assert!(s.paint.fill.is_none()); +} + +/// A Pattern Options rename renames the pattern's paints outside the layers too, and in the fill +/// new art takes. +#[test] +fn rename_pattern_reaches_paints_outside_the_layers() { + let mut s = session(); + make_dots(&mut s); + paint_outside_the_layers(&mut s, "Dots"); + s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + assert_eq!(pattern_uses(doc(&s), "Dots"), 0); + assert_eq!(pattern_uses(doc(&s), "Spots"), 7); + assert!(is_pattern(&s.paint.fill, "Spots")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Dots"), "Undo gives the fill its old name back"); +} + +/// A new graph whose stored series fill and stroke and value label fill and stroke are pattern +/// `name`, as a later redraw would give them to its marks and labels. +fn graph_painted_with(s: &mut Session, name: &str) -> NodeId { + let v = s.execute("graph.create", &json!({"type": "column", "x": 200, "y": 200, "width": 60, "height": 40, "csv": ",2024\nQ1,3\nQ2,5"})); + let id = id_of(&v.unwrap()); + let p = pattern_paint(name); + let mut extras = doc_mut(s).node_mut(id).unwrap().extras_mut(); + let spec = extras.graph.as_mut().unwrap(); + spec.series_paints = vec![vectorcraft_doc::SeriesPaint { fill: Some(p.clone()), stroke: Some(p.clone()), ..Default::default() }]; + spec.label_styles.values = Some(vectorcraft_doc::CharStyle { fill: p.clone(), stroke: p, ..Default::default() }); + id +} + +/// The series and label paints a graph stores, in order: series fill and stroke, then the value +/// labels' fill and stroke. +fn stored_graph_paints(s: &Session, id: NodeId) -> Vec { + let n = node(s, id); + let g = n.extras().graph.as_deref().unwrap(); + let series = g.series_paints.iter().flat_map(|p| [p.fill.clone(), p.stroke.clone()]).flatten(); + series.chain(g.label_styles.values.iter().flat_map(|st| [st.fill.clone(), st.stroke.clone()])).collect() +} + +/// Delete Pattern clears the series and label paints a graph stores, and a rename renames them, so +/// a later `graph.setData` draws no paint of a missing pattern. +#[test] +fn delete_and_rename_reach_a_graphs_stored_paints() { + let mut s = session(); + make_dots(&mut s); + let g = graph_painted_with(&mut s, "Dots"); + s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + assert!(stored_graph_paints(&s, g).iter().all(|p| is_pattern(p, "Spots")), "the rename reaches the graph"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(stored_graph_paints(&s, g).iter().all(|p| is_pattern(p, "Dots"))); + let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); + assert_eq!(v["unpainted"], 4, "the series fill and stroke and the value labels' fill and stroke"); + assert!(stored_graph_paints(&s, g).iter().all(Paint::is_none)); + s.execute("graph.setData", &json!({"id": g.0, "rows": [[2], [4]]})).unwrap(); + assert_eq!(pattern_uses(doc(&s), "Dots"), 0, "the redrawn graph holds no paint of the deleted pattern"); +} + +/// Canceling a new pattern clears the series and label paints a graph stores. +#[test] +fn cancel_new_pattern_reaches_a_graphs_stored_paints() { + let mut s = session(); + // The graph is made outside the editing layer, and its paints take the new pattern's name. + let g = graph_painted_with(&mut s, "Tiles"); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + assert_eq!(stored_graph_paints(&s, g).len(), 4); + assert!(stored_graph_paints(&s, g).iter().all(Paint::is_none)); + assert_eq!(pattern_uses(doc(&s), "Tiles"), 0); +} + +/// With New Art Has Basic Appearance off, Redo of Delete Pattern gives new art the paints the +/// command left, the selected object's appearance taken after the pattern's fill was cleared. +#[test] +fn redo_of_delete_pattern_keeps_the_inherited_new_art_paints() { + let mut s = session(); + let r = make_dots(&mut s); + s.prefs.new_art_basic = false; + sel(&mut s, &[r]); + s.paint.fill = pattern_paint("Dots"); + s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); + let after = s.paint.clone(); + assert!(!is_pattern(&after.fill, "Dots")); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.paint, after); +} + +/// A Pattern Options rename tried as a preview changes the fill new art takes only while the +/// preview stands: another preview and Cancel start from the fill the interaction began with. +/// Keeping the preview keeps the rename, and Undo takes it back. +#[test] +fn rename_previews_restore_the_fill_new_art_takes() { + let mut s = session(); + make_dots(&mut s); + s.paint.fill = pattern_paint("Dots"); + s.begin_interaction("Pattern Options").unwrap(); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Spots")); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Tiles"})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Tiles")); + s.cancel_interaction().unwrap(); + assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some()); + s.begin_interaction("Pattern Options").unwrap(); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + s.commit_interaction().unwrap(); + assert!(is_pattern(&s.paint.fill, "Spots") && doc(&s).pattern("Spots").is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some()); +} + +/// With New Art Has Basic Appearance off, a canceled rename preview leaves new art with the +/// appearance of the selected object as the document has it again, so no paint of the previewed +/// name is left. +#[test] +fn canceled_rename_preview_leaves_no_inherited_paint_of_the_new_name() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + s.prefs.new_art_basic = false; + sel(&mut s, &[big]); + s.paint = vectorcraft_tools::PaintDefaults::default(); + s.begin_interaction("Pattern Options").unwrap(); + s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + s.cancel_interaction().unwrap(); + assert!(doc(&s).pattern("Dots").is_some() && doc(&s).pattern("Spots").is_none()); + let items = s.paint.appearance.iter().flat_map(|a| a.items.iter()).map(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(), + vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(), + }); + let paints: Vec = [s.paint.fill.clone(), s.paint.stroke.clone()].into_iter().chain(items).collect(); + assert!(paints.iter().all(|p| !is_pattern(p, "Spots")), "{paints:?}"); +} + #[test] fn transform_patterns_only_moves_placement() { let mut s = session(); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 0224d59b38..cc4460d250 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -235,8 +235,16 @@ fn live_effect_plugins_reshape_editable_art_and_expand() { run(&mut s, "select.all", json!({})); run(&mut s, "object.move", json!({"dx": 50, "dy": 0})); assert_eq!(effected(&s), *node(&s, a).path_data().unwrap()); + // A parameter of another type than the manifest gives it is refused. + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let wrong = json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": "4 pt"}}); + let e = s.execute("effect.apply", &wrong).unwrap_err().to_string(); + assert!(e.contains("`size` must be a number"), "{e}"); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before); run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}})); assert_eq!(effected(&s), triangle()); + let e = s.execute("effect.setParams", &json!({"ids": [a.0], "index": 1, "params": {"size": true}})).unwrap_err().to_string(); + assert!(e.contains("`size` must be a number"), "{e}"); assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4})); // Uninstalled: the geometry is drawn as it is. run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"})); diff --git a/crates/engine/src/tests/symbol_nine_slice.rs b/crates/engine/src/tests/symbol_nine_slice.rs index fba17f11ee..b0689d1587 100644 --- a/crates/engine/src/tests/symbol_nine_slice.rs +++ b/crates/engine/src/tests/symbol_nine_slice.rs @@ -250,3 +250,103 @@ fn a_guide_dragged_on_the_canvas_changes_how_a_scaled_instance_draws() { let left = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left; assert!((left - 0.05).abs() < 1e-9, "{left}"); } + +/// The 200 × 150 [`panel`] instance with its left guide 25 % outside the art: the left column +/// holds no art, so the corner square is in the center column, which runs 0..75 in the art and +/// 0..175 in the instance, and the center square moves with it. The rows are as before. +fn assert_left_outside(boxes: &[Rect]) { + let k = 175.0 / 75.0; + let want = [ + Rect::new(50.0, 50.0, 250.0, 200.0), + Rect::new(50.0, 50.0, 50.0 + 10.0 * k, 60.0), + Rect::new(50.0 + 45.0 * k, 115.0, 50.0 + 55.0 * k, 135.0), + ]; + assert_eq!(boxes.len(), want.len(), "{boxes:?}"); + for (b, w) in boxes.iter().zip(want) { + assert!(close(*b, w), "{b:?} vs {w:?} in {boxes:?}"); + } +} + +/// `symbol.options` takes guides outside the art, within -16..16 and in order on each axis, as +/// one undo step; a scaled instance then draws by the cells its art falls in, and Break Link to +/// Symbol gives that art. Values out of range or out of order are refused without an edit. +#[test] +fn guides_outside_the_art_are_set_by_command_and_scale_by_the_cells_that_hold_art() { + let (mut s, id) = panel(); + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": [-0.25, 0.25, 0.25, 0.25]})).unwrap(); + assert_left_outside(&placed_boxes(&s, id)); + let list = s.execute("symbol.list", &json!({})).unwrap(); + assert_eq!(list["symbols"][0]["nineSlice"], json!([-0.25, 0.25, 0.25, 0.25])); + for bad in [json!([-16.5, 0.25, 0.25, 0.25]), json!([0.25, 0.25, 0.25, 17]), json!([1.2, 0.25, -0.2, 0.25]), json!([0.25, 0.6, 0.25, 0.6])] { + let e = s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": bad})).unwrap_err().to_string(); + assert!(e.contains("each -16..16"), "{e}"); + } + assert_eq!(s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left, -0.25, "no edit"); + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": [1.25, 0.25, -0.5, 0.25]})).unwrap(); + // Both vertical guides past the art's right edge: the art keeps its width from the left. + let boxes = placed_boxes(&s, id); + assert!(close(boxes[0], Rect::new(50.0, 50.0, 150.0, 200.0)) && close(boxes[1], Rect::new(50.0, 50.0, 60.0, 60.0)), "{boxes:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_left_outside(&placed_boxes(&s, id)); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + let art = NodeId(r["ids"][0].as_u64().unwrap()); + assert_left_outside(&path_boxes(s.doc().unwrap().doc.node(art).unwrap())); +} + +/// Dragging the left guide past the art's left edge on the canvas puts it outside the art, as one +/// undo step, and the scaled instance draws by the cells that hold art. +#[test] +fn a_guide_dragged_past_the_arts_edge_lies_outside_it() { + let (mut s, id) = panel(); + s.execute("symbol.edit", &json!({"name": "Panel"})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let e = s.doc().unwrap().doc.edited_nine_slice().unwrap(); + let ((a, b), (l, r)) = (e.line(NineSliceGuide::Left), (e.line(NineSliceGuide::Left).0.x, e.line(NineSliceGuide::Right).0.x)); + let y = (a.y + b.y) / 2.0; + // The guides lie a quarter in from each edge, so the art's left edge lies half the guides' + // distance to the left of the left guide, and 25 % outside it lies as far again. + let to = l - (r - l); + s.pointer(&PointerEvent::new(PointerKind::Down, l + 1.0, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to, y), v).unwrap(); + let g = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap(); + assert!((g.left + 0.25).abs() < 1e-9, "{g:?}"); + let e = s.doc().unwrap().doc.edited_nine_slice().unwrap(); + assert!((e.line(NineSliceGuide::Left).0.x - to).abs() < 1e-9, "the guide is drawn where it was dropped"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left, 0.25, "one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("symbol.exitEditing", &json!({})).unwrap(); + assert_left_outside(&placed_boxes(&s, id)); +} + +/// A native file keeps guides outside the art exactly, and an instance opened from it draws as +/// before saving; a file whose saved guides are out of range or out of order opens with 9-slice +/// off for that symbol. +#[test] +fn guides_outside_the_art_survive_saving_and_reopening_a_native_file() { + let (mut s, id) = panel(); + let guides = [-0.123456789, 1.5, 0.3, -2.75]; + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": guides})).unwrap(); + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": [-0.25, 0.25, 0.25, 0.25]})).unwrap(); + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": guides})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + let g = back.symbol_nine_slice("Panel").unwrap(); + assert_eq!([g.left, g.top, g.right, g.bottom], guides); + let placed = |d: &vectorcraft_doc::Document| { + let NodeKind::SymbolInstance { symbol, xf } = &d.node(id).unwrap().kind else { panic!("an instance") }; + let art = d.symbols.iter().find(|s| &s.name == symbol).unwrap().art.clone(); + path_boxes(&d.place_symbol_art(symbol, &art, *xf).unwrap()) + }; + assert_eq!(placed(&back), placed(&doc)); + for bad in [json!([-20.0, 0.25, 0.25, 0.25]), json!([0.8, 0.25, 0.5, 0.25]), json!([0.25, 0.25, 0.25]), json!("x")] { + let mut d = (*doc).clone(); + d.unknown.insert(vectorcraft_doc::SYMBOL_NINE_SLICE.into(), json!({ "Panel": bad })); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + assert!(back.symbol_nine_slice("Panel").is_none(), "{bad}"); + let NodeKind::SymbolInstance { symbol, xf } = &back.node(id).unwrap().kind else { panic!("an instance") }; + assert!(back.symbol_slice_map(symbol, *xf).is_none(), "{bad}: scaled as a whole"); + } +} diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index 54c653e938..8e3830c81c 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -1190,6 +1190,32 @@ fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() { assert_eq!(undo_len(&s), history + 1); } +/// `object.distort` with `angle` reads `from` as a box in the frame turned by it, as the Free +/// Transform tool sends a turned bounding box: corners where that box already stands leave the art. +#[test] +fn distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let original = bounds(&s, a); + let bx = vectorcraft_doc::OrientedBox { angle: 90.0, rect: Rect::new(-100.0, 0.0, 0.0, 100.0) }; + let corners: Vec = bx.corners().iter().map(|c| json!([c.x, c.y])).collect(); + let history = undo_len(&s); + for p in [ + json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": "90", "ids": [a.0]}), + json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": [90], "ids": [a.0]}), + ] { + assert!(s.execute("object.distort", &p).is_err(), "invalid input: {p}"); + assert!(close(bounds(&s, a), original)); + assert_eq!(undo_len(&s), history); + } + s.execute("object.distort", &json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": 90, "ids": [a.0]})).unwrap(); + assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a)); + assert_eq!(undo_len(&s), history + 1); + // Without `from`, the box is the objects' bounds in the turned frame: the same box here. + s.execute("object.distort", &json!({"corners": corners, "angle": 90, "ids": [a.0]})).unwrap(); + assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a)); +} + /// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step /// each, the art on each moving with it, and one artboard always left. #[test] diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4688845de4..52be19a952 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3,7 +3,8 @@ //! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records -//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files +//! of every kind with random contents) opened, placed or pasted, nor PICT pictures (damaged files, +//! opcodes with random contents) opened or placed, nor EPS and PostScript files //! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of //! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents //! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the @@ -2938,6 +2939,106 @@ proptest! { } } +// ---------- PICT pictures: opened and placed ---------- + +fn be16(v: &[i16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() +} + +/// A version 2 PICT file of a 200 × 100 pt frame holding `ops`, each padded to an even length. +fn pict_of(ops: &[(u16, Vec)]) -> Vec { + let mut b = vec![0; 512]; + b.extend(be16(&[0, 0, 0, 100, 200])); + b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]); + b.extend(be16(&[-1, 0])); + b.extend([0; 20]); + for (op, data) in ops { + b.extend(op.to_be_bytes()); + b.extend(data); + if b.len() % 2 == 1 { + b.push(0); + } + } + b.extend([0x00, 0xFF]); + b +} + +/// A picture with shapes, a region, text, a pattern and a packed pixel map. +fn pict_sample() -> Vec { + let region = [be16(&[36, 0, 0, 20, 20]), be16(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat(); + let table = be16(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]); + let pixmap = [ + be16(&[0x8008u16 as i16, 0, 0, 2, 8]), + be16(&[0, 0, 0, 0, 72, 0, 72, 0, 0, 8, 1, 8, 0, 0, 0, 0, 0, 0]), + table, + be16(&[0, 0, 2, 8, 10, 20, 14, 36, 0]), + vec![2, 0xF9, 0, 9, 7, 1, 0, 1, 0, 1, 0, 1, 0], + ] + .concat(); + pict_of(&[ + (0x1A, be16(&[-1, 0, 0])), + (0x07, be16(&[2, 2])), + (0x31, be16(&[10, 20, 50, 80])), + (0x30, be16(&[10, 20, 50, 80])), + (0x0B, be16(&[8, 8])), + (0x41, be16(&[0, 100, 40, 160])), + (0x51, be16(&[50, 100, 90, 160])), + (0x61, be16(&[0, 0, 60, 60, 30, 120])), + (0x71, be16(&[22, 0, 0, 40, 40, 0, 0, 40, 40, 0, 40])), + (0x81, region.clone()), + (0x01, region), + (0x20, be16(&[70, 10, 70, 190])), + (0x21, be16(&[90, 100])), + (0x0A, vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]), + (0x34, be16(&[0, 0, 10, 10])), + (0x2C, [be16(&[9, 3]), vec![6], b"Geneva".to_vec()].concat()), + (0x0D, be16(&[12])), + (0x28, [be16(&[80, 10]), vec![3], b"abc".to_vec()].concat()), + (0x98, pixmap), + ]) +} + +/// The sample the mutations start from opens with art and an image. +#[test] +fn the_pict_sample_opens() { + let l = vectorcraft_engine::cmd::fileio::load("x.pct", &pict_sample()).unwrap(); + assert_eq!(l.format.id, "pict"); + let (mut paths, mut images, mut texts) = (0, 0, 0); + l.doc.walk(|n| match n.kind { + vectorcraft_doc::NodeKind::Path { .. } => paths += 1, + vectorcraft_doc::NodeKind::Image(_) => images += 1, + vectorcraft_doc::NodeKind::Text(_) => texts += 1, + _ => {} + }); + assert!(paths > 8 && images == 1 && texts == 1, "{paths} paths, {images} images, {texts} texts, warnings {:?}", l.warnings); +} + +/// PICT opcodes VectorCraft reads, and reserved ones it skips. +const PICT_OPS: [u16; 40] = [ + 0x01, 0x03, 0x04, 0x07, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x12, 0x13, 0x14, 0x1A, 0x1B, 0x20, 0x21, 0x22, 0x23, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x30, + 0x31, 0x38, 0x41, 0x51, 0x61, 0x71, 0x81, 0x90, 0x91, 0x98, 0x99, 0x9A, 0x9B, 0xA1, 0x8200, 0x0024, +]; + +proptest! { + #![proptest_config(config())] + + #[test] + fn mutated_pict_pictures_never_panic(cut in 0usize..4096, edits in prop::collection::vec((0usize..4096, any::()), 0..16)) { + let mut bytes = pict_sample(); + let n = bytes.len(); + for &(at, b) in &edits { + bytes[512 + at % (n - 512)] = b; + } + bytes.truncate(cut.max(512)); + survive_metafile("mutated PICT", "x.pct", &bytes)?; + } + + #[test] + fn random_pict_opcodes_never_panic(ops in prop::collection::vec((prop::sample::select(PICT_OPS.to_vec()), arb_body()), 0..32)) { + survive_metafile("PICT opcodes", "x.pct", &pict_of(&ops))?; + } +} + // ---------- HP-GL plot files: opened and placed ---------- /// A plot file with every kind of instruction the reader plays, printer escapes around it. diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index b7270f614e..8c4e95f4e6 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -980,6 +980,34 @@ fn a_scaled_nine_slice_symbol_instance_keeps_its_corner_size() { assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); } +/// A scaled instance of a symbol whose left 9-slice guide lies outside its art is written as the +/// canvas draws it: the corner square is in the center column, which runs 0..75 in the art and +/// 0..175 in the instance, so it is about 23 wide. The editing copy read alone has no guides. +#[test] +fn a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn() { + let mut doc = Document::new(400.0, 400.0); + let layer = doc.layers[0].id; + let square = |doc: &mut Document, r: Rect| { + Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::basic(solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0)) + }; + let frame = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0))); + let corner = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0))); + let art = Node::group(doc.alloc_id(), vec![frame, corner]); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + doc.set_symbol_nine_slice("S", vectorcraft_doc::SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25)); + let xf = Affine::scale_non_uniform(2.0, 1.5); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let sizes: Vec<(i64, i64)> = + all.iter().filter_map(|p| p.geometric_bounds()).map(|b| (b.width().round() as i64, b.height().round() as i64)).collect(); + assert!(sizes.contains(&(23, 10)), "the corner stretches with the center column: {sizes:?}"); + assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); + assert!(back.symbol_nine_slice("S").is_none()); +} + /// Nested symbols whose instances multiply, written as their art (on a large canvas), stop at /// [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art in all, with a warning. #[test] @@ -3943,6 +3971,42 @@ fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() { } } +/// The text document holds no Mojikumi set: a paragraph with any of the predefined sets other +/// than None is reported as left out, and reads back without the set; None and Line-end +/// Punctuation Half Width are not reported. +#[test] +fn predefined_mojikumi_sets_are_reported_as_left_out() { + use vectorcraft_doc::{Mojikumi, TextObject}; + for (set, reported) in [ + (Mojikumi::None, false), + (Mojikumi::LineEndHalf, false), + (Mojikumi::YakumonoHankaku, true), + (Mojikumi::GyomatsuYakumonoHankaku, true), + (Mojikumi::GyomatsuYakumonoZenkaku, true), + (Mojikumi::YakumonoZenkaku, true), + ] { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "「かな」。", style(14.0, Color::BLACK)); + t.para.mojikumi = set; + 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, "{set:?}: {:?}", out.warnings); + let mut all = vec![]; + paths(&read_back(&doc).layers, &mut all); + let sets: Vec = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.para_at(0).mojikumi), + _ => None, + }) + .collect(); + assert_eq!(sets.len(), 1, "{set:?}"); + assert!(matches!(sets[0], Mojikumi::None | Mojikumi::LineEndHalf), "{set:?} reads back as {:?}", sets[0]); + } +} + /// The text document has no Bunri-Kinshi: a paragraph with it on and an inseparable pair in it is /// reported as left out, and comes back off; with it off, or with no pair, nothing is reported. #[test] diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 9d4066d524..342feb568c 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -731,6 +731,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen", ), + ( + "tool.toggleTouchMode", + "Switch Selection and Drawing Modes", + "", + "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", + ), ( "effect.dialog", "Effect…", @@ -945,6 +951,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step); for several rows it sends only the fields set in the dialog", ), + ( + "ui.layerDelete", + "Delete Selection", + "", + "{ids?|id?, doc?, confirmed?: bool} Delete Selection as the Layers panel runs it (its trash button, rows dropped on the trash, its menu, Delete and Backspace), on the rows layer.delete takes (default: the highlighted rows, else the current layer). When a layer or sublayer among them holds art (an object other than a guide; hidden, locked and nested objects count) it asks first: dialog `confirm` with `message`; OK deletes those rows by id with layer.delete (one undo step), Cancel keeps them. OK runs ui.layerDelete again with the rows, `doc` (the document's uid) and `confirmed: true`: with another document active then, nothing is deleted. Other rows (an empty layer, groups and objects alone) and rows layer.delete refuses go to layer.delete at once → layer.delete's {deleted}, or null while the question is open", + ), ( "ui.rename", "Rename", diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml index fe54a67f6e..01b5d6434a 100644 --- a/crates/metafile/Cargo.toml +++ b/crates/metafile/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-metafile" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft Windows metafiles: hand-written EMF and WMF writers and readers" +description = "VectorCraft metafiles: hand-written EMF and WMF writers and readers, and a QuickDraw picture (PICT) reader" [lints] workspace = true diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs index a0e95498c3..4aeb5b4381 100644 --- a/crates/metafile/src/dib.rs +++ b/crates/metafile/src/dib.rs @@ -11,8 +11,8 @@ const MAX_SIDE: u32 = 8192; /// Most pixels an image the writers store holds. const MAX_PIXELS: u64 = 1 << 24; /// Most pixels along a side, and in all, of a bitmap the reader decodes. -const MAX_READ_SIDE: u32 = 1 << 15; -const MAX_READ_PIXELS: u64 = 1 << 26; +pub(crate) const MAX_READ_SIDE: u32 = 1 << 15; +pub(crate) const MAX_READ_PIXELS: u64 = 1 << 26; /// Most pixels the bitmaps of one file may decode to together (four of the largest one), so a small /// file of many image records can't cost minutes of decoding (#3600). pub(crate) const MAX_FILE_PIXELS: u64 = 1 << 28; @@ -164,7 +164,7 @@ fn png_without_metadata(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> { } /// Take a bitmap of `w` × `h` from what is `left` of the file's budget, before it is decoded. -fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { +pub(crate) fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { let n = u64::from(w) * u64::from(h); *left = left.checked_sub(n).ok_or(OVER_BUDGET)?; Ok(()) diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index 0a3d791ee2..da7816663e 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -1,6 +1,7 @@ -//! The metafile reader: the format parsers (`emf`, `wmf`) play their records on a [`Player`], a -//! small model of the device context they draw into (objects, transforms, the current position, -//! the path bracket, the clip), which turns what they draw into document objects. +//! The metafile reader: the format parsers (`emf`, `wmf`, and `pict` for QuickDraw pictures) play +//! their records on a [`Player`], a small model of the device context they draw into (objects, +//! transforms, the current position, the path bracket, the clip), which turns what they draw into +//! document objects. //! //! Fills and strokes become paths with a fill and a stroke; lines drawn one after another with the //! same pen join into one path. Clips become clipping groups around what they clip. Images keep @@ -8,6 +9,7 @@ //! doesn't know are counted and skipped, with one warning. mod emf; +pub(crate) mod pict; mod wmf; use std::sync::Arc; @@ -787,15 +789,25 @@ impl Player { if img.width == 0 || img.height == 0 { return; } + let (w, h) = (f64::from(img.width), f64::from(img.height)); + self.image_xf(img, Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h), opacity); + } + + /// An image placed by `xf` (its pixels, from the top-left, → logical), at `opacity`. An image + /// that `xf` flattens or sends past finite numbers is left out, and adds nothing to the + /// document's images. + pub(crate) fn image_xf(&mut self, img: Rgba, xf: Affine, opacity: f64) { + if img.width == 0 || img.height == 0 { + return; + } + let xf = self.xf() * xf; + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + return; + } let Ok(png) = img.to_png() else { return self.warn(BAD_IMAGE) }; let blob = ImageBlob::new("image/png", png); let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); - let (w, h) = (f64::from(img.width), f64::from(img.height)); - let xf = self.xf() * Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h); - if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { - return; - } let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None }; let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))); n.opacity = opacity.clamp(0.0, 1.0) as f32; diff --git a/crates/metafile/src/import/pict.rs b/crates/metafile/src/import/pict.rs new file mode 100644 index 0000000000..a9b4cae9da --- /dev/null +++ b/crates/metafile/src/import/pict.rs @@ -0,0 +1,1173 @@ +//! QuickDraw pictures (PICT, versions 1 and 2), played on the [`Player`] by the opcodes and data +//! lengths of Apple's *Imaging With QuickDraw*, appendix A. +//! +//! A PICT file holds 512 bytes of application data before the picture; a picture on its own (as +//! a `PICT` resource holds it) reads too. A unit is a point, and the artboard is the picture's +//! frame; an extended version 2 header's source rectangle is mapped onto the frame. +//! +//! - Lines, rectangles, rounded rectangles, ovals, arcs and wedges, polygons and regions become +//! paths. A framed shape is stroked inside its outline at the pen's size, and a line below and +//! to the right of its points, where the pen draws; painted, filled and erased shapes are filled +//! with the pen, fill or background pattern. A pattern of two colors is filled with their +//! average. +//! - Text becomes point type in the font the picture names, read as Mac Roman. +//! - Bitmaps and pixel maps (1 to 8 bits with their color table, 16 and 32 bits, packed or not) +//! become images, clipped to their mask region; QuickTime JPEG images become images. +//! - The clip region clips what follows it. +//! +//! What isn't read (inverted shapes, transfer modes other than a copy, other QuickTime formats) +//! comes back as one warning each. Apple's table gives every opcode's length, so opcodes that draw +//! nothing VectorCraft reads are skipped; a picture cut short keeps what came before. + +use std::collections::HashMap; +use std::io::Cursor; + +use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect, Shape, Vec2, shapes}; + +use super::{BAD_IMAGE, BrushObj, FontObj, PS_GEOMETRIC, PenObj, Player, SRCCOPY, TOO_MANY_PIXELS, crop, round_rect}; +use crate::dib::{self, Rgba}; +use crate::{Imported, Kind}; + +/// What a file that holds no picture is refused with. +const NOT_PICT: &str = "not a PICT file"; +/// The application data before the picture in a PICT file. +const FILE_HEADER: usize = 512; +/// Most opcodes one picture plays (a small file can't keep the reader busy for minutes). +const MAX_OPS: usize = 1 << 22; +/// Most rectangles one region breaks into. +const MAX_RECTS: usize = 1 << 16; +/// Steps the regions of one picture may take to read (each inversion point and band costs some). +const MAX_REGION_WORK: u64 = 1 << 28; +/// Most bytes the unpacked rows of one image may hold. +const MAX_ROW_BYTES: u64 = 1 << 28; +/// Most bytes the unpacked rows of one picture's images and patterns may hold together. +const MAX_FILE_ROW_BYTES: u64 = 1 << 30; +/// Most bytes a row may hold past what its pixels need. +const ROW_SLACK: usize = 64; +/// Most pixels, and bytes of unpacked rows, a pixel pattern may hold. +const MAX_PATTERN_PIXELS: usize = 1 << 16; +const MAX_PATTERN_ROW_BYTES: usize = 1 << 20; +/// Curves made for ovals and arcs are this close to the true curve, points. +const TOLERANCE: f64 = 0.01; +/// GDI text alignment on the baseline, which QuickDraw's text location is. +const TA_BASELINE: u32 = 0x18; +/// Pen style flags: square and flat ends, mitered joins. +const SQUARE: u32 = 0x100; +const FLAT: u32 = 0x200; +const MITER: u32 = 0x2000; + +const CUT_SHORT: &str = "the picture is cut short or damaged: what came before was imported"; +const TOO_MANY_OPS: &str = "the picture holds more opcodes than one file may: the rest was left out"; +const INVERTED: &str = "inverted shapes were left out"; +const PATTERNS: &str = "patterns are filled with the average of their colors"; +const MODES: &str = "art drawn in transfer modes other than a copy is drawn as a copy"; +const FRAMED_REGIONS: &str = "framed regions are outlined rectangle by rectangle"; +const BIG_REGIONS: &str = "regions larger than one picture may hold were left out"; +const CODECS: &str = "QuickTime images in formats other than JPEG were left out"; +const NOTHING: &str = "the picture draws nothing VectorCraft reads"; + +/// Big-endian reads from a slice, never past its end. +struct Be<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Be<'a> { + fn new(data: &'a [u8], pos: usize) -> Self { + Self { data, pos } + } + fn bytes(&mut self, n: usize) -> Option<&'a [u8]> { + let b = self.data.get(self.pos..self.pos.checked_add(n)?)?; + self.pos += n; + Some(b) + } + fn array(&mut self) -> Option<[u8; N]> { + self.bytes(N)?.try_into().ok() + } + fn skip(&mut self, n: usize) -> Option<()> { + self.bytes(n).map(drop) + } + fn u8(&mut self) -> Option { + self.array().map(|[b]: [u8; 1]| b) + } + fn u16(&mut self) -> Option { + self.array().map(u16::from_be_bytes) + } + fn i16(&mut self) -> Option { + self.array().map(i16::from_be_bytes) + } + fn u32(&mut self) -> Option { + self.array().map(u32::from_be_bytes) + } + fn i32(&mut self) -> Option { + self.array().map(i32::from_be_bytes) + } + /// A QuickDraw point: vertical, then horizontal. + fn point(&mut self) -> Option { + let v = self.i16()?; + let h = self.i16()?; + Some(Point::new(h.into(), v.into())) + } + /// A size stored as a point (a pen or an oval: height, then width). + fn size(&mut self) -> Option { + self.point().map(Point::to_vec2) + } + /// A QuickDraw rectangle: top, left, bottom, right. + fn rect(&mut self) -> Option { + let (top, left, bottom, right) = (self.i16()?, self.i16()?, self.i16()?, self.i16()?); + Some(Rect::new(left.into(), top.into(), right.into(), bottom.into())) + } + /// An `RGBColor` (16 bits a component), 8 bits a component. + fn rgb(&mut self) -> Option<[u8; 3]> { + let [r, g, b] = [self.u16()?, self.u16()?, self.u16()?]; + Some([r, g, b].map(|c| (c >> 8) as u8)) + } +} + +/// Where a picture is, its frame and whether it is version 2. +struct Header { + frame: Rect, + v2: bool, + /// Where its first opcode starts. + ops: usize, +} + +/// The picture whose `picSize` is at `at`, if a version opcode follows its frame. +fn header(bytes: &[u8], at: usize) -> Option
{ + let mut r = Be::new(bytes, at.checked_add(2)?); + let frame = r.rect()?; + let (v2, ops) = match r.array::<2>()? { + [0x11, 0x01] => (false, r.pos), + [0x00, 0x11] if r.array::<4>()? == [0x02, 0xFF, 0x0C, 0x00] => (true, at + 10), + _ => return None, + }; + (frame.width() > 0.0 && frame.height() > 0.0).then_some(Header { frame, v2, ops }) +} + +/// The picture of a file (after its application data), else the picture `bytes` start with. +fn find(bytes: &[u8]) -> Option
{ + header(bytes, FILE_HEADER).or_else(|| header(bytes, 0)) +} + +/// Do `bytes` hold a version 2 picture, after a file's application data or on their own? The +/// version 1 signature is two bytes, too short to tell a file by. +pub(crate) fn is_pict(bytes: &[u8]) -> bool { + [FILE_HEADER, 0].into_iter().any(|at| header(bytes, at).is_some_and(|h| h.v2)) +} + +/// A pattern: eight rows of eight bits (set bits in the foreground color, the others in the +/// background color), or one color. +#[derive(Clone, Copy)] +enum Pat { + Bits([u8; 8]), + Rgb([u8; 3]), +} + +/// What a shape opcode does with its shape. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Verb { + Frame, + Paint, + Erase, + Invert, + Fill, +} + +/// The verb of a shape opcode (its low three bits; 5 to 7 are reserved). +fn verb(op: u16) -> Option { + match op & 7 { + 0 => Some(Verb::Frame), + 1 => Some(Verb::Paint), + 2 => Some(Verb::Erase), + 3 => Some(Verb::Invert), + 4 => Some(Verb::Fill), + _ => None, + } +} + +/// An old-style QuickDraw color (`blackColor` 33 … `blueColor` 409) as RGB. +fn old_color(c: u32) -> [u8; 3] { + match c { + 30 => [255; 3], + 69 => [255, 255, 0], + 137 => [255, 0, 255], + 205 => [255, 0, 0], + 273 => [0, 255, 255], + 341 => [0, 255, 0], + 409 => [0, 0, 255], + _ => [0; 3], + } +} + +/// `a` and `b` mixed: `t` of `a`. +fn mix(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] { + let m = |x: u8, y: u8| (f64::from(x) * t + f64::from(y) * (1.0 - t)).round() as u8; + [m(a[0], b[0]), m(a[1], b[1]), m(a[2], b[2])] +} + +/// Byte `b` of Mac Roman. +fn mac_roman(b: u8) -> char { + const HIGH: [char; 128] = [ + 'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', // + 'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', // + '†', '°', '¢', '£', '§', '•', '¶', 'ß', '®', '©', '™', '´', '¨', '≠', 'Æ', 'Ø', // + '∞', '±', '≤', '≥', '¥', 'µ', '∂', '∑', '∏', 'π', '∫', 'ª', 'º', 'Ω', 'æ', 'ø', // + '¿', '¡', '¬', '√', 'ƒ', '≈', '∆', '«', '»', '…', '\u{A0}', 'À', 'Ã', 'Õ', 'Œ', 'œ', // + '–', '—', '“', '”', '‘', '’', '÷', '◊', 'ÿ', 'Ÿ', '⁄', '€', '‹', '›', 'fi', 'fl', // + '‡', '·', '‚', '„', '‰', 'Â', 'Ê', 'Á', 'Ë', 'È', 'Í', 'Î', 'Ï', 'Ì', 'Ó', 'Ô', // + '\u{F8FF}', 'Ò', 'Ú', 'Û', 'Ù', 'ı', 'ˆ', '˜', '¯', '˘', '˙', '˚', '¸', '˝', '˛', 'ˇ', + ]; + match b { + 0x80.. => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or(char::REPLACEMENT_CHARACTER), + _ => char::from(b), + } +} + +/// The family name of a classic Macintosh font number. +fn font_family(id: u16) -> &'static str { + match id { + 0 => "Chicago", + 1 | 3 => "Geneva", + 2 => "New York", + 4 => "Monaco", + 20 => "Times", + 21 => "Helvetica", + 22 => "Courier", + 23 => "Symbol", + _ => "", + } +} + +/// The arc of the oval in `r` from `start` degrees clockwise from 12 o'clock, over `sweep` +/// degrees (clockwise when positive). Angles are measured on the rectangle, so 45° runs through +/// its top-right corner. A wedge closes through the center. +fn arc(r: Rect, start: i16, sweep: i16, wedge: bool) -> BezPath { + let c = r.center(); + let radii = Vec2::new(r.width() / 2.0, r.height() / 2.0); + let mut bp = BezPath::new(); + if sweep == 0 { + return bp; + } + let a0 = (f64::from(start) - 90.0).to_radians(); + let sweep = f64::from(sweep).clamp(-360.0, 360.0).to_radians(); + let arc = kurbo::Arc { center: c, radii, start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; + if wedge { + bp.move_to(c); + bp.line_to(c + Vec2::new(radii.x * a0.cos(), radii.y * a0.sin())); + arc.append_iter(TOLERANCE).for_each(|el| bp.push(el)); + bp.close_path(); + } else { + kurbo::Shape::path_elements(&arc, TOLERANCE).for_each(|el| bp.push(el)); + } + bp +} + +/// The outlines of `rects`, as one path. +fn rects_path(rects: &[Rect]) -> BezPath { + let mut bp = BezPath::new(); + for r in rects { + bp.extend(shapes::rectangle(*r).to_bezpath()); + } + bp +} + +/// Take `n` steps from `work`; false when too few are left. +fn spend(work: &mut u64, n: usize) -> bool { + match work.checked_sub(n as u64) { + Some(left) => { + *work = left; + true + } + None => false, + } +} + +/// A region (its size, bounding box and inversion points), read at `r`, as rectangles: `None` +/// when the data runs past the end; the flag is false when the region is larger than `work` or +/// [`MAX_RECTS`] allows, and then no rectangles are given. +fn region(r: &mut Be, work: &mut u64) -> Option<(Vec, bool)> { + let size = usize::from(r.u16()?); + let mut b = Be::new(r.bytes(size.checked_sub(2)?)?, 0); + let bbox = b.rect()?; + if size <= 10 { + let whole = bbox.width() > 0.0 && bbox.height() > 0.0; + return Some((if whole { vec![bbox] } else { vec![] }, true)); + } + Some(bands(&mut b, work).map_or((vec![], false), |rects| (rects, true))) +} + +/// The rectangles of a region's scanlines: each lists the x coordinates where being inside the +/// region changes from the scanline before. Bands of the same spans one after another are one +/// rectangle. +fn bands(b: &mut Be, work: &mut u64) -> Option> { + let mut out: Vec = vec![]; + let mut active: Vec = vec![]; + let mut last_band = 0; + let mut prev_y = 0.0; + while let Some(y) = b.i16().filter(|y| *y != 0x7FFF) { + let y = f64::from(y); + let spans: Vec<(f64, f64)> = active.as_chunks::<2>().0.iter().map(|[a, b]| (f64::from(*a), f64::from(*b))).collect(); + if !spans.is_empty() && y > prev_y { + if !spend(work, spans.len() + 1) { + return None; + } + let prev = out.get_mut(last_band..).unwrap_or_default(); + let same = prev.len() == spans.len() && prev.iter().zip(&spans).all(|(r, (x0, x1))| r.x0 == *x0 && r.x1 == *x1 && r.y1 == prev_y); + if same { + prev.iter_mut().for_each(|r| r.y1 = y); + } else { + last_band = out.len(); + out.extend(spans.iter().map(|&(x0, x1)| Rect::new(x0, prev_y, x1, y))); + if out.len() > MAX_RECTS { + return None; + } + } + } + prev_y = y; + while let Some(x) = b.i16().filter(|x| *x != 0x7FFF) { + if !spend(work, active.len() + 1) { + return None; + } + match active.binary_search(&x) { + Ok(i) => { + active.remove(i); + } + Err(i) => active.insert(i, x), + } + } + } + Some(out) +} + +/// Skip the data of opcode `op`, by the length Apple's table gives it. +fn skip(op: u16, r: &mut Be) -> Option<()> { + let n = match op { + 0x00 | 0x17..=0x19 | 0x1C | 0x1E | 0xB0..=0xCF | 0x8000..=0x80FF => 0, + 0x04 => 1, + 0x03 | 0x05 | 0x08 | 0x0D | 0x11 | 0x15 | 0x16 | 0xA0 => 2, + 0x06 | 0x07 | 0x0B | 0x0C | 0x0E | 0x0F => 4, + 0x1A | 0x1B | 0x1D | 0x1F => 6, + 0x02 | 0x09 | 0x0A | 0x10 => 8, + // Reserved opcodes with a 16-bit length, and the font name. + 0x24..=0x27 | 0x2C..=0x2F | 0x92..=0x97 | 0x9C..=0x9F | 0xA2..=0xAF => usize::from(r.u16()?), + // Reserved for Apple: two bytes for each unit of the high byte (the 24-byte header 0x0C00). + 0x0100..=0x7FFF => usize::from(op >> 8) * 2, + // 0xD0–0xFE and 0x8100–0xFFFF (QuickTime images among them): a 32-bit length. + _ => usize::try_from(r.u32()?).ok()?, + }; + r.skip(n) +} + +/// How a bitmap's or pixel map's pixels are stored. +struct PixMap { + row_bytes: usize, + bounds: Rect, + /// Bits a pixel. + size: u16, + pack: u16, + cmps: u16, + /// Indexed pixels' colors; a bitmap has none (its set bits are the foreground color). + palette: Option>, + /// Rows of 8 bytes or more may be packed with PackBits, as the packing says. + packed: bool, +} + +impl PixMap { + /// From `rowBytes` on: a pixel map when `rowBytes` has its high bit set or `pixmap` says so, + /// else a bitmap; a pixel map's color table follows unless `direct`. + fn read(r: &mut Be, pixmap: bool, direct: bool) -> Option { + let raw = r.u16()?; + let mut pm = PixMap { row_bytes: usize::from(raw & 0x3FFF), bounds: r.rect()?, size: 1, pack: 0, cmps: 1, palette: None, packed: true }; + if pixmap || raw & 0x8000 != 0 { + r.skip(2)?; + pm.pack = r.u16()?; + r.skip(14)?; + pm.size = r.u16()?; + pm.cmps = r.u16()?; + r.skip(14)?; + if !direct { + pm.palette = Some(color_table(r)?); + } + } + Some(pm) + } + + /// The width and height of its pixels. + fn dims(&self) -> Option<(usize, usize)> { + let (w, h) = (self.bounds.width(), self.bounds.height()); + (w > 0.0 && h > 0.0).then_some((w as usize, h as usize)) + } + + /// The packing: 0 is 4 for 32-bit pixels and 3 for 16-bit ones. + fn packing(&self) -> u16 { + match (self.size, self.pack) { + (32, 0) => 4, + (16, 0) => 3, + (_, p) => p, + } + } + + /// Rows are stored as they are (no byte count, no PackBits). + fn raw_rows(&self) -> bool { + !self.packed || self.row_bytes < 8 || self.packing() == 1 || (self.size == 32 && self.packing() == 2) + } + + /// Bytes of an unpacked row: a component plane each for packing 4, three bytes a pixel for + /// packing 2, else `rowBytes`. + fn row_len(&self, w: usize) -> usize { + match self.packing() { + 4 if self.size == 32 && self.row_bytes >= 8 => w * usize::from(self.cmps), + 2 if self.size == 32 => w * 3, + _ => self.row_bytes, + } + } + + /// Whether its pixels can be read: a depth QuickDraw has, and rows long enough for them and at + /// most [`ROW_SLACK`] bytes longer. + fn decodable(&self, w: usize) -> bool { + let need = match self.size { + 1 | 2 | 4 | 8 => (w * usize::from(self.size)).div_ceil(8), + 16 => 2 * w, + 32 if self.packing() == 4 && self.row_bytes >= 8 => { + if !matches!(self.cmps, 3 | 4) { + return false; + } + w * usize::from(self.cmps) + } + 32 if self.packing() == 2 => 3 * w, + 32 => 4 * w, + _ => return false, + }; + (need..=need + ROW_SLACK).contains(&self.row_len(w)) + } + + /// Read `h` rows of pixel data, unpacked into one buffer when `keep`, else only stepped over. + /// `None` when the data runs past the end; the flag is false when a row didn't unpack to its + /// length. + fn rows(&self, r: &mut Be, w: usize, h: usize, keep: bool) -> Option<(Vec, bool)> { + let len = self.row_len(w); + let unit = if self.packing() == 3 { 2 } else { 1 }; + let mut out = vec![]; + let mut ok = true; + let mut keep = keep; + for _ in 0..h { + if self.raw_rows() { + let row = r.bytes(len)?; + if keep { + out.extend_from_slice(row); + } + continue; + } + let n = if self.row_bytes > 250 { usize::from(r.u16()?) } else { usize::from(r.u8()?) }; + let packed = r.bytes(n)?; + // After a row that doesn't unpack, the rest are only stepped over. + if keep && !unpack(packed, len, unit, &mut out) { + ok = false; + keep = false; + out = vec![]; + } + } + Some((out, ok)) + } + + /// The color of pixel `x` of `row` (the foreground and background colors for a bitmap's bits). + fn pixel(&self, row: &[u8], x: usize, w: usize, fg: [u8; 3], bg: [u8; 3]) -> Option<[u8; 3]> { + let at = |i: usize| row.get(i).copied(); + match self.size { + 1 | 2 | 4 | 8 => { + let bits = usize::from(self.size); + let shift = 8 - bits - x * bits % 8; + let index = usize::from((at(x * bits / 8)? >> shift) & ((1u16 << bits) - 1) as u8); + match &self.palette { + Some(p) => p.get(index).copied(), + None => Some(if index == 0 { bg } else { fg }), + } + } + 16 => { + let v = u16::from_be_bytes([at(2 * x)?, at(2 * x + 1)?]); + let c = |s: u16| (((v >> s) & 31) * 255 / 31) as u8; + Some([c(10), c(5), c(0)]) + } + _ if self.packing() == 4 && self.row_bytes >= 8 => { + // Component planes, alpha first when there are four (QuickDraw doesn't draw it). + let first = usize::from(self.cmps) - 3; + Some([at(first * w + x)?, at((first + 1) * w + x)?, at((first + 2) * w + x)?]) + } + _ if self.packing() == 2 => Some([at(3 * x)?, at(3 * x + 1)?, at(3 * x + 2)?]), + _ => Some([at(4 * x + 1)?, at(4 * x + 2)?, at(4 * x + 3)?]), + } + } + + /// Unpacked rows as opaque pixels. + fn to_rgba(&self, data: &[u8], w: usize, h: usize, fg: [u8; 3], bg: [u8; 3]) -> Option { + let len = self.row_len(w); + let mut pixels = Vec::with_capacity(w * h * 4); + for y in 0..h { + let row = data.get(y * len..(y + 1) * len)?; + for x in 0..w { + pixels.extend(self.pixel(row, x, w, fg, bg)?); + pixels.push(255); + } + } + Some(Rgba { width: u32::try_from(w).ok()?, height: u32::try_from(h).ok()?, pixels }) + } +} + +/// A `ColorTable`: indexed pixels' colors (entry by entry for a device table, else by each +/// entry's value). +fn color_table(r: &mut Be) -> Option> { + r.skip(4)?; + let flags = r.u16()?; + let n = usize::from(r.u16()?) + 1; + let mut palette = vec![[0u8; 3]; 256]; + for i in 0..n { + let value = usize::from(r.u16()?); + let c = r.rgb()?; + if let Some(e) = palette.get_mut(if flags & 0x8000 != 0 { i } else { value }) { + *e = c; + } + } + Some(palette) +} + +/// Unpack PackBits `data` (runs of `unit`-byte elements) onto `out`, `len` bytes; false when the +/// data is damaged or short (the row is padded with zeros). +fn unpack(data: &[u8], len: usize, unit: usize, out: &mut Vec) -> bool { + let start = out.len(); + let mut i = 0; + let mut ok = true; + while out.len() - start < len { + let Some(&n) = data.get(i) else { + ok = false; + break; + }; + i += 1; + let n = n as i8; + if n >= 0 { + let k = (n as usize + 1) * unit; + let Some(lit) = data.get(i..i + k) else { + ok = false; + break; + }; + out.extend_from_slice(lit); + i += k; + } else if n != -128 { + let Some(v) = data.get(i..i + unit) else { + ok = false; + break; + }; + for _ in 0..(1 - isize::from(n)) { + out.extend_from_slice(v); + } + i += unit; + } + } + out.resize(start + len, 0); + ok +} + +/// The picture being played: QuickDraw's drawing state, and the player that makes the objects. +struct Pict { + p: Player, + frame: Rect, + /// Picture space → document. + base: Affine, + origin: Vec2, + fg: [u8; 3], + bg: [u8; 3], + pen: Vec2, + pen_mode: u16, + pen_pat: Pat, + fill_pat: Pat, + back_pat: Pat, + oval: Vec2, + pen_loc: Point, + font: u16, + face: u8, + text_size: i16, + text_loc: Point, + /// Font numbers the picture names. + names: HashMap, + /// The last rectangle of the rectangle, rounded rectangle, oval and arc opcodes. + last: [Rect; 4], + region_work: u64, + /// Bytes of unpacked rows the picture's images and patterns may still take. + row_bytes_left: u64, +} + +impl Pict { + fn new(frame: Rect) -> Self { + let base = Affine::translate(-frame.origin().to_vec2()); + let artboard = Rect::new(0.0, 0.0, frame.width(), frame.height()); + // A picture's coordinates map straight onto the document, as a WMF's do. + let p = Player::new(Kind::Wmf, base, 25.4 / 72.0, artboard); + Self { + p, + frame, + base, + origin: Vec2::ZERO, + fg: [0; 3], + bg: [255; 3], + pen: Vec2::new(1.0, 1.0), + pen_mode: 8, + pen_pat: Pat::Bits([0xFF; 8]), + fill_pat: Pat::Bits([0xFF; 8]), + back_pat: Pat::Bits([0; 8]), + oval: Vec2::ZERO, + pen_loc: Point::ZERO, + font: 0, + face: 0, + text_size: 0, + text_loc: Point::ZERO, + names: HashMap::new(), + last: [Rect::ZERO; 4], + region_work: MAX_REGION_WORK, + row_bytes_left: MAX_FILE_ROW_BYTES, + } + } + + /// Charge the `w` × `h` pixels of `pm`, and the bytes of its unpacked rows, to the picture's + /// budgets: false, and nothing charged, when either is spent. + fn charge(&mut self, pm: &PixMap, w: usize, h: usize) -> bool { + let bytes = (pm.row_len(w) as u64).saturating_mul(h as u64); + let Some(rows_left) = self.row_bytes_left.checked_sub(bytes) else { return false }; + let (Ok(wide), Ok(high)) = (u32::try_from(w), u32::try_from(h)) else { return false }; + if dib::charge(wide, high, &mut self.p.pixels_left).is_err() { + return false; + } + self.row_bytes_left = rows_left; + true + } + + /// Play opcode `op`, its data at `r`; `None` when the data runs past the end. + fn op(&mut self, op: u16, r: &mut Be) -> Option<()> { + if !matches!(op, 0x20..=0x23) { + self.p.flush(); + } + match op { + 0x01 => self.clip(r), + 0x0C => self.move_origin(r), + 0x20..=0x23 => self.line(op, r), + 0x28..=0x2B => self.text(op, r), + 0x2C => self.font_name(r), + 0x30..=0x6F => self.shape(op, r), + 0x70..=0x77 => self.poly(verb(op), r), + 0x80..=0x87 => self.region_shape(verb(op), r), + // The same polygon and region: Apple's table gives them no data and no drawing. + 0x78..=0x7F | 0x88..=0x8F => Some(()), + 0x90 | 0x91 | 0x98..=0x9B => self.bits(op, r), + 0xA1 => { + r.skip(2)?; + let n = usize::from(r.u16()?); + r.skip(n) + } + 0x0C00 => self.header(r), + 0x8200 => self.quicktime(r), + _ => self.state(op, r), + } + } + + /// The opcodes that set the drawing state; the others are skipped. + fn state(&mut self, op: u16, r: &mut Be) -> Option<()> { + match op { + 0x02 => self.back_pat = Pat::Bits(r.array()?), + 0x03 => self.font = r.u16()?, + 0x04 => self.face = r.u8()?, + 0x07 => self.pen = r.size()?, + 0x08 => self.pen_mode = r.u16()?, + 0x09 => self.pen_pat = Pat::Bits(r.array()?), + 0x0A => self.fill_pat = Pat::Bits(r.array()?), + 0x0B => self.oval = r.size()?, + 0x0D => self.text_size = r.i16()?, + 0x0E => self.fg = old_color(r.u32()?), + 0x0F => self.bg = old_color(r.u32()?), + 0x12 => self.back_pat = self.pix_pat(r)?, + 0x13 => self.pen_pat = self.pix_pat(r)?, + 0x14 => self.fill_pat = self.pix_pat(r)?, + 0x1A => self.fg = r.rgb()?, + 0x1B => self.bg = r.rgb()?, + _ => return skip(op, r), + } + Some(()) + } + + /// The version 2 header: an extended one's source rectangle maps onto the frame. + fn header(&mut self, r: &mut Be) -> Option<()> { + let mut b = Be::new(r.bytes(24)?, 0); + if b.i16() == Some(-2) + && let Some(src) = b.skip(10).and_then(|()| b.rect()) + && src.width() > 0.0 + && src.height() > 0.0 + { + let k = Vec2::new(self.frame.width() / src.width(), self.frame.height() / src.height()); + self.base = Affine::scale_non_uniform(k.x, k.y) * Affine::translate(-src.origin().to_vec2()); + self.p.dev_to_doc = self.base * Affine::translate(-self.origin); + } + Some(()) + } + + /// The origin moves by (dh, dv): a point drawn after it is that much nearer the top left. + fn move_origin(&mut self, r: &mut Be) -> Option<()> { + let (dh, dv) = (r.i16()?, r.i16()?); + self.origin += Vec2::new(dh.into(), dv.into()); + self.p.dev_to_doc = self.base * Affine::translate(-self.origin); + Some(()) + } + + /// A pixel pattern: its color (the average of a color pattern's pixels), else its bitmap. + fn pix_pat(&mut self, r: &mut Be) -> Option { + let kind = r.u16()?; + let bits: [u8; 8] = r.array()?; + match kind { + 1 => { + let pm = PixMap::read(r, true, false)?; + let (w, h) = pm.dims()?; + let small = w * h <= MAX_PATTERN_PIXELS && pm.row_len(w) * h <= MAX_PATTERN_ROW_BYTES; + let keep = small && pm.decodable(w) && self.charge(&pm, w, h); + let (data, _) = pm.rows(r, w, h, keep)?; + let avg = keep.then(|| pm.to_rgba(&data, w, h, self.fg, self.bg)).flatten().map(|img| self.average(&img)); + Some(avg.map_or(Pat::Bits(bits), Pat::Rgb)) + } + 2 => Some(Pat::Rgb(r.rgb()?)), + _ => Some(Pat::Bits(bits)), + } + } + + /// The average color of `img`, with a warning when its pixels differ. + fn average(&mut self, img: &Rgba) -> [u8; 3] { + let px = img.pixels.as_chunks::<4>().0; + if px.windows(2).any(|w| w[0] != w[1]) { + self.p.warn(PATTERNS); + } + let n = px.len().max(1) as u64; + let sum = |i: usize| (px.iter().map(|p| u64::from(p[i])).sum::() / n) as u8; + [sum(0), sum(1), sum(2)] + } + + /// The color a pattern fills with: its own, or its two colors mixed as its bits are. + fn pat_color(&mut self, pat: Pat) -> [u8; 3] { + match pat { + Pat::Rgb(c) => c, + Pat::Bits(b) => { + let ones: u32 = b.iter().map(|x| x.count_ones()).sum(); + if ones != 0 && ones != 64 { + self.p.warn(PATTERNS); + } + mix(self.fg, self.bg, f64::from(ones) / 64.0) + } + } + } + + /// The pen's color (a warning when its mode isn't a copy). + fn pen_color(&mut self) -> [u8; 3] { + if !matches!(self.pen_mode, 0 | 8) { + self.p.warn(MODES); + } + self.pat_color(self.pen_pat) + } + + /// The color painting, erasing or filling an area takes; `None` for the other verbs. + fn area_color(&mut self, verb: Verb) -> Option<[u8; 3]> { + match verb { + Verb::Paint => Some(self.pen_color()), + Verb::Erase => Some(self.pat_color(self.back_pat)), + Verb::Fill => Some(self.pat_color(self.fill_pat)), + Verb::Frame | Verb::Invert => None, + } + } + + fn has_pen(&self) -> bool { + self.pen.x > 0.0 && self.pen.y > 0.0 + } + + /// Fill `bp` (picture space) with `color`. + fn fill(&mut self, mut bp: BezPath, color: [u8; 3], rule: FillRule) { + bp.apply_affine(self.p.xf()); + self.p.dc.brush = BrushObj::Solid(color); + self.p.dc.fill_rule = rule; + self.p.draw(bp, true, false); + } + + /// The pen as a stroke of `color` with `ends`, as wide as the pen's average side. + fn set_pen(&mut self, color: [u8; 3], ends: u32) { + let width = (self.pen.x + self.pen.y) / 2.0; + self.p.dc.pen = PenObj { style: PS_GEOMETRIC | ends | MITER, width, color, entries: vec![] }; + } + + /// Stroke `bp` (picture space) with the pen in `color`. + fn stroke(&mut self, mut bp: BezPath, color: [u8; 3], ends: u32) { + self.set_pen(color, ends); + bp.apply_affine(self.p.xf()); + self.p.draw(bp, false, true); + } + + /// Draw a framed shape: its outline (from `outline` of a rectangle and an oval size) stroked + /// inside `r`, or filled when the pen is as large as the shape. + fn frame(&mut self, r: Rect, outline: impl Fn(Rect, Vec2) -> BezPath, ends: u32) { + if !self.has_pen() { + return; + } + let color = self.pen_color(); + let half = self.pen / 2.0; + let inner = Rect::new(r.x0 + half.x, r.y0 + half.y, r.x1 - half.x, r.y1 - half.y); + if inner.width() <= 0.0 || inner.height() <= 0.0 { + return self.fill(outline(r, self.oval), color, FillRule::NonZero); + } + self.stroke(outline(inner, self.oval - self.pen), color, ends); + } + + /// The rectangle, rounded rectangle, oval and arc opcodes. + fn shape(&mut self, op: u16, r: &mut Be) -> Option<()> { + let family = op >> 4; + let slot = usize::from(family - 3); + let rect = if op & 8 != 0 { self.last.get(slot).copied()? } else { r.rect()? }; + let angles = if family == 6 { Some((r.i16()?, r.i16()?)) } else { None }; + let Some(verb) = verb(op) else { return Some(()) }; + if let Some(l) = self.last.get_mut(slot) { + *l = rect; + } + if rect.width() <= 0.0 || rect.height() <= 0.0 { + return Some(()); + } + let outline = move |r: Rect, oval: Vec2, wedge: bool| match family { + 3 => shapes::rectangle(r).to_bezpath(), + 4 => round_rect(r, oval.x, oval.y), + 5 => kurbo::Ellipse::from_rect(r).to_path(TOLERANCE), + _ => angles.map_or_else(BezPath::new, |(start, sweep)| arc(r, start, sweep, wedge)), + }; + match verb { + Verb::Frame => self.frame(rect, |r, oval| outline(r, oval, false), if family == 6 { FLAT } else { SQUARE }), + Verb::Invert => self.p.warn(INVERTED), + _ => { + if let Some(c) = self.area_color(verb) { + self.fill(outline(rect, self.oval, true), c, FillRule::NonZero); + } + } + } + Some(()) + } + + /// A polygon opcode: framed along its points, else filled. + fn poly(&mut self, verb: Option, r: &mut Be) -> Option<()> { + let size = usize::from(r.u16()?); + let mut b = Be::new(r.bytes(size.checked_sub(2)?)?, 8); + let Some(verb) = verb else { return Some(()) }; + let mut pts = vec![]; + while let Some(p) = b.point() { + pts.push(p); + } + let Some((first, rest)) = pts.split_first() else { return Some(()) }; + let closed = pts.len() > 2 && pts.first() == pts.last(); + match verb { + Verb::Frame if self.has_pen() => { + let half = self.pen / 2.0; + let mut bp = BezPath::new(); + bp.move_to(*first + half); + rest.iter().for_each(|p| bp.line_to(*p + half)); + if closed { + bp.close_path(); + } + let color = self.pen_color(); + self.stroke(bp, color, SQUARE); + } + Verb::Frame => {} + Verb::Invert => self.p.warn(INVERTED), + _ => { + let mut bp = BezPath::new(); + bp.move_to(*first); + rest.iter().for_each(|p| bp.line_to(*p)); + bp.close_path(); + if let Some(c) = self.area_color(verb) { + self.fill(bp, c, FillRule::EvenOdd); + } + } + } + Some(()) + } + + /// A region's rectangles, with a warning when it was too large to read. + fn read_region(&mut self, r: &mut Be) -> Option> { + let (rects, whole) = region(r, &mut self.region_work)?; + if !whole { + self.p.warn(BIG_REGIONS); + } + Some(rects) + } + + /// A region opcode: each rectangle framed, else the region filled. + fn region_shape(&mut self, verb: Option, r: &mut Be) -> Option<()> { + let rects = self.read_region(r)?; + let Some(verb) = verb.filter(|_| !rects.is_empty()) else { return Some(()) }; + match verb { + Verb::Frame if self.has_pen() => { + if rects.len() > 1 { + self.p.warn(FRAMED_REGIONS); + } + let half = self.pen / 2.0; + let inner: Vec = rects.iter().map(|r| Rect::new(r.x0 + half.x, r.y0 + half.y, r.x1 - half.x, r.y1 - half.y)).collect(); + let color = self.pen_color(); + self.stroke(rects_path(&inner), color, SQUARE); + } + Verb::Frame => {} + Verb::Invert => self.p.warn(INVERTED), + _ => { + if let Some(c) = self.area_color(verb) { + self.fill(rects_path(&rects), c, FillRule::NonZero); + } + } + } + Some(()) + } + + /// The clip region: it replaces the clip. An empty region clips away everything after it, as + /// a clip of no area at the frame's corner; one too large to read leaves the clip as it was. + fn clip(&mut self, r: &mut Be) -> Option<()> { + let (mut rects, whole) = region(r, &mut self.region_work)?; + if !whole { + self.p.warn(BIG_REGIONS); + return Some(()); + } + if rects.is_empty() { + let corner = Point::new(self.frame.x0, self.frame.y0); + rects.push(Rect::from_points(corner, corner)); + } + let mut bp = rects_path(&rects); + bp.apply_affine(self.p.xf()); + self.p.push_clip(bp, FillRule::NonZero, true); + Some(()) + } + + /// The line opcodes: the pen drawn from point to point, hanging below and to the right of + /// the line; lines one after another join into one path. + fn line(&mut self, op: u16, r: &mut Be) -> Option<()> { + let delta = |r: &mut Be| Some(Vec2::new(f64::from(r.u8()? as i8), f64::from(r.u8()? as i8))); + let (from, to) = match op { + 0x20 => (r.point()?, r.point()?), + 0x21 => (self.pen_loc, r.point()?), + 0x22 => { + let a = r.point()?; + (a, a + delta(r)?) + } + _ => (self.pen_loc, self.pen_loc + delta(r)?), + }; + self.pen_loc = to; + if !self.has_pen() { + return Some(()); + } + let color = self.pen_color(); + if from == to { + // A dot: the pen's rectangle. + self.p.flush(); + self.fill(shapes::rectangle(Rect::from_points(from, from + self.pen)).to_bezpath(), color, FillRule::NonZero); + return Some(()); + } + let pen = PenObj { style: PS_GEOMETRIC | SQUARE | MITER, width: (self.pen.x + self.pen.y) / 2.0, color, entries: vec![] }; + if self.p.dc.pen != pen { + self.p.flush(); + self.p.dc.pen = pen; + } + let half = self.pen / 2.0; + let xf = self.p.xf(); + let (a, b) = (xf * (from + half), xf * (to + half)); + if self.p.pending.elements().last().and_then(|e| e.end_point()) != Some(a) { + self.p.pending.move_to(a); + } + self.p.pending.line_to(b); + Some(()) + } + + /// The text opcodes: a string at the text location, moved to or by the opcode's data. + fn text(&mut self, op: u16, r: &mut Be) -> Option<()> { + match op { + 0x28 => self.text_loc = r.point()?, + 0x29 => self.text_loc.x += f64::from(r.u8()?), + 0x2A => self.text_loc.y += f64::from(r.u8()?), + _ => { + self.text_loc.x += f64::from(r.u8()?); + self.text_loc.y += f64::from(r.u8()?); + } + } + let n = usize::from(r.u8()?); + let s: String = r.bytes(n)?.iter().map(|&b| mac_roman(b)).filter(|c| !c.is_control()).collect(); + let size = if self.text_size > 0 { self.text_size } else { 12 }; + let face = self.names.get(&self.font).cloned().unwrap_or_else(|| font_family(self.font).to_string()); + self.p.dc.font = FontObj { + height: -f64::from(size), + escapement: 0.0, + weight: if self.face & 1 != 0 { 700 } else { 400 }, + italic: self.face & 2 != 0, + face, + }; + self.p.dc.text_color = self.fg; + self.p.dc.text_align = TA_BASELINE; + self.p.text(self.text_loc, &s); + Some(()) + } + + /// A font's name for its number. + fn font_name(&mut self, r: &mut Be) -> Option<()> { + let n = usize::from(r.u16()?); + let mut b = Be::new(r.bytes(n)?, 0); + if let Some(id) = b.u16() + && let Some(len) = b.u8() + && let Some(name) = b.bytes(usize::from(len)) + { + let name: String = name.iter().map(|&c| mac_roman(c)).collect(); + self.names.insert(id, name.trim().to_string()); + } + Some(()) + } + + /// A bitmap or pixel map copied into a rectangle (`Rgn` opcodes: clipped to a mask region). + fn bits(&mut self, op: u16, r: &mut Be) -> Option<()> { + let direct = matches!(op, 0x9A | 0x9B); + if direct { + r.skip(4)?; + } + let mut pm = PixMap::read(r, direct, direct)?; + // BitsRect and BitsRgn store their rows unpacked. + pm.packed = op >= 0x98; + let (src, dst, mode) = (r.rect()?, r.rect()?, r.u16()?); + let mask = if op & 1 != 0 { Some(self.read_region(r)?) } else { None }; + let (w, h) = pm.dims()?; + let (wide, high) = (w as u64, h as u64); + let fits = wide * high <= dib::MAX_READ_PIXELS && pm.row_len(w) as u64 * high <= MAX_ROW_BYTES; + let readable = pm.decodable(w); + let keep = fits && readable && self.charge(&pm, w, h); + let (data, ok) = pm.rows(r, w, h, keep)?; + if !keep { + self.p.warn(if readable { TOO_MANY_PIXELS } else { BAD_IMAGE }); + return Some(()); + } + let Some(mut img) = pm.to_rgba(&data, w, h, self.fg, self.bg).filter(|_| ok) else { + self.p.warn(BAD_IMAGE); + return Some(()); + }; + self.transfer(&mut img, mode, pm.size == 1 && pm.palette.is_none()); + if dst.width() <= 0.0 || dst.height() <= 0.0 || mask.as_ref().is_some_and(Vec::is_empty) { + return Some(()); + } + let saved = self.p.dc.clips.clone(); + if let Some(rects) = &mask { + let mut bp = rects_path(rects); + bp.apply_affine(self.p.xf()); + self.p.push_clip(bp, FillRule::NonZero, false); + } + self.p.image(Ok(img), src - pm.bounds.origin().to_vec2(), dst, SRCCOPY, 1.0); + self.p.dc.clips = saved; + Some(()) + } + + /// Transfer mode `mode` on copied pixels: OR of a bitmap and the transparent mode leave the + /// background color out; the others are copied, with a warning. + fn transfer(&mut self, img: &mut Rgba, mode: u16, bitmap: bool) { + // 64 dithers, which doesn't change what is copied. + let clear = match mode & !64 { + 0 => false, + 1 if bitmap => true, + 36 => true, + _ => { + self.p.warn(MODES); + false + } + }; + if clear { + let bg = self.bg; + img.pixels.as_chunks_mut::<4>().0.iter_mut().filter(|p| [p[0], p[1], p[2]] == bg).for_each(|p| p[3] = 0); + } + } + + /// A compressed QuickTime image: a JPEG one becomes an image placed by its matrix. + fn quicktime(&mut self, r: &mut Be) -> Option<()> { + let n = usize::try_from(r.u32()?).ok()?; + let data = r.bytes(n)?; + match qt_image(data) { + Some((ctype, xf, src, image)) if &ctype == b"jpeg" => match self.jpeg(image) { + Ok(img) => { + let img = crop(img, src); + self.p.image_xf(img, xf * Affine::translate(src.origin().to_vec2()), 1.0); + } + Err(w) => self.p.warn(w), + }, + Some(_) => self.p.warn(CODECS), + None => self.p.warn(BAD_IMAGE), + } + Some(()) + } + + /// A JPEG stream's pixels, taken from the file's pixel budget. + fn jpeg(&mut self, data: &[u8]) -> Result { + let reader = || image::ImageReader::with_format(Cursor::new(data), image::ImageFormat::Jpeg); + let (w, h) = reader().into_dimensions().map_err(|_| BAD_IMAGE)?; + if w > dib::MAX_READ_SIDE || h > dib::MAX_READ_SIDE || u64::from(w) * u64::from(h) > dib::MAX_READ_PIXELS { + return Err(TOO_MANY_PIXELS); + } + dib::charge(w, h, &mut self.p.pixels_left).map_err(|_| TOO_MANY_PIXELS)?; + let img = reader().decode().map_err(|_| BAD_IMAGE)?.to_rgba8(); + Ok(Rgba { width: img.width(), height: img.height(), pixels: img.into_raw() }) + } +} + +/// A QuickTime image's compressor type, matrix (image → picture space), source rectangle and +/// compressed data, from its header and image description. +fn qt_image(d: &[u8]) -> Option<([u8; 4], Affine, Rect, &[u8])> { + let mut b = Be::new(d, 2); + let mut m = [0.0; 9]; + for v in &mut m { + *v = f64::from(b.i32()?) / 65536.0; + } + let matte = usize::try_from(b.u32()?).ok()?; + b.skip(10)?; + let src = b.rect()?; + b.skip(4)?; + let mask = usize::try_from(b.u32()?).ok()?; + b.skip(matte)?; + b.skip(mask)?; + let start = b.pos; + let id_size = usize::try_from(b.u32()?).ok()?; + let ctype = b.array::<4>()?; + b.skip(36)?; + let size = usize::try_from(b.u32()?).ok()?; + let at = start.checked_add(id_size)?; + let data = d.get(at..at.checked_add(size)?).or_else(|| d.get(at..))?; + // The rows of a QuickTime matrix are [a b u] [c d v] [tx ty w]; u, v and w (perspective) are + // left out. + let xf = Affine::new([m[0], m[1], m[3], m[4], m[6], m[7]]); + Some((ctype, xf, src, data)) +} + +/// Read a picture. +pub(crate) fn play(bytes: &[u8]) -> Result { + let h = find(bytes).ok_or(NOT_PICT)?; + let mut q = Pict::new(h.frame); + let mut r = Be::new(bytes, h.ops); + // Why the picture stopped before its end opcode, if it did. + let mut stopped = Some(TOO_MANY_OPS); + for _ in 0..MAX_OPS { + // Version 2 opcodes start on even bytes. + if h.v2 && r.pos % 2 == 1 && r.skip(1).is_none() { + stopped = Some(CUT_SHORT); + break; + } + let Some(op) = (if h.v2 { r.u16() } else { r.u8().map(u16::from) }) else { + stopped = Some(CUT_SHORT); + break; + }; + if op == 0x00FF { + stopped = None; + break; + } + if q.op(op, &mut r).is_none() { + stopped = Some(CUT_SHORT); + break; + } + } + if let Some(why) = stopped { + q.p.warn(why); + } + q.p.flush(); + if q.p.out.is_empty() { + q.p.warn(NOTHING); + } + Ok(q.p.finish()) +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index a517d516a6..0e699236d7 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -15,13 +15,14 @@ //! //! [`import`] reads either format back into a document of one artboard (the picture's frame) and //! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are -//! skipped, with one warning. +//! skipped, with one warning. [`import_pict`] reads QuickDraw pictures (PICT, versions 1 and 2) +//! into the same kind of document. //! //! - `bytes`: little-endian writing and bounds-checked reading. //! - `dib`: device-independent bitmaps, both ways. //! - `scene`: the document walk both writers share. //! - `emf`, `wmf`: the writers. -//! - `import`: the reader. +//! - `import`: the readers. mod bytes; mod dib; @@ -107,5 +108,28 @@ pub fn import(bytes: &[u8]) -> Result { Ok(i) } +/// A QuickDraw picture read into a document. +#[derive(Clone, Debug)] +pub struct Picture { + pub document: Document, + /// What was approximated or skipped. + pub warnings: Vec, +} + +/// Do `bytes` hold a version 2 QuickDraw picture, after a PICT file's 512 bytes of application +/// data or on their own? A version 1 picture is known by its file's name. +pub fn is_pict(bytes: &[u8]) -> bool { + import::pict::is_pict(bytes) +} + +/// Read a QuickDraw picture (version 1 or 2; a PICT file, or a picture on its own) into a document +/// whose one artboard is the picture's frame. +pub fn import_pict(bytes: &[u8]) -> Result { + let i = import::pict::play(bytes)?; + let mut p = Picture { document: i.document, warnings: i.warnings }; + p.document.limit_depth_noting(&mut p.warnings); + Ok(p) +} + #[cfg(test)] mod tests; diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 23bcfdc672..602db3d55f 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -12,6 +12,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; use crate::*; mod import; +mod pict; /// Add `n` (with a fresh id) on top of `d`'s first layer. pub(crate) fn add(d: &mut Document, mut n: Node) { diff --git a/crates/metafile/src/tests/pict.rs b/crates/metafile/src/tests/pict.rs new file mode 100644 index 0000000000..055e88c95b --- /dev/null +++ b/crates/metafile/src/tests/pict.rs @@ -0,0 +1,466 @@ +//! QuickDraw pictures read from hand-built opcodes: versions 1 and 2, with and without the file's +//! application data; shapes, lines, regions, patterns, text, bitmaps, pixel maps and QuickTime +//! JPEG images; and pictures that are cut short, damaged or never end. + +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect}; + +use crate::import_pict; + +/// Big-endian words. +fn be(v: &[i16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() +} + +/// A QuickDraw rectangle: top, left, bottom, right. +fn rect(top: i16, left: i16, bottom: i16, right: i16) -> Vec { + be(&[top, left, bottom, right]) +} + +/// A version 2 PICT file: 512 bytes of application data, then a picture of `frame` (top, left, +/// bottom, right) holding `ops`, each padded to an even length, and the end opcode. +fn v2(frame: [i16; 4], ops: &[(u16, Vec)]) -> Vec { + let mut b = vec![0xA5; 512]; + b.extend([0, 0]); + b.extend(be(&frame)); + b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]); + b.extend(be(&[-1, 0])); + b.extend([0; 20]); + for (op, data) in ops { + b.extend(op.to_be_bytes()); + b.extend(data); + if b.len() % 2 == 1 { + b.push(0); + } + } + b.extend([0x00, 0xFF]); + b +} + +/// An RGB foreground color. +fn fg(r: u16, g: u16, b: u16) -> (u16, Vec) { + (0x1A, [r, g, b].iter().flat_map(|c| c.to_be_bytes()).collect()) +} + +/// Every object of the imported document, depth first. +fn all(d: &Document) -> Vec { + let mut v = vec![]; + for l in &d.layers { + for c in l.children().into_iter().flatten() { + c.walk(&mut |n| v.push(n.clone())); + } + } + v +} + +fn paths(d: &Document) -> Vec { + all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { clipping: false, .. })).collect() +} + +fn near(a: Rect, b: Rect, tol: f64) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol) +} + +fn fill_rgb(n: &Node) -> Option<[u8; 3]> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => f.paint.color().map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }), + _ => None, + }) +} + +fn stroke(n: &Node) -> Option<([u8; 3], f64)> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Stroke(s) => s.paint.color().map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + ([r, g, b], s.width) + }), + _ => None, + }) +} + +/// The images of `d` with their pixels. +fn images(d: &Document) -> Vec<(Node, image::RgbaImage)> { + all(d) + .into_iter() + .filter_map(|n| match &n.kind { + NodeKind::Image(im) => { + let png = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + Some((n.clone(), png)) + } + _ => None, + }) + .collect() +} + +#[test] +fn a_version_2_picture_opens_as_shapes_in_its_frame() { + let bytes = v2( + [10, 20, 110, 220], + &[ + fg(0xFFFF, 0, 0), + // paintRect + (0x31, rect(20, 30, 60, 80)), + (0x07, be(&[4, 4])), + fg(0, 0, 0xFFFF), + // frameRect: stroked inside the rectangle. + (0x30, rect(20, 120, 80, 200)), + // A line, and one from its end: one path. + (0x20, be(&[90, 40, 90, 140])), + (0x21, be(&[100, 140])), + ], + ); + assert!(crate::is_pict(&bytes)); + let p = import_pict(&bytes).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let a = p.document.artboards[0].rect; + assert!(near(a, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-9), "{a:?}"); + let v = paths(&p.document); + assert_eq!(v.len(), 3, "{v:?}"); + assert_eq!(fill_rgb(&v[0]), Some([255, 0, 0])); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 50.0), 1e-6)); + assert!(v[0].appearance.stroke().is_none()); + // The pen's outer edge is the rectangle. + assert_eq!(stroke(&v[1]), Some(([0, 0, 255], 4.0))); + assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(102.0, 12.0, 178.0, 68.0), 1e-6)); + assert!(fill_rgb(&v[1]).is_none()); + // The pen hangs below and to the right of the line. + assert_eq!(stroke(&v[2]), Some(([0, 0, 255], 4.0))); + assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(22.0, 82.0, 122.0, 92.0), 1e-6)); +} + +#[test] +fn a_bare_picture_and_a_version_1_picture_open() { + // A version 2 picture without the file's application data. + let file = v2([0, 0, 50, 50], &[(0x31, rect(10, 10, 20, 20))]); + let bare = &file[512..]; + assert!(crate::is_pict(bare)); + assert_eq!(paths(&import_pict(bare).unwrap().document).len(), 1); + // Version 1: byte opcodes, known by name only. + let mut v1 = vec![0, 0]; + v1.extend(rect(0, 0, 40, 60)); + v1.extend([0x11, 0x01, 0x31]); + v1.extend(rect(5, 5, 15, 25)); + v1.extend([0xA0, 0x00, 0x05, 0x50]); + v1.extend(rect(20, 20, 30, 30)); + v1.push(0xFF); + assert!(!crate::is_pict(&v1)); + let p = import_pict(&v1).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + assert_eq!(p.document.artboards[0].rect.size(), (60.0, 40.0).into()); + let v = paths(&p.document); + assert_eq!(v.len(), 2); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 25.0, 15.0), 1e-6)); + // The default pen: one point, black. + assert_eq!(stroke(&v[1]), Some(([0, 0, 0], 1.0))); + // Inside a file's application data too. + let mut file = vec![0; 512]; + file.extend(&v1); + assert_eq!(paths(&import_pict(&file).unwrap().document).len(), 2); +} + +#[test] +fn files_that_are_not_pictures_are_refused() { + assert_eq!(import_pict(&[0; 600]).unwrap_err(), "not a PICT file"); + assert_eq!(import_pict(b"").unwrap_err(), "not a PICT file"); + // An empty frame. + let mut b = vec![0, 0]; + b.extend(rect(10, 10, 10, 50)); + b.extend([0x11, 0x01, 0xFF]); + assert!(import_pict(&b).is_err()); +} + +#[test] +fn rounded_rectangles_ovals_arcs_and_same_shapes() { + let bytes = v2( + [0, 0, 100, 100], + &[ + (0x0B, be(&[20, 20])), + (0x41, rect(0, 0, 50, 50)), + (0x51, rect(50, 0, 100, 50)), + // A wedge from 12 to 3 o'clock. + (0x61, [rect(0, 50, 100, 150), be(&[0, 90])].concat()), + fg(0, 0xFFFF, 0), + // paintSameRect after paintRect: the same rectangle. + (0x31, rect(60, 60, 70, 80)), + (0x39, vec![]), + ], + ); + let p = import_pict(&bytes).unwrap(); + let v = paths(&p.document); + assert_eq!(v.len(), 5, "{v:?}"); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 50.0), 1e-6)); + assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 50.0, 50.0, 100.0), 1e-3)); + assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(100.0, 0.0, 150.0, 50.0), 1e-3), "{:?}", v[2].geometric_bounds()); + assert_eq!(v[3].geometric_bounds(), v[4].geometric_bounds()); + assert_eq!(fill_rgb(&v[4]), Some([0, 255, 0])); +} + +#[test] +fn polygons_and_regions_fill_and_clip() { + // An L: the top band 20 wide, the bottom band 10. + let region = [be(&[36]), rect(0, 0, 20, 20), be(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat(); + let poly = [be(&[22]), rect(0, 0, 40, 40), be(&[0, 0, 40, 40, 0, 40])].concat(); + let bytes = v2([0, 0, 100, 100], &[(0x81, region.clone()), (0x71, poly), (0x01, region), (0x31, rect(0, 0, 100, 100))]); + let p = import_pict(&bytes).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let v = paths(&p.document); + assert_eq!(v.len(), 3); + let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") }; + assert_eq!(path.subpaths.len(), 2, "two bands"); + assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9)); + assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 40.0, 40.0), 1e-9)); + // The rectangle after the clip region is clipped to it. + let layer = p.document.layers[0].children().unwrap(); + let NodeKind::Group { children, clip: true } = &layer[2].kind else { panic!("a clipping group: {:?}", layer[2].kind) }; + assert!(near(children[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9)); +} + +#[test] +fn patterns_fill_with_their_average_color() { + let gray = vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]; + let bytes = v2( + [0, 0, 10, 10], + &[(0x0A, gray), (0x34, rect(0, 0, 10, 10)), (0x12, [be(&[2]), vec![0; 8], be(&[0, -1, 0])].concat()), (0x32, rect(0, 0, 5, 5))], + ); + let p = import_pict(&bytes).unwrap(); + let v = paths(&p.document); + assert_eq!(fill_rgb(&v[0]), Some([128, 128, 128])); + // eraseRect with an RGB background pattern. + assert_eq!(fill_rgb(&v[1]), Some([0, 255, 0])); + assert!(p.warnings.iter().any(|w| w.contains("average of their colors")), "{:?}", p.warnings); +} + +#[test] +fn text_comes_in_as_point_type_in_the_named_font() { + let name = [be(&[9, 300]), vec![6], b"Geneva".to_vec()].concat(); + let bytes = v2( + [0, 0, 100, 200], + &[(0x2C, name), (0x03, be(&[300])), (0x0D, be(&[18])), (0x04, vec![1]), (0x28, [be(&[50, 10]), vec![4], b"Caf\x8E".to_vec()].concat())], + ); + let p = import_pict(&bytes).unwrap(); + let texts: Vec<_> = all(&p.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).collect(); + let NodeKind::Text(t) = &texts[0].kind else { panic!("text") }; + assert_eq!(t.plain_text(), "Café"); + let st = &t.runs[0].style; + assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Geneva", "Bold", 18.0)); + // The text location is the baseline's start. + assert_eq!(t.xf.translation(), Point::new(10.0, 50.0).to_vec2()); +} + +/// A pixel map's fields after `rowBytes` and its bounds: version, packing, resolution, depth and +/// components. +fn pixmap_fields(pack: i16, size: i16, cmps: i16) -> Vec { + be(&[0, pack, 0, 0, 72, 0, 72, 0, 0, size, cmps, 8, 0, 0, 0, 0, 0, 0]) +} + +#[test] +fn a_packed_indexed_pixel_map_becomes_an_image() { + let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]); + // An 8 × 2 map: a run of red, then blue and red by turns. + let rows = [vec![2, 0xF9, 0], vec![9, 7, 1, 0, 1, 0, 1, 0, 1, 0]].concat(); + let data = + [be(&[0x8008u16 as i16]), rect(0, 0, 2, 8), pixmap_fields(0, 8, 1), table, rect(0, 0, 2, 8), rect(10, 20, 14, 36), be(&[0]), rows].concat(); + let p = import_pict(&v2([0, 0, 50, 50], &[(0x98, data)])).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 1); + let (n, px) = &imgs[0]; + assert_eq!(px.dimensions(), (8, 2)); + assert_eq!(px.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(px.get_pixel(0, 1).0, [0, 0, 255, 255]); + assert_eq!(px.get_pixel(1, 1).0, [255, 0, 0, 255]); + // Drawn into its destination, twice its size. + assert!(near(n.geometric_bounds().unwrap(), Rect::new(20.0, 10.0, 36.0, 14.0), 1e-9)); +} + +#[test] +fn direct_pixel_maps_and_masked_bitmaps() { + // 32-bit, packed by component planes: red, then green. + let row = vec![7, 5, 255, 0, 0, 255, 0, 0]; + let direct = + [be(&[0, 0xFF, 0x8008u16 as i16]), rect(0, 0, 1, 2), pixmap_fields(4, 32, 3), rect(0, 0, 1, 2), rect(0, 0, 1, 2), be(&[0]), row].concat(); + // A one-bit bitmap ORed in (its clear bits left out), masked by a region. + let mask = [be(&[10]), rect(0, 0, 1, 1)].concat(); + let bitmap = [be(&[2]), rect(0, 0, 1, 2), rect(0, 0, 1, 2), rect(5, 0, 6, 2), be(&[1]), mask, vec![0x80, 0]].concat(); + let p = import_pict(&v2([0, 0, 10, 10], &[(0x9A, direct), (0x91, bitmap)])).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 2); + assert_eq!(imgs[0].1.get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(imgs[0].1.get_pixel(1, 0).0, [0, 255, 0, 255]); + assert_eq!(imgs[1].1.get_pixel(0, 0).0, [0, 0, 0, 255]); + assert_eq!(imgs[1].1.get_pixel(1, 0).0[3], 0, "the clear bit is left out"); + let layer = p.document.layers[0].children().unwrap(); + assert!(matches!(layer[1].kind, NodeKind::Group { clip: true, .. }), "the mask region clips the bitmap"); +} + +/// A pixel map's rows may be only a little longer than its pixels need: a map one pixel wide +/// with rows of 16,383 bytes is left out, and a pixel pattern like it fills with its bitmap. +#[test] +fn rows_far_longer_than_their_pixels_are_refused() { + let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, -1, 0, 0]); + // A row of 16,383 ones: 127 runs of 128 and one of 127, after its byte count. + let mut row = be(&[256]); + (0..127).for_each(|_| row.extend([0x81, 1])); + row.extend([0x82, 1]); + let rows = row.repeat(4); + let map = [be(&[0xBFFFu16 as i16]), rect(0, 0, 4, 1), pixmap_fields(0, 8, 1), table.clone()].concat(); + let data = [map, rect(0, 0, 4, 1), rect(0, 0, 4, 1), be(&[0]), rows].concat(); + let p = import_pict(&v2([0, 0, 10, 10], &[(0x98, data)])).unwrap(); + assert!(images(&p.document).is_empty()); + assert!(p.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", p.warnings); + + let pattern = [be(&[1]), vec![0xFF; 8], be(&[0xBFFFu16 as i16]), rect(0, 0, 1, 1), pixmap_fields(0, 8, 1), table, row].concat(); + let p = import_pict(&v2([0, 0, 10, 10], &[(0x12, pattern), (0x32, rect(0, 0, 5, 5))])).unwrap(); + assert_eq!(fill_rgb(&paths(&p.document)[0]), Some([0, 0, 0]), "the pattern's bitmap in the foreground color"); +} + +/// BitsRect rows are stored unpacked, also when a row is 8 bytes or longer. +#[test] +fn bitmap_rows_of_eight_bytes_or_more_are_read_unpacked() { + let row = vec![0x80, 0, 0, 0, 0, 0, 0, 0]; + let bitmap = [be(&[8]), rect(0, 0, 1, 16), rect(0, 0, 1, 16), rect(0, 0, 1, 16), be(&[0]), row].concat(); + let p = import_pict(&v2([0, 0, 10, 20], &[(0x90, bitmap), (0x31, rect(2, 2, 4, 4))])).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 1); + assert_eq!(imgs[0].1.dimensions(), (16, 1)); + assert_eq!(imgs[0].1.get_pixel(0, 0).0, [0, 0, 0, 255]); + assert_eq!(imgs[0].1.get_pixel(1, 0).0, [255, 255, 255, 255]); + assert_eq!(paths(&p.document).len(), 1, "the rectangle after it"); +} + +/// An empty clip region clips away what follows it. +#[test] +fn an_empty_clip_region_hides_what_follows() { + let empty = [be(&[10]), rect(0, 0, 0, 0)].concat(); + let p = import_pict(&v2([0, 0, 50, 50], &[(0x01, empty), (0x31, rect(0, 0, 10, 10))])).unwrap(); + let layer = p.document.layers[0].children().unwrap(); + let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) }; + let b = children[0].geometric_bounds().unwrap(); + assert_eq!((b.width(), b.height()), (0.0, 0.0), "{b:?}"); +} + +/// A QuickTime image of `ctype` with `matrix` (a, b, c, d, tx, ty in points) and source rectangle +/// `w` × `h`, holding `data`. +fn quicktime(ctype: &[u8; 4], matrix: [f64; 6], w: i16, h: i16, data: &[u8]) -> Vec { + let fixed = |v: f64| ((v * 65536.0) as i32).to_be_bytes(); + let [a, b, c, d, tx, ty] = matrix; + let mut q = be(&[0]); + for v in [a, b, 0.0, c, d, 0.0, tx, ty] { + q.extend(fixed(v)); + } + q.extend((1i32 << 30).to_be_bytes()); + q.extend([0; 4]); + q.extend([0; 8]); + q.extend(be(&[0])); + q.extend(rect(0, 0, h, w)); + q.extend([0; 4]); + q.extend([0; 4]); + let mut desc = vec![]; + desc.extend(86u32.to_be_bytes()); + desc.extend(ctype); + desc.extend([0; 24]); + desc.extend(be(&[w, h])); + desc.extend([0; 8]); + desc.extend((data.len() as u32).to_be_bytes()); + desc.resize(86, 0); + q.extend(desc); + q.extend(data); + [(q.len() as u32).to_be_bytes().to_vec(), q].concat() +} + +fn red_jpeg() -> Vec { + let img = image::RgbImage::from_pixel(4, 2, image::Rgb([255, 0, 0])); + let mut out = std::io::Cursor::new(vec![]); + img.write_to(&mut out, image::ImageFormat::Jpeg).unwrap(); + out.into_inner() +} + +#[test] +fn quicktime_jpeg_images_are_placed_by_their_matrix() { + let jpeg = red_jpeg(); + let ops = [ + (0x8200, quicktime(b"jpeg", [2.0, 0.0, 0.0, 2.0, 10.0, 5.0], 4, 2, &jpeg)), + (0x8200, quicktime(b"rle ", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], 4, 2, &[1, 2, 3])), + ]; + let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap(); + let imgs = images(&p.document); + assert_eq!(imgs.len(), 1); + let (n, px) = &imgs[0]; + assert_eq!(px.dimensions(), (4, 2)); + let [r, g, b, _] = px.get_pixel(1, 1).0; + assert!(r > 240 && g < 16 && b < 16, "{r} {g} {b}"); + assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 5.0, 18.0, 9.0), 1e-9)); + assert!(p.warnings.iter().any(|w| w.contains("other than JPEG")), "{:?}", p.warnings); +} + +/// An image whose matrix flattens it is left out, and adds nothing to the document's images. +#[test] +fn a_flattened_image_adds_no_image_data() { + let p = import_pict(&v2([0, 0, 50, 50], &[(0x8200, quicktime(b"jpeg", [0.0; 6], 4, 2, &red_jpeg()))])).unwrap(); + assert!(images(&p.document).is_empty()); + assert!(p.document.images.is_empty(), "{:?}", p.document.images.keys().collect::>()); +} + +#[test] +fn pictures_cut_short_keep_what_came_before() { + let full = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0x31, rect(20, 20, 30, 30))]); + // Cut inside the second rectangle. + let cut = &full[..full.len() - 6]; + let p = import_pict(cut).unwrap(); + assert_eq!(paths(&p.document).len(), 1); + assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings); + // A long comment longer than the file. + let p = import_pict(&v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0xA1, be(&[100, 5000]))])).unwrap(); + assert_eq!(paths(&p.document).len(), 1); + assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings); + // Nothing drawn. + let p = import_pict(&v2([0, 0, 50, 50], &[(0xA0, be(&[100]))])).unwrap(); + assert!(p.warnings.iter().any(|w| w.contains("draws nothing")), "{:?}", p.warnings); +} + +#[test] +fn reserved_opcodes_are_skipped_by_their_lengths() { + let ops = [ + // A 16-bit length, a fixed length for Apple's reserved words, and a 32-bit length. + (0x0024, be(&[4, 1, 2])), + (0x0200, be(&[1, 2])), + (0x00D0, [8u32.to_be_bytes().to_vec(), vec![9; 8]].concat()), + (0x8201, [2u32.to_be_bytes().to_vec(), vec![9; 2]].concat()), + (0x31, rect(0, 0, 10, 10)), + ]; + let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap(); + assert_eq!(paths(&p.document).len(), 1, "{:?}", p.warnings); +} + +/// A picture of nothing but no-ops stops at the opcode limit. +#[test] +fn a_picture_that_never_ends_stops_at_the_opcode_limit() { + let mut b = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10))]); + b.truncate(b.len() - 2); + b.resize(b.len() + 2 * ((1 << 22) + 1), 0); + let p = import_pict(&b).unwrap(); + assert_eq!(paths(&p.document).len(), 1); + assert!(p.warnings.iter().any(|w| w.contains("more opcodes")), "{:?}", p.warnings); +} + +/// Regions share one budget of inversion points per picture: past it, a region is left out. +#[test] +fn regions_past_the_picture_budget_are_left_out() { + // One scanline of 16,000 inversion points: 8,000 rectangles. + let mut words = vec![0]; + words.extend(0..16_000i16); + words.extend([0x7FFF, 1, 0x7FFF, 0x7FFF]); + let body = [rect(0, 0, 1, 16_000), be(&words)].concat(); + let region = [be(&[(body.len() + 2) as i16]), body].concat(); + let p = import_pict(&v2([0, 0, 300, 300], &[(0x81, region.clone()), (0x81, region.clone()), (0x81, region)])).unwrap(); + assert!(p.warnings.iter().any(|w| w.contains("regions larger")), "{:?}", p.warnings); + let v = paths(&p.document); + assert_eq!(v.len(), 2); + let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") }; + assert_eq!(path.subpaths.len(), 8_000); +} diff --git a/crates/pdf/src/import/actual_text.rs b/crates/pdf/src/import/actual_text.rs index d6d18a7566..6d7ddbaf9d 100644 --- a/crates/pdf/src/import/actual_text.rs +++ b/crates/pdf/src/import/actual_text.rs @@ -11,6 +11,9 @@ pub(super) struct ActualText { depth: usize, owner: Option, pending: Option, + /// The owner's replacement is empty: its content has no text, so its glyphs stay art + /// ([`Self::textless`], #6971). + empty: bool, } impl ActualText { @@ -22,6 +25,7 @@ impl ActualText { if self.owner == Some(self.depth) { self.owner = None; self.pending = None; + self.empty = false; } self.depth = self.depth.saturating_sub(1); } @@ -30,6 +34,12 @@ impl ActualText { self.owner.is_some() } + /// Inside a span whose replacement text is empty: the glyphs there are no text, but they + /// still draw, so they come in as outlines rather than as type with no characters (#6971). + pub(super) fn textless(&self) -> bool { + self.owner.is_some() && self.empty + } + pub(super) fn set(&mut self, bytes: &[u8]) -> bool { if self.active() { return true; @@ -39,8 +49,10 @@ impl ActualText { if self.depth == 0 || self.depth > 128 || bytes.len() > 64 * 1024 { return false; } + let text = crate::import_scan::text_string(bytes); self.owner = Some(self.depth); - self.pending = Some(crate::import_scan::text_string(bytes)); + self.empty = text.is_empty(); + self.pending = Some(text); true } } diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 6d47168716..21d862d2fd 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -800,7 +800,8 @@ impl<'p> Builder<'p> { } /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps - /// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text, + /// its outline (no Unicode text, mirrored, not type mode, or in a span whose ActualText is + /// empty). No `paint`: invisible text, /// type with neither fill nor stroke. pub(super) fn type_glyph( &mut self, @@ -811,7 +812,7 @@ impl<'p> Builder<'p> { stroke: Option<&StrokeProps>, overprint: bool, ) -> bool { - if self.text_as != TextAs::Text || self.nested > 0 { + if self.text_as != TextAs::Text || self.nested > 0 || self.actual.textless() { return false; } let authored = self.actual.active(); diff --git a/crates/pdf/src/tests/actual_text.rs b/crates/pdf/src/tests/actual_text.rs index 8e6bd4810b..7ff4599a57 100644 --- a/crates/pdf/src/tests/actual_text.rs +++ b/crates/pdf/src/tests/actual_text.rs @@ -121,6 +121,31 @@ fn actual_text_empty_and_nested_owners() { } } +/// Glyphs in a span whose ActualText is empty have no text, but they still draw: they stay on the +/// page as outlines in their paint, filled and stroked as the text rendering mode says (#6971). +#[test] +fn empty_actual_text_keeps_the_glyphs_art() { + let outlines = |report: &ImportReport| { + let mut found = vec![]; + report.document.walk(|node| { + if matches!(node.kind, NodeKind::Path { .. }) { + found.push((node.appearance.fill().is_some(), node.appearance.stroke().is_some(), node.geometric_bounds())); + } + }); + found + }; + for (mode, fill, stroke) in [("0", true, false), ("1", false, true), ("2", true, true)] { + let content = format!("/Span << /ActualText () >> BDC 1 0 0 rg 0 0 1 RG 3 w {mode} Tr (Halo) Tj 0 Tr EMC 0 0 0 rg (C) Tj"); + let report = open(&content); + assert_eq!(plain(&report), "C", "mode {mode}: the span adds no text"); + let found = outlines(&report); + assert!(found.iter().any(|(f, _, _)| *f == fill) && found.iter().any(|(_, s, _)| *s == stroke), "mode {mode}: the glyphs' art: {found:?}"); + let ink = found.iter().filter_map(|(_, _, b)| *b).fold(None, |acc: Option, b| Some(acc.map_or(b, |a| a.union(b)))); + let ink = ink.unwrap_or_else(|| panic!("mode {mode}: no outlines")); + assert!(ink.x0 >= 15.0 && ink.x1 > 40.0 && ink.y0 > 50.0 && ink.y1 < 140.0, "mode {mode}: where the word is set: {ink:?}"); + } +} + #[test] fn actual_text_decodes_pdf_strings_and_preserves_authored_order() { for (encoded, expected) in [ diff --git a/crates/svg/src/export/text/tests.rs b/crates/svg/src/export/text/tests.rs index fa8ca0455c..951817c2c5 100644 --- a/crates/svg/src/export/text/tests.rs +++ b/crates/svg/src/export/text/tests.rs @@ -90,6 +90,17 @@ fn japanese_type_with_mojikumi_is_laid_out_again() { assert_eq!(layouts_again(&none, true), 0); } +/// A middle dot is a character the Mojikumi sets space: type holding only middle dots is laid out +/// again with each set that spaces them. +#[test] +fn middle_dots_with_mojikumi_are_laid_out_again() { + for set in [Mojikumi::YakumonoHankaku, Mojikumi::GyomatsuYakumonoHankaku] { + let mut t = point("・・"); + t.para.mojikumi = set; + assert_eq!(layouts_again(&t, true), 1, "{set:?}"); + } +} + #[test] fn a_japanese_run_or_paragraph_lays_the_whole_object_out_again() { let mut runs = point("Latin "); diff --git a/crates/svg/tests/it/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs index 3e58b02286..8e83b6262e 100644 --- a/crates/svg/tests/it/symbols_layers.rs +++ b/crates/svg/tests/it/symbols_layers.rs @@ -115,6 +115,41 @@ fn a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it() { assert_similar(&canvas, &resvg_render(&svg, 300, 200), 24.0, 0.002); } +/// A scaled instance of a symbol whose left 9-slice guide lies outside its art is written as the +/// canvas draws it: the corner square is in the stretched center column. Reading the file back +/// gives that art and no 9-slice guides, which SVG has no place for. +#[test] +fn a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn() { + let mut d = Document::new(300.0, 200.0); + let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let rects = [(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb8(220, 220, 220)), (Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK)]; + let art: Vec> = rects.iter().map(|(r, c)| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(*r), fill(*c)))).collect(); + let group = Node::group(d.alloc_id(), art); + d.symbols.push(Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(group) }); + d.set_symbol_nine_slice("Panel", vectorcraft_doc::SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25)); + instance(&mut d, "Panel", Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5)); + let svg = export(&d, &ExportOptions::default()); + assert_eq!(svg.matches(" { wakan: Vec, /// How much narrower a line ending with each glyph is set ([`line_end_trim`]), for measuring /// where lines break; empty when the paragraph's lines are measured at full width (Mojikumi - /// None, or right-to-left text). + /// None or YakumonoZenkaku, or right-to-left text with a set that adds no space between + /// Japanese and Latin text). line_end: Vec, flex: Vec, marker: usize, @@ -234,14 +237,26 @@ fn prepare_paragraph<'a, 'text>( if para.bunri_kinshi && para.kinsoku != Kinsoku::None { keep_inseparable_pairs(text, &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![] }; + // Japanese composition (the paragraph's Mojikumi set): every punctuation mark set half width, + // or consecutive punctuation sharing one half-em space… + let rules = MojikumiRules::of(para.mojikumi); + let mut compressed = if rules.half { + halve_punctuation(&mut sg) + } else if rules.share { + compress_punctuation(&mut sg) + } else { + vec![] + }; // …and a quarter em between Japanese and Latin text. - let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; + let mut wakan = if rules.wakan { space_japanese_and_latin(&mut sg) } else { vec![] }; // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how // far a justified line may move each one from it (Dynamic Text's Block on area type sets the // Desired word spacing to its word gap). let mut flex = apply_desired(&mut sg, &t.layout_justification(para), !cx.vertical); + // The Japanese–Latin space is part of the advance Desired glyph scaling scaled. + for (w, f) in wakan.iter_mut().zip(&flex) { + *w *= f.scale; + } // Threaded text: a first paragraph that continues from the frame before has its first // line, first-line indent and list marker there. let continued = pi == 0 && t.first_para_continues; @@ -277,11 +292,9 @@ fn prepare_paragraph<'a, 'text>( pen.pending += para.space_before; } // A line fits when it fits as it is set: with its last character narrowed by Mojikumi. - let line_end = if para.mojikumi == Mojikumi::LineEndHalf && !rtl { - (0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, k)).collect() - } else { - vec![] - }; + let end_rules = rules.at_line_end(rtl); + let line_end = + if end_rules.trims_line_end() { (0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, end_rules, k)).collect() } else { vec![] }; let composed = compose_para(cx, &sg, &flex, &line_end, para, pen, rtl, continued); // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the // Hyphen Limit counts the lines in a row that end with a hyphen. @@ -369,7 +382,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC // space before it goes), and the line has that much more room. At a paragraph's start // that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①). if !*rtl - && para.mojikumi == Mojikumi::LineEndHalf + && MojikumiRules::of(para.mojikumi).flush_start && let Some(glyph) = sg.get_mut(*i) && glyph.lead <= 0.0 && let Some(half) = punct_half(glyph, Punct::Opening) @@ -536,14 +549,17 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut true } -/// Mojikumi (Line-end Punctuation Half Width): how much narrower a line ending with glyph `k` of -/// `sg` is set. A closing bracket, comma or full stop ending the line is set half width, unless it -/// already lost that half to the punctuation after it (`compressed`), and no Japanese–Latin space -/// (`wakan`) is left after the line's last character. Never more than the glyph's advance, and 0 -/// for an advance or a space that is not finite. -pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], k: usize) -> f64 { +/// Mojikumi: how much narrower a line ending with glyph `k` of `sg` is set by the paragraph's set +/// `rules`. With [`MojikumiRules::half_end`], a closing bracket, comma or full stop ending the line +/// is set half width, and with [`MojikumiRules::dot_end`] a middle dot loses the quarter em after +/// it, unless the glyph already lost that space (`compressed`); no Japanese–Latin space (`wakan`) +/// is left after the line's last character. Never more than the glyph's advance, and 0 for an +/// advance or a space that is not finite. +pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], rules: MojikumiRules, k: usize) -> f64 { let Some(g) = sg.get(k) else { return 0.0 }; - let half = if compressed.get(k).copied().unwrap_or(false) { None } else { punct_half(g, Punct::Closing) }; + let lost = compressed.get(k).copied().unwrap_or(false); + let half = (!lost && rules.half_end).then(|| punct_half(g, Punct::Closing)).flatten(); + let half = half.or_else(|| (!lost && rules.dot_end).then(|| dot_quarter(g)).flatten()); let trim = half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0); if trim.is_finite() && g.adv.is_finite() { trim.min(g.adv).max(0.0) } else { 0.0 } } @@ -563,10 +579,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData trimmed -= 1; } let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1])); + let rules = MojikumiRules::of(para.mojikumi).at_line_end(paragraph.rtl); let end_trim = trimmed .checked_sub(1) - .filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf) - .map_or(0.0, |k| line_end_trim(sg, ¶graph.compressed, ¶graph.wakan, k)); + .filter(|&k| k >= index && rules.trims_line_end()) + .map_or(0.0, |k| line_end_trim(sg, ¶graph.compressed, ¶graph.wakan, rules, k)); let measured_width = set_width(sg, index, trimmed, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim; let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte); let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte); diff --git a/crates/text/src/layout/vertical.rs b/crates/text/src/layout/vertical.rs index aea53c124b..fc6b8f1f7e 100644 --- a/crates/text/src/layout/vertical.rs +++ b/crates/text/src/layout/vertical.rs @@ -1,6 +1,6 @@ use super::flow::flow; use super::region::Region; -use super::{Affine, Ctx, Punct, Range, Rect, SGlyph, Tcy, TextLayout, TextObject, VerticalAlign, continues_cluster, is_cjk, punct}; +use super::{Affine, Ctx, Mojikumi, Punct, Range, Rect, SGlyph, Tcy, TextLayout, TextObject, VerticalAlign, continues_cluster, is_cjk, punct}; /// System Layout on or Fractional Widths off: each character that takes space advances by a whole /// number of points, at least one, so small type does not collapse onto itself. A zero advance (a @@ -126,6 +126,84 @@ pub(super) fn punct_half(g: &SGlyph, kind: Punct) -> Option { ((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.5 * g.sx) } +/// A quarter em of `g`'s size when it is a full-width Japanese middle dot (・, its advance an em, +/// give or take a tenth): the space on each side of the dot that a Mojikumi set can take off. +pub(super) fn dot_quarter(g: &SGlyph) -> Option { + if g.ch != '・' || g.tcy.is_some() || g.proportional { + return None; + } + let em = g.face.units_per_em(); + ((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.25 * g.sx) +} + +/// What a paragraph's Mojikumi set does to its characters. The five predefined sets other than +/// Line-end Punctuation Half Width follow VectorCraft's own spacing rules, built on the help +/// page's one-line description of each set +/// (). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct MojikumiRules { + /// Consecutive punctuation shares one half-em space ([`compress_punctuation`]). + pub share: bool, + /// Every opening and closing bracket, comma and full stop is set half width, and a middle dot + /// loses the quarter em on each side of it ([`halve_punctuation`]). + pub half: bool, + /// An opening bracket starting a line is set flush with the line's start. + pub flush_start: bool, + /// A closing bracket, comma or full stop ending a line is set half width. + pub half_end: bool, + /// A middle dot ending a line loses the quarter em after it. + pub dot_end: bool, + /// A quarter em between Japanese and Latin letters or digits ([`space_japanese_and_latin`]), + /// taken off again at a line's end. + pub wakan: bool, +} + +impl MojikumiRules { + pub(super) fn of(set: Mojikumi) -> Self { + let none = Self::default(); + match set { + Mojikumi::None | Mojikumi::YakumonoZenkaku => none, + Mojikumi::LineEndHalf => Self { share: true, flush_start: true, half_end: true, wakan: true, ..none }, + Mojikumi::YakumonoHankaku => Self { half: true, wakan: true, ..none }, + Mojikumi::GyomatsuYakumonoHankaku => Self { flush_start: true, half_end: true, dot_end: true, wakan: true, ..none }, + Mojikumi::GyomatsuYakumonoZenkaku => Self { wakan: true, ..none }, + } + } + + /// The rules a line's end follows: a right-to-left line keeps its punctuation's width there + /// and only loses the space between Japanese and Latin text. + pub(super) fn at_line_end(self, rtl: bool) -> Self { + if rtl { Self { half_end: false, dot_end: false, ..self } } else { self } + } + + /// Whether a line ending at some character can be set narrower than its characters' advances. + pub(super) fn trims_line_end(&self) -> bool { + self.half_end || self.dot_end || self.wakan + } +} + +/// YakumonoHankaku: every full-width opening bracket loses the half-em space before it, every +/// closing bracket, comma and full stop the half-em space after it, and every middle dot a quarter +/// em on each side. Returns which glyphs lost the space after them (a line ending in one takes +/// nothing more off). +pub(super) fn halve_punctuation(sg: &mut [SGlyph]) -> Vec { + let mut lost_after = vec![false; sg.len()]; + for (g, lost) in sg.iter_mut().zip(lost_after.iter_mut()) { + if let Some(h) = punct_half(g, Punct::Closing) { + g.adv -= h; + *lost = true; + } else if let Some(h) = punct_half(g, Punct::Opening) { + g.adv -= h; + g.lead += h; + } else if let Some(q) = dot_quarter(g) { + g.adv -= 2.0 * q; + g.lead += q; + *lost = true; + } + } + lost_after +} + /// Mojikumi (JLREQ 3.1.4): consecutive punctuation shares one half-em space. A closing bracket, /// comma or full stop followed by punctuation loses the space after it; an opening bracket after /// another loses the space before it. Returns which glyphs lost the space after them (a line @@ -154,7 +232,7 @@ pub(super) fn compress_punctuation(sg: &mut [SGlyph]) -> Vec { } /// The space between Japanese and Latin letters or digits (JLREQ 3.2.2: a quarter em of the -/// Japanese characters' size), with Line-end Punctuation Half Width. +/// Japanese characters' size), with the Mojikumi sets that add it ([`MojikumiRules::wakan`]). const WAKAN_AKI: f64 = 0.25; /// A Japanese character for the space next to Latin text: kana and kanji (and full-width @@ -174,12 +252,12 @@ fn is_japanese_letter(c: char) -> bool { } impl TextLayout { - /// Whether Line-end Punctuation Half Width (Mojikumi) can set type laid out as this layout - /// differently from Mojikumi None: `text`, the type's plain text, or one of the layout's list - /// markers holds Japanese punctuation or a Japanese letter, the only characters it spaces. - /// When it holds neither, the type lays out the same with either setting. + /// Whether a Mojikumi set can set type laid out as this layout differently from Mojikumi + /// None: `text`, the type's plain text, or one of the layout's list markers holds Japanese + /// punctuation, a middle dot or a Japanese letter, the only characters the sets space. When it + /// holds none of them, the type lays out the same with any set. pub fn mojikumi_applies(&self, text: &str) -> bool { - let spaced = |c: char| punct(c).is_some() || is_japanese_letter(c); + let spaced = |c: char| punct(c).is_some() || c == '・' || is_japanese_letter(c); text.chars().any(spaced) || self.markers.iter().any(|m| m.text.chars().any(spaced)) } } diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index a75ee8d3af..8c53e29a04 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -453,7 +453,7 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option> { pub const FULL_WIDTH_FAMILY: &str = "Fullwidthtest Sans"; /// The ideographs and Japanese punctuation [`full_width_font`] sets one em wide. -pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。"; +pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。・"; /// A test-only cmap over bundled Source Sans 3: [`FULL_WIDTH_CHARS`] use the one-em-wide glyph of /// U+2318, so Japanese composition is tested without OS fonts; Latin letters and the space keep diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index e76aee72f0..dd12804e91 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -1145,6 +1145,249 @@ fn mojikumi_line_end_half_width_counts_when_a_line_breaks() { } } +/// The predefined Mojikumi sets space punctuation by their own rules, horizontal and vertical: +/// YakumonoHankaku sets every bracket, comma and full stop half width and a middle dot a half em +/// (a quarter off each side, drawn a quarter em earlier); GyomatsuYakumonoHankaku keeps them full +/// width inside the line, sets an opening bracket starting the line flush and a closing mark ending +/// it half width, and takes the quarter em after a middle dot ending the line off; +/// GyomatsuYakumonoZenkaku and YakumonoZenkaku keep every mark full width. All but YakumonoZenkaku +/// put a quarter em between Japanese and Latin letters. Uses a test font whose ideographs and +/// punctuation are one em. +#[test] +fn mojikumi_predefined_sets_space_punctuation_by_their_rules() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::Mojikumi; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + let lay = |text: &str, m: Mojikumi, vertical_type: bool| { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + t.vertical = vertical_type; + t.para.mojikumi = m; + layout(&db, &t) + }; + let round = |v: Vec| v.into_iter().map(|x| (x * 100.0).round() / 100.0).collect::>(); + for vertical_type in [false, true] { + let adv = |text: &str, m| round(lay(text, m, vertical_type).glyphs.iter().map(|g| g.advance).collect()); + let at = |m: Mojikumi| format!("vertical {vertical_type} {m:?}"); + let middle = "一「二」三・四、五。"; + let cases = [ + (Mojikumi::None, [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0]), + (Mojikumi::YakumonoHankaku, [20.0, 10.0, 20.0, 10.0, 20.0, 10.0, 20.0, 10.0, 20.0, 10.0]), + (Mojikumi::GyomatsuYakumonoHankaku, [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 10.0]), + (Mojikumi::GyomatsuYakumonoZenkaku, [20.0; 10]), + (Mojikumi::YakumonoZenkaku, [20.0; 10]), + ]; + for (m, want) in cases { + assert_eq!(adv(middle, m), want, "{}", at(m)); + } + // Consecutive punctuation: YakumonoHankaku halves both marks, GyomatsuYakumonoHankaku + // neither (Line-end Punctuation Half Width only the first). + assert_eq!(adv("一」「二", Mojikumi::YakumonoHankaku), [20.0, 10.0, 10.0, 20.0], "vertical {vertical_type}"); + assert_eq!(adv("一」「二", Mojikumi::GyomatsuYakumonoHankaku), [20.0; 4], "vertical {vertical_type}"); + assert_eq!(adv("一」「二", Mojikumi::LineEndHalf), [20.0, 10.0, 20.0, 20.0], "vertical {vertical_type}"); + // A line's start and end: the opening bracket flush and the middle dot's outer quarter off. + assert_eq!(adv("「一・", Mojikumi::GyomatsuYakumonoHankaku), [10.0, 20.0, 15.0], "vertical {vertical_type}"); + assert_eq!(adv("「一・", Mojikumi::YakumonoHankaku), [10.0, 20.0, 10.0], "vertical {vertical_type}"); + assert_eq!(adv("「一・", Mojikumi::GyomatsuYakumonoZenkaku), [20.0; 3], "vertical {vertical_type}"); + // YakumonoHankaku draws an opening bracket half an em and a middle dot a quarter em + // earlier than its pen. + let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 }; + let (on, off) = (lay("一「二", Mojikumi::YakumonoHankaku, vertical_type), lay("一「二", Mojikumi::None, vertical_type)); + let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box()); + assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: 「 drawn half an em earlier ({moved})"); + let (on, off) = (lay("一・二", Mojikumi::YakumonoHankaku, vertical_type), lay("一・二", Mojikumi::None, vertical_type)); + let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box()); + assert!((moved - 5.0).abs() < 0.01, "vertical {vertical_type}: ・ drawn a quarter em earlier ({moved})"); + // The space between Japanese and Latin letters. + let none = adv("一A二", Mojikumi::None); + for (m, extra) in [ + (Mojikumi::YakumonoHankaku, 5.0), + (Mojikumi::GyomatsuYakumonoHankaku, 5.0), + (Mojikumi::GyomatsuYakumonoZenkaku, 5.0), + (Mojikumi::YakumonoZenkaku, 0.0), + ] { + let got = adv("一A二", m); + assert!((got[0] - none[0] - extra).abs() < 0.01 && (got[1] - none[1] - extra).abs() < 0.01, "{}: {got:?} {none:?}", at(m)); + } + } +} + +/// The predefined Mojikumi sets measure a line as they set it, with both composers, horizontal +/// and vertical: in a measure of three and a half ems, a closing bracket that fits only half width +/// ends the line with YakumonoHankaku and GyomatsuYakumonoHankaku, and goes to the next line with +/// the character before it (kinsoku) with GyomatsuYakumonoZenkaku and YakumonoZenkaku. A middle dot +/// that fits only without the quarter em after it ends the line with GyomatsuYakumonoHankaku. +#[test] +fn mojikumi_predefined_sets_count_when_a_line_breaks() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + let lay = |text: &str, m: Mojikumi, composer: Composer, vertical_type: bool, measure: f64| { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + t.vertical = vertical_type; + t.para.mojikumi = m; + t.para.composer = composer; + t.para.burasagari = Burasagari::None; + t.para.kinsoku = Kinsoku::Hard; + t.para.kinsoku_type = KinsokuShoriType::PushOutOnly; + let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, measure) } else { Rect::new(0.0, 0.0, measure, 200.0) }; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + layout(&db, &t) + }; + let first = |l: &TextLayout, text: &str| l.lines.first().map(|line| text[line.start..line.end].to_string()).unwrap_or_default(); + let widest = |l: &TextLayout| { + l.lines.iter().map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::()).fold(0.0, f64::max) + }; + for vertical_type in [false, true] { + for composer in [Composer::SingleLine, Composer::EveryLine] { + let text = "一二三」四五六"; + for (m, line) in [ + (Mojikumi::YakumonoHankaku, "一二三」"), + (Mojikumi::GyomatsuYakumonoHankaku, "一二三」"), + (Mojikumi::GyomatsuYakumonoZenkaku, "一二"), + (Mojikumi::YakumonoZenkaku, "一二"), + ] { + let l = lay(text, m, composer, vertical_type, 70.0); + assert_eq!(first(&l, text), line, "vertical {vertical_type} {composer:?} {m:?}"); + assert!(widest(&l) <= 70.0 + 1e-9, "vertical {vertical_type} {composer:?} {m:?}: {}", widest(&l)); + } + let text = "一二三・四五六"; + for (m, line) in [(Mojikumi::GyomatsuYakumonoHankaku, "一二三・"), (Mojikumi::GyomatsuYakumonoZenkaku, "一二"), (Mojikumi::None, "一二")] + { + let l = lay(text, m, composer, vertical_type, 76.0); + assert_eq!(first(&l, text), line, "vertical {vertical_type} {composer:?} {m:?}"); + assert!(widest(&l) <= 76.0 + 1e-9, "vertical {vertical_type} {composer:?} {m:?}: {}", widest(&l)); + } + } + } +} + +/// A right-to-left paragraph measures its lines at full width with every Mojikumi set, and draws +/// them as it measures them: a closing bracket ending a line keeps its full em, with both composers. +#[test] +fn mojikumi_right_to_left_lines_are_drawn_as_measured() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi, ParaDirection}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + let lay = |text: &str, m: Mojikumi, composer: Composer, measure: f64| { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + t.para.direction = Some(ParaDirection::RightToLeft); + t.para.mojikumi = m; + t.para.composer = composer; + t.para.burasagari = Burasagari::None; + t.para.kinsoku = Kinsoku::Hard; + t.para.kinsoku_type = KinsokuShoriType::PushOutOnly; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, measure, 200.0).to_path(0.1)) }; + layout(&db, &t) + }; + let text = "一二三」四五六"; + for composer in [Composer::SingleLine, Composer::EveryLine] { + for m in [Mojikumi::LineEndHalf, Mojikumi::GyomatsuYakumonoHankaku] { + let at = format!("{composer:?} {m:?}"); + // Three and a half ems: the bracket does not fit at full width, so kinsoku takes 三 on. + let narrow = lay(text, m, composer, 70.0); + let first = narrow.lines.first().map(|line| &text[line.start..line.end]); + assert_eq!(first, Some("一二"), "{at}"); + // Four and a quarter ems: the line is measured four ems wide and drawn so. + let l = lay(text, m, composer, 85.0); + let first = l.lines.first().map(|line| &text[line.start..line.end]); + assert_eq!(first, Some("一二三」"), "{at}"); + let bracket = text.find('」').and_then(|byte| l.glyphs.iter().find(|g| g.byte == byte)).map(|g| g.advance); + assert_eq!(bracket.map(|a| (a * 100.0).round() / 100.0), Some(20.0), "{at}"); + } + } +} + +/// A right-to-left line ending between a Japanese and a Latin character does not keep the quarter +/// em the set adds there: in three ems, 一二三 fits before Latin text, measured and drawn three ems +/// wide, with both composers and every set that adds the space. +#[test] +fn mojikumi_right_to_left_lines_end_without_the_japanese_latin_space() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::{Composer, Mojikumi, ParaDirection}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + let text = "一二三AB"; + for composer in [Composer::SingleLine, Composer::EveryLine] { + for m in [Mojikumi::LineEndHalf, Mojikumi::YakumonoHankaku, Mojikumi::GyomatsuYakumonoHankaku, Mojikumi::GyomatsuYakumonoZenkaku] { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + t.para.direction = Some(ParaDirection::RightToLeft); + t.para.mojikumi = m; + t.para.composer = composer; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 60.0, 200.0).to_path(0.1)) }; + let l = layout(&db, &t); + let line = l.lines.first(); + assert_eq!(line.map(|line| &text[line.start..line.end]), Some("一二三"), "{composer:?} {m:?}"); + let drawn = line.map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::()); + assert_eq!(drawn.map(|w| (w * 100.0).round() / 100.0), Some(60.0), "{composer:?} {m:?}"); + } + } +} + +/// With a Desired glyph scaling other than 100%, the space a Mojikumi set takes off before an +/// opening bracket or a middle dot scales with the glyph: the glyph is drawn that much earlier, +/// with YakumonoHankaku and with Line-end Punctuation Half Width's consecutive brackets. +#[test] +fn mojikumi_space_taken_off_scales_with_desired_glyph_scaling() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::Mojikumi; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + let lay = |text: &str, m: Mojikumi| { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + t.para.mojikumi = m; + t.para.justification.glyph_scaling = [50.0, 50.0, 100.0]; + layout(&db, &t) + }; + for (text, m, k, want) in + [("一「二", Mojikumi::YakumonoHankaku, 1, 5.0), ("一・二", Mojikumi::YakumonoHankaku, 1, 2.5), ("一「「二", Mojikumi::LineEndHalf, 2, 5.0)] + { + let (on, off) = (lay(text, m), lay(text, Mojikumi::None)); + let pen = |l: &TextLayout| l.glyphs[..k].iter().map(|g| g.advance).sum::(); + let drawn = |l: &TextLayout| l.glyphs[k].outline.bounding_box().x0 - pen(l); + let moved = drawn(&off) - drawn(&on); + assert!((moved - want).abs() < 0.01, "{text} {m:?}: drawn {moved} earlier, want {want}"); + } +} + +/// With a Desired glyph scaling other than 100%, a line ending before Latin text is measured +/// without the scaled quarter em the set added there, not an unscaled one: at 50%, 一二三 is 30 pt +/// wide and does not fit 29 pt, with both composers and every set that adds the space. +#[test] +fn mojikumi_line_end_japanese_latin_space_scales_with_desired_glyph_scaling() { + use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font}; + use vectorcraft_doc::{Composer, Mojikumi}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(full_width_font().expect("test font")); + let text = "一二三AB"; + for composer in [Composer::SingleLine, Composer::EveryLine] { + for m in [Mojikumi::LineEndHalf, Mojikumi::YakumonoHankaku, Mojikumi::GyomatsuYakumonoHankaku, Mojikumi::GyomatsuYakumonoZenkaku] { + let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, style); + t.xf = Affine::IDENTITY; + t.para.mojikumi = m; + t.para.composer = composer; + t.para.justification.glyph_scaling = [50.0, 50.0, 100.0]; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 29.0, 200.0).to_path(0.1)) }; + let l = layout(&db, &t); + let first = l.lines.first().map(|line| &text[line.start..line.end]); + assert_eq!(first, Some("一二"), "{composer:?} {m:?}"); + } + } +} + /// Mojikumi: in a frame that is narrow at the top, and in a frame a wrap object narrows at the top, /// a line's first word is measured as the line sets it. A word ending with a closing bracket that /// fits the narrow lines only with the bracket half width, and an opening bracket that fits them diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index a807cbb8fa..7b778498e6 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -9,8 +9,8 @@ mod solid; mod visibility; pub use solid::{ - Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, View, extrude, extrude_face_map, inflate, inflate_together, plane, - plane_face_map, + Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, inflate_each, + inflate_together, plane, plane_face_map, }; pub use visibility::{VisibleFace, visible_faces}; mod shading; @@ -32,6 +32,9 @@ const MAX_FACES: usize = 32768; const MAX_REVOLVE_FACES: usize = 163_840; /// Most faces Extrude & Bevel and Inflate build for one solid, as many as Revolve's. const MAX_SOLID_FACES: usize = MAX_REVOLVE_FACES; +/// Most faces the Extrude & Bevel or Inflate solids of one effect on one object keep together: the +/// solid of its fill and those of its strokes, a group's members' all counted, as many as one solid's. +pub const MAX_OBJECT_FACES: usize = MAX_SOLID_FACES; /// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. const CURVE_SAMPLES: usize = 16; const MIN_CURVE_SAMPLES: usize = 4; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index bfd3149e6e..f3da6f866f 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -30,9 +30,10 @@ use inflate::GRID_WORK; #[cfg(test)] pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; -pub use inflate::{inflate, inflate_together}; +pub use inflate::{inflate, inflate_each, inflate_together}; #[cfg(test)] use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; +pub use rings::TOO_COMPLEX_SURFACE as TOO_COMPLEX_SOLID; use rings::signed_area; #[cfg(test)] use rings::{MAX_SOLID_POINTS, rings}; diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 47ce3371db..d3773a92f9 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -62,7 +62,18 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, /// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest /// puff needs with Perspective. One result for each shape, in order. pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)).collect() + inflate_each(shapes, bounds, options, view).collect() +} + +/// [`inflate_together`], each solid built only as the iterator reaches it: a caller that stops early +/// builds none of the solids after. +pub fn inflate_each( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, +) -> impl Iterator, String>> + use<> { + inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)) } /// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 7aed5e4191..80dc66407c 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -17,7 +17,9 @@ pub(super) const CURVE_PIECES: u32 = 32; pub(super) const MIN_CURVE_PIECES: u32 = 4; const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect"; -pub(super) const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape"; +/// The error a solid past its limits returns, and a 3D effect whose solids together pass +/// [`crate::MAX_OBJECT_FACES`]. +pub const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape"; /// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from /// its chords), 1 to [`CURVE_PIECES`]. diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index 6a287b9b54..cd72f1dd40 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -573,12 +573,15 @@ impl Tool for SmartPenTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = vec![]; - if cx.pen_rubber_band - && self.press.is_none() + // The rubber band preference hides only the preview segment; Measurement Labels decides the readout. + if self.press.is_none() && let (Some((id, _, sp)), Some(h)) = (self.active(cx), self.hover) - && let Some(path) = self.rubber_band(sp, h) { - o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); + if cx.pen_rubber_band + && let Some(path) = self.rubber_band(sp, h) + { + o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); + } if let Some(last) = sp.anchors.last().map(|a| a.p) && cx.measurement_labels && last.distance(h) > 1e-9 diff --git a/crates/tools/src/addons/smart_pen/tests.rs b/crates/tools/src/addons/smart_pen/tests.rs index a7f792c22f..8bcc907f9d 100644 --- a/crates/tools/src/addons/smart_pen/tests.rs +++ b/crates/tools/src/addons/smart_pen/tests.rs @@ -315,6 +315,35 @@ fn the_rubber_band_shows_the_next_segment_and_ending_the_path() { assert!(t.overlays(&cx).is_empty()); } +/// With Enable Rubber Band for the Pen off, the preview segment is hidden but Measurement Labels +/// still shows the readout from the last anchor to the pointer. +#[test] +fn the_measurement_label_shows_with_the_rubber_band_off() { + let (d, _, s) = doc(line()); + let p = paint(); + let on = plain(&d, &s, &p); + let off = ToolContext { pen_rubber_band: false, ..plain(&d, &s, &p) }; + let (from, to) = (Point::new(200.0, 100.0), Point::new(300.0, 160.0)); + let measure = + |o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::>(); + let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. })); + let mut t = drawing(&off, from); + t.pointer(&off, &ev(PointerKind::Move, to.x, to.y, NO)); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!band(&o), "{o:?}"); + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(band(&o), "{o:?}"); + for rubber in [true, false] { + let quiet = ToolContext { pen_rubber_band: rubber, measurement_labels: false, ..plain(&d, &s, &p) }; + assert!(measure(&t.overlays(&quiet)).is_empty()); + } + // Hovering exactly on the last anchor measures no segment. + t.pointer(&off, &ev(PointerKind::Move, from.x, from.y, NO)); + assert!(measure(&t.overlays(&off)).is_empty()); +} + #[test] fn an_alt_drag_away_from_paths_selects_the_points_inside_a_marquee() { let (d, id, s) = doc(SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)], false)); diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 292f1d25e8..3addd48785 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -28,7 +28,7 @@ //! Direct Selection also edits a blend's spine: drag its points (Shift-click picks several, which //! drag together; a key object on a point moves with it) and, once a point is clicked, its //! handles; and the points of selected gradient meshes -//! and mesh envelopes and their handles ([`MeshEdit`]): Shift-clicks and the marquee pick several, +//! and of mesh and warp envelopes and their handles ([`MeshEdit`]): Shift-clicks and the marquee pick several, //! a click on a gradient mesh's line picks its two ends and one inside a patch the four points //! around it, a drag of picked points moves them all, the arrow keys nudge them, and a solid fill //! colour or the Transparency panel's opacity goes to a gradient mesh's picked points. Dragging a diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index c40607ee63..02087d997a 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -718,38 +718,44 @@ impl Tool for CurvatureTool { } fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = self.snap.guides().to_vec(); - let band = cx.curvature_rubber_band && self.drag.is_none(); + // The rubber band preference hides only the preview curve; Measurement Labels decides the readout. + let idle = self.drag.is_none(); + let band = cx.curvature_rubber_band; let mut bp = BezPath::new(); if self.active(cx).is_none() { // The curve on from the current end of a selected path: its last segment and the next. - if band + if idle && let (Some(e), Some(h)) = (current_end(cx), self.hover) && let Some(sp) = subpath(cx, e.id, e.si) { - let n = sp.anchors.len(); - let near = if e.start { - sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect() - } else { - sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect() - }; - let mut tail = SubPath::new(near, false); - curvature_extend(&mut tail, false, h, self.how(&e), false); - tail.to_bezpath_into(&mut bp); - o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + if band { + let n = sp.anchors.len(); + let near = if e.start { + sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect() + } else { + sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect() + }; + let mut tail = SubPath::new(near, false); + curvature_extend(&mut tail, false, h, self.how(&e), false); + tail.to_bezpath_into(&mut bp); + o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + } if cx.measurement_labels && e.p.distance(h) > 1e-9 { o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(e.p, h) }); } } return o; } - if band + if idle && !self.closed && let Some(h) = self.hover { - let mut pts = self.pts.clone(); - pts.push((h, false)); - curvature_spline(&pts, false).to_bezpath_into(&mut bp); - o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + if band { + let mut pts = self.pts.clone(); + pts.push((h, false)); + curvature_spline(&pts, false).to_bezpath_into(&mut bp); + o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false }); + } if let Some((from, _)) = self.pts.last() && cx.measurement_labels && from.distance(h) > 1e-9 diff --git a/crates/tools/src/draw2/curvature/tests.rs b/crates/tools/src/draw2/curvature/tests.rs index f21deec016..50ef2281d1 100644 --- a/crates/tools/src/draw2/curvature/tests.rs +++ b/crates/tools/src/draw2/curvature/tests.rs @@ -539,3 +539,51 @@ fn clicks_go_on_from_a_current_end_and_the_other_end_closes() { let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false); assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}"); } + +/// With Enable Rubber Band for the Curvature tool off, the preview curve is hidden but +/// Measurement Labels still shows the readout, both while drawing a path and going on from the +/// current end of a selected one. +#[test] +fn the_measurement_label_shows_with_the_rubber_band_off() { + let p = paint(); + let measure = + |o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::>(); + let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. })); + let to = pt(650.0, 60.0); + // Going on from the current end of a selected path. + let (d, id) = doc_with(wave()); + let s = selected(id, Some(4)); + let on = quiet(&d, &s, &p); + let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) }; + let mut t = CurvatureTool::default(); + t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y)); + let from = pt(450.0, 60.0); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!band(&o), "{o:?}"); + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(band(&o), "{o:?}"); + // A path being drawn. + let (d, id) = doc_with(SubPath::polyline(&[pt(100.0, 100.0), pt(200.0, 150.0)], false)); + let s = selected(id, None); + let on = quiet(&d, &s, &p); + let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) }; + let mut t = CurvatureTool { id: Some(id), pts: vec![(pt(100.0, 100.0), false), (pt(200.0, 150.0), false)], ..CurvatureTool::default() }; + assert!(t.active(&off).is_some()); + t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y)); + let from = pt(200.0, 150.0); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!band(&o), "{o:?}"); + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(band(&o), "{o:?}"); + for rubber in [true, false] { + let unlabeled = ToolContext { curvature_rubber_band: rubber, measurement_labels: false, ..quiet(&d, &s, &p) }; + assert!(measure(&t.overlays(&unlabeled)).is_empty()); + } + // Hovering exactly on the last point measures no segment. + t.pointer(&off, &PointerEvent::new(PointerKind::Move, from.x, from.y)); + assert!(measure(&t.overlays(&off)).is_empty()); +} diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index d61eb95f5f..21ee30eed6 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -21,7 +21,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::Point; use crate::blendwidget::{self, BlendWidgetDrag}; -use crate::meshedit::{MeshEdit, grid_of}; +use crate::meshedit::{MeshEdit, grid_of, may_have_grid}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; @@ -246,13 +246,13 @@ pub struct MeshTool { } impl MeshTool { - /// Meshes (gradient meshes and mesh envelopes) to consider for point hits: selected ones - /// first, then any in the document. + /// Meshes (gradient meshes, and mesh and warp envelopes) to consider for point hits: selected + /// ones first, then any in the document. fn meshes(cx: &ToolContext) -> Vec { - let mut v: Vec = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).and_then(grid_of).is_some()).collect(); + let mut v: Vec = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(may_have_grid)).collect(); let mut rest = vec![]; cx.doc.walk(|n| { - if grid_of(n).is_some() { + if may_have_grid(n) { rest.push(n.id); } }); diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index c0c1d96231..1069c90eb5 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -1,5 +1,5 @@ //! Picking and dragging mesh points and their handles on the canvas, shared by the Mesh tool and -//! Direct Selection: gradient meshes and mesh envelopes alike. A click on a point picks it alone +//! Direct Selection: gradient meshes, mesh envelopes and warp envelopes alike. A click on a point picks it alone //! and focuses it: its handles show and can be dragged too. Shift-clicks add points to the picked //! ones or take them away, and Direct Selection's marquee and its clicks on a gradient mesh's line //! or patch pick points too ([`part_points`]). The picked points show filled; a drag of one of @@ -11,7 +11,7 @@ use std::borrow::Cow; use std::collections::BTreeSet; use serde_json::{Value, json}; -use vectorcraft_doc::live::{GradientMesh, envelope_grid}; +use vectorcraft_doc::live::{GradientMesh, envelope_grid, warp_grid}; use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind}; use vectorcraft_geom::{Point, Vec2}; @@ -19,19 +19,30 @@ use crate::{Action, Mods, Overlay, ToolContext, ToolKey}; const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; -/// The editable grid of `n`: a gradient mesh, or a mesh envelope's grid (not while its contents -/// are being edited). +/// The editable grid of `n`: a gradient mesh, a mesh envelope's grid or a warp envelope's +/// ([`warp_grid`], which its first edit makes a mesh envelope of); not while an envelope's contents +/// are being edited. pub fn grid_of(n: &Node) -> Option> { match &n.kind { NodeKind::Mesh(m) => Some(Cow::Borrowed(m)), NodeKind::Envelope { kind, editing: false, .. } => match &**kind { EnvelopeKind::Mesh { rows, cols, points, handles } => envelope_grid(*rows, *cols, points, handles).map(Cow::Owned), - _ => None, + EnvelopeKind::Warp { .. } => warp_grid(n).map(Cow::Owned), + EnvelopeKind::TopObject { .. } => None, }, _ => None, } } +/// Can `n` have an editable grid ([`grid_of`])? Its kind alone answers, without building the grid. +pub fn may_have_grid(n: &Node) -> bool { + match &n.kind { + NodeKind::Mesh(_) => true, + NodeKind::Envelope { kind, editing: false, .. } => matches!(**kind, EnvelopeKind::Mesh { .. } | EnvelopeKind::Warp { .. }), + _ => false, + } +} + /// The points of gradient mesh `m` a Direct Selection click at `p` picks: the point within /// `point_tol`, else the two ends of the mesh line within `line_tol`, else the four points around /// the patch under `p`. @@ -433,6 +444,55 @@ mod tests { assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Commit]); } + /// The rectangle of [`doc_with_rect`] in an arc warp envelope. + fn doc_with_warp() -> (Document, NodeId) { + let (mut d, e) = doc_with_envelope(); + if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) { + **kind = + EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET }; + } + (d, e) + } + + /// The Mesh tool and Direct Selection drag a warp envelope's points (on its grid) and the + /// clicked point's handles, as they do a mesh envelope's; while its contents are edited, its + /// points take no press. + #[test] + fn the_mesh_tool_and_direct_selection_drag_warp_envelope_points() { + let (mut d, e) = doc_with_warp(); + let grid = grid_of(d.node(e).unwrap()).unwrap().into_owned(); + assert_eq!(Some(&grid), warp_grid(d.node(e).unwrap()).as_ref()); + let mut s = Selection::default(); + s.set([e]); + let p = paint(); + let c = cx(&d, &s, &p); + let tools: [(&str, Box); 2] = + [("mesh", crate::meshblend::create("mesh").unwrap()), ("directSelection", Box::new(crate::direct::DirectSelectionTool::new(false)))]; + let (i, q) = (6, grid.points[6].clone()); + for (what, mut t) in tools { + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, q.p.x, q.p.y)), vec![Action::Begin("Move Mesh Point".into())], "{what}"); + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, q.p.x + 10.0, q.p.y - 10.0)), + vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": i, "x": q.p.x + 10.0, "y": q.p.y - 10.0}))], + "{what}" + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, q.p.x + 10.0, q.p.y - 10.0)), vec![Action::Commit], "{what}"); + // The clicked point's handle. + let end = q.p + q.handles[0]; + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, end.x, end.y)), vec![Action::Begin("Move Mesh Point".into())], "{what}"); + let drag = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, end.x + 5.0, end.y)); + assert!(matches!(&drag[..], [Action::Preview(id, p)] if id == "object.mesh.movePoint" && p["handle"] == json!(0)), "{what}: {drag:?}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, end.x + 5.0, end.y)); + } + if let Some(NodeKind::Envelope { editing, .. }) = d.node_mut(e).map(|n| &mut n.kind) { + *editing = true; + } + assert!(grid_of(d.node(e).unwrap()).is_none(), "Edit Contents: no envelope points"); + let c = cx(&d, &s, &p); + let mut t = crate::meshblend::create("mesh").unwrap(); + assert_ne!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, q.p.x, q.p.y)), vec![Action::Begin("Move Mesh Point".into())]); + } + #[test] fn mesh_drag_honors_measurement_labels() { let (d, _e) = doc_with_envelope(); diff --git a/crates/tools/src/nineslice.rs b/crates/tools/src/nineslice.rs index 61de9e9580..74f40b6bbf 100644 --- a/crates/tools/src/nineslice.rs +++ b/crates/tools/src/nineslice.rs @@ -5,10 +5,15 @@ //! takes the Move cursor, and dragging the guide moves it (`symbol.options`'s //! `nineSliceGuides`), in one undo step. //! -//! VectorCraft's own rules: a guide stays within the art's box and stops short of the opposite -//! guide; the drag does not snap and its modifiers change nothing; a guide within reach takes the -//! press before the art and the bounding box, and of two guides within reach the nearer one, else -//! the first of left, top, right and bottom; Escape during the drag puts the guide back. +//! A guide may be dragged past the art's edges, and the instance still scales by the slices its +//! art falls in. +//! +//! VectorCraft's own rules: a guide stays within 16 times the art's width or height of its edge +//! and stops short of the opposite guide; a guide outside the art is drawn and taken along the +//! art's height or width beside it; the drag does not snap and its modifiers change nothing; a +//! guide within reach takes the press before the art and the bounding box, and of two guides +//! within reach the nearer one, else the first of left, top, right and bottom; Escape during the +//! drag puts the guide back. use serde_json::json; use vectorcraft_doc::{EditedNineSlice, NineSliceGuide}; @@ -203,6 +208,36 @@ mod tests { assert_eq!(preview(&a[1]).1["nineSliceGuides"], json!([0.25, 0.4, 0.25, 0.25])); } + /// A guide outside the art is taken beside the art, along its height, and a drag may take a + /// guide past the art's edge and back; Escape puts it back where it was. + #[test] + fn a_guide_outside_the_art_is_taken_and_dragged_past_the_arts_edge() { + let d = editing(SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25)); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let at = |x: f64, y: f64| guide_at(&c, Point::new(x, y)).map(|(_, g)| g); + assert_eq!(at(75.0, 150.0), Some(NineSliceGuide::Left), "25 % left of the art at x = 100"); + assert_eq!(at(77.0, 110.0), Some(NineSliceGuide::Left)); + assert_eq!(at(75.0, 95.0), None, "the guide ends with the art's height"); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&c, Point::new(75.0, 150.0), Mods::default()), Cursor::Move); + t.pointer(&c, &ev(PointerKind::Down, 75.0, 150.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 50.0, 150.0)); + assert_eq!(a[0], Action::Begin("Symbol Options".into())); + assert_eq!(preview(&a[1]).1["nineSliceGuides"], json!([-0.5, 0.25, 0.25, 0.25])); + let (_, v) = preview(&t.pointer(&c, &ev(PointerKind::Drag, 110.0, 150.0))[0]); + assert_eq!(v["nineSliceGuides"], json!([0.1, 0.25, 0.25, 0.25]), "back inside the art"); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + // From inside the art, the default bottom guide passes the art's bottom edge at y = 200. + let d = editing(Some(SymbolNineSlice::default())); + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 175.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 150.0, 250.0)); + assert_eq!(preview(&a[1]).1["nineSliceGuides"], json!([0.25, 0.25, 0.25, -0.5])); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 150.0, 250.0)), vec![Action::Commit]); + } + /// Escape during a guide drag cancels it and is the tool's; the release after it commits /// nothing. A double-click on a guide neither edits the art nor leaves the mode. #[test] diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 0b625f2813..a0ab57ae87 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -495,19 +495,21 @@ impl Tool for PenTool { return t.overlays(cx); } let mut o = vec![]; - if cx.pen_rubber_band - && self.drag.is_none() + // The rubber band preference hides only the preview segment; Measurement Labels decides the readout. + if self.drag.is_none() && self.handle.is_none() && let (Some((id, _, _, last, out)), Some(h)) = (self.active(cx), self.hover) { - let mut bp = BezPath::new(); - bp.move_to(last); - if out.distance(last) > 1e-9 { - bp.quad_to(out, h); - } else { - bp.line_to(h); + if cx.pen_rubber_band { + let mut bp = BezPath::new(); + bp.move_to(last); + if out.distance(last) > 1e-9 { + bp.quad_to(out, h); + } else { + bp.line_to(h); + } + o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); } - o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false }); if cx.measurement_labels && last.distance(h) > 1e-9 { o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(last, h) }); } diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 478589fe9e..32c91318e6 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -773,6 +773,48 @@ fn anchors_snap_to_smart_guides() { assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]}))); } +/// With Enable Rubber Band off, the preview segment is hidden but Measurement Labels still shows +/// the D / Angle / dX / dY readout at the snapped point before the next click. +#[test] +fn measurement_label_shows_with_the_rubber_band_off() { + let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false)); + let p = paint(); + let on = cx(&d, &s, &p); + let off = ToolContext { pen_rubber_band: false, ..cx(&d, &s, &p) }; + let from = Point::new(60.0, 300.0); + let to = Point::new(150.0, 318.0); + let measure = + |o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::>(); + let preview = |o: &[Overlay]| { + o.iter().any(|o| matches!(o, Overlay::Path { path, .. } if path.elements().first().and_then(|e| e.end_point()) == Some(from))) + }; + let mut t = PenTool { drawing: true, ..PenTool::default() }; + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); + let o = t.overlays(&off); + assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}"); + assert!(!preview(&o), "{o:?}"); + // The rubber band on still shows both. + let o = t.overlays(&on); + assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}"); + assert!(preview(&o), "{o:?}"); + // Measurement Labels off shows no readout either way. + for band in [true, false] { + let quiet = ToolContext { pen_rubber_band: band, measurement_labels: false, ..cx(&d, &s, &p) }; + assert!(measure(&t.overlays(&quiet)).is_empty()); + } + // The click places the same anchor as with the rubber band on. + let a = click(&mut t, &off, 152.0, 318.0, Mods::default()); + assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0, "subpath": 0}))); + // Hovering exactly on the last anchor measures no segment. + let mut t = PenTool { drawing: true, ..PenTool::default() }; + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 60.0, 300.0)); + assert!(measure(&t.overlays(&off)).is_empty()); + // With no path being drawn there is nothing to measure from. + let mut t = PenTool::default(); + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0)); + assert!(measure(&t.overlays(&off)).is_empty()); +} + #[test] fn alt_over_a_shown_handle_or_an_anchor_converts() { // The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100). diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index d0d624bbeb..05b81ae049 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1383,9 +1383,7 @@ pub(crate) fn release_guide_at(cx: &ToolContext, p: Point, m: Mods) -> Option Cursor { - let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 }; - let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h); - match h { + match screen_handle(h, angle) { Handle::Top | Handle::Bottom => Cursor::ResizeV, Handle::Left | Handle::Right => Cursor::ResizeH, Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe, @@ -1393,6 +1391,13 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor { } } +/// The handle of an upright box that sits where handle `h` of a box turned by `angle` +/// (counter-clockwise degrees) appears, in 45° steps. +pub(crate) fn screen_handle(h: Handle, angle: f64) -> Handle { + let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 }; + Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h) +} + #[cfg(test)] mod tests; diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 0f111faea2..bd7c0c0ecd 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -4,15 +4,17 @@ //! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle //! shears along that side. A scale snaps as the Selection tool's does. The Perspective Distort and Free Distort modes (tool option `mode`, or //! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the -//! box and preview `object.distort`. +//! box and preview `object.distort`. After a rotation the box stays turned with the selection, as +//! the Selection tool's does: its handles scale, shear and distort along the selection's own sides. use serde_json::{Value, json}; +use vectorcraft_doc::OrientedBox; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::{BLUE, polygon, rect_corners}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::guides::{PointSnap, Targets}; -use crate::select::{matrix_json, selection_bounds}; +use crate::select::{handle_cursor, matrix_json, screen_handle, selection_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -69,7 +71,8 @@ impl Op { #[derive(Clone, Copy, Debug)] struct Drag { op: Op, - rect: Rect, + /// The selection's bounding box at the press, turned with the selection. + bx: OrientedBox, start: Point, began: bool, } @@ -170,28 +173,37 @@ impl FreeTransformTool { } } - fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option { - let tol = cx.tol(5.0); - if let Some(h) = hit_handle(r, p, tol) { + /// What a press at `p` on box `bx` does, read in the box's own frame. + fn classify(&self, cx: &ToolContext, bx: &OrientedBox, p: Point, m: Mods) -> Option { + let (tol, r, lp) = (cx.tol(5.0), bx.rect, bx.to_local(p)); + if let Some(h) = hit_handle(r, lp, tol) { return Some(self.handle_op(h, m)); } - if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { + if self.mode == DistortMode::Free && in_rotate_zone(r, lp, tol, cx.tol(18.0)).is_some() { return Some(Op::Rotate); } - r.contains(p).then_some(Op::Move) + r.contains(lp).then_some(Op::Move) } fn preview(&mut self, cx: &ToolContext, d: Drag, p: Point, m: Mods) -> Action { let shift = m.shift || self.constrain; + let bx = d.bx; let delta = p - d.start; + // The drag along the box's own sides. + let local = bx.to_local(p) - bx.to_local(d.start); self.guides.clear(); let xf = match d.op { Op::Corners(h, mode) => { - let q = distort_quad(d.rect, h, delta, mode); + let q = distort_quad(bx.rect, h, local, mode).map(|c| bx.to_doc() * c); self.quad = Some(q); self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(delta.x, delta.y))); let corners: Vec = q.iter().map(|c| json!([c.x, c.y])).collect(); - return Action::Preview("object.distort".into(), json!({ "corners": corners, "from": [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1] })); + let r = bx.rect; + let mut params = json!({ "corners": corners, "from": [r.x0, r.y0, r.x1, r.y1] }); + if bx.angle != 0.0 { + params["angle"] = json!(bx.angle); + } + return Action::Preview("object.distort".into(), params); } Op::Move => { let v = move_delta(d.start, p, shift, cx.constrain_angle); @@ -199,22 +211,23 @@ impl FreeTransformTool { Affine::translate(v) } Op::Scale(h) => { - let mut a = scale_for_drag(d.rect, h, p, shift, m.alt); + // Scaled in the box's own frame, then turned back onto the page. + let mut a = scale_for_drag(bx.rect, h, bx.to_local(p), shift, m.alt); if let Some(t) = &self.targets { - (a, self.guides) = t.snap_scale(cx, &vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt); + (a, self.guides) = t.snap_scale(cx, &bx, h, a, shift, m.alt); } - let nr = a.transform_rect_bbox(d.rect); + let nr = a.transform_rect_bbox(bx.rect); self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height()))); - a + bx.conjugate(a) } - Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt), + Op::Shear(h) => bx.conjugate(shear_for_side(bx.rect, h, local, m.alt)), Op::Rotate => { - let (a, deg) = rotate_for_drag(d.rect.center(), d.start, p, shift); + let (a, deg) = rotate_for_drag(bx.center(), d.start, p, shift); self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg))); a } }; - self.quad = Some(rect_corners(d.rect).map(|c| xf * c)); + self.quad = Some(bx.corners().map(|c| xf * c)); Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(xf), "copy": false })) } } @@ -233,10 +246,10 @@ impl Tool for FreeTransformTool { match ev.kind { PointerKind::Down => { self.drag = None; - let Some(r) = selection_bounds(cx) else { return vec![] }; - if let Some(op) = self.classify(cx, r, p, ev.mods) { + let Some(bx) = selection_box(cx) else { return vec![] }; + if let Some(op) = self.classify(cx, &bx, p, ev.mods) { self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::for_scale(cx))); - self.drag = Some(Drag { op, rect: r, start: p, began: false }); + self.drag = Some(Drag { op, bx, start: p, began: false }); } vec![] } @@ -292,10 +305,12 @@ impl Tool for FreeTransformTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = vec![]; - let quad = match (self.quad, selection_bounds(cx)) { - (Some(q), _) => q, - (None, Some(r)) => rect_corners(r), - _ => return o, + let quad = match self.quad { + Some(q) => q, + None => match selection_box(cx) { + Some(bx) => bx.corners(), + None => return o, + }, }; o.push(Overlay::Path { path: polygon(&quad, true), color: BLUE, width: 1.0, dashed: false }); for i in 0..4 { @@ -313,19 +328,17 @@ impl Tool for FreeTransformTool { } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { - let Some(r) = selection_bounds(cx) else { return Cursor::Arrow }; - match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) { - Some(Op::Rotate) => Cursor::rotate_about_in_view(r.center(), p, cx.view_angle()), + let Some(bx) = self.drag.map(|d| d.bx).or_else(|| selection_box(cx)) else { return Cursor::Arrow }; + // The box's angle as it shows on screen. + let shown = bx.angle - cx.view_angle(); + match self.drag.map(|d| d.op).or_else(|| self.classify(cx, &bx, p, m)) { + Some(Op::Rotate) => Cursor::rotate_about_in_view(bx.center(), p, cx.view_angle()), Some(Op::Move) => Cursor::Move, - Some(Op::Shear(Handle::Top | Handle::Bottom)) => Cursor::ShearH, - Some(Op::Shear(_)) => Cursor::ShearV, - Some(Op::Scale(h)) => match h { - Handle::Top | Handle::Bottom => Cursor::ResizeV, - Handle::Left | Handle::Right => Cursor::ResizeH, - Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe, - Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw, - } - .turned(cx.view_angle()), + Some(Op::Shear(h)) => match screen_handle(h, shown) { + Handle::Top | Handle::Bottom | Handle::TopLeft | Handle::BottomRight => Cursor::ShearH, + _ => Cursor::ShearV, + }, + Some(Op::Scale(h)) => handle_cursor(h, shown), Some(Op::Corners(..)) => Cursor::Crosshair, None => Cursor::Arrow, } @@ -422,6 +435,43 @@ mod tests { assert_eq!(t.cursor(&c, Point::new(207.0, 93.0), Mods::default()), Cursor::RotateToward(3), "out from the top right"); } + /// After a 30° turn the box, its handles and their cursors turn with the selection, and a side + /// handle scales along the selection's own width. + #[test] + fn the_box_and_its_handles_turn_with_the_selection() { + let (mut d, id) = doc_with_rect(); + let c = Vec2::new(150.0, 150.0); + let turn = Affine::translate(c) * Affine::rotate(-30f64.to_radians()) * Affine::translate(-c); + d.node_mut(id).unwrap().transform(turn, false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let bx = selection_box(&cx).unwrap(); + assert!((bx.angle - 30.0).abs() < 1e-9, "{bx:?}"); + let mut t = FreeTransformTool::default(); + let shown: Vec = t.overlays(&cx).iter().filter_map(|o| if let Overlay::Anchor { p, .. } = o { Some(*p) } else { None }).collect(); + for corner in bx.corners() { + assert!(shown.iter().any(|q| q.distance(corner) < 1e-9), "{corner:?} not in {shown:?}"); + } + let top = bx.to_doc() * Handle::Top.pos(bx.rect); + assert_eq!(t.cursor(&cx, top, Mods::default()), Cursor::ResizeNwSe, "the top handle, turned 30°"); + // The Right handle dragged 100 pt out along the selection's own x axis doubles its width. + let right = bx.to_doc() * Handle::Right.pos(bx.rect); + let to = bx.to_doc() * (Handle::Right.pos(bx.rect) + Vec2::new(100.0, 0.0)); + t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y)); + assert_eq!(a[0], Action::Begin("Scale".into())); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]); + let local = bx.to_doc().inverse() * m * bx.to_doc(); + let want = Affine::translate((bx.rect.x0, 0.0)) * Affine::scale_non_uniform(2.0, 1.0) * Affine::translate((-bx.rect.x0, 0.0)); + for (got, want) in local.as_coeffs().iter().zip(want.as_coeffs()) { + assert!((got - want).abs() < 1e-9, "{local:?}"); + } + } + #[test] fn cmd_side_handle_shears() { let (d, id) = doc_with_rect(); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 8e30081d7f..2ea7ac8079 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -68,6 +68,7 @@ rustix = { workspace = true, features = ["std", "process"] } [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +vectorcraft-apischema = { workspace = true } vectorcraft-testkit = { workspace = true } # A variable font built in code (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index c418f1950c..1d2493385e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -416,6 +416,10 @@ impl VectorcraftApp { self.pen_end.forget_tool(); self.held_tool.clear(); self.activate_tool(id, true); + let now = self.session.tool_id(); + if crate::touch::is_drawing_tool(now) { + self.ui.touch_drawing_tool = Some(now.to_string()); + } } /// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]): diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 04a9f2e4c8..8597607717 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -966,6 +966,88 @@ fn two_finger_tap_undoes_and_three_finger_tap_redoes() { assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone"); } +/// In the Touch workspace a two-finger tap switches between the Selection tool and the drawing tool +/// chosen last, and undoes nothing; three fingers still redo. Outside it, two fingers undo again. +#[test] +fn two_finger_tap_in_touch_switches_selection_and_drawing_modes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap(); + crate::workspaces::switch(&mut app.ui, crate::workspaces::TOUCH).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len(); + let c = app.canvas_rect.unwrap().center(); + let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let tap = |app: &mut VectorcraftApp, fingers: u64| { + let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0); + frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]); + frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]); + frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect()); + frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]); + frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect()); + frame(app, &ctx, vec![]); + }; + tap(&mut app, 2); + assert_eq!((app.session.tool_id(), count(&app)), ("selection", 1), "selection mode, nothing undone or drawn"); + tap(&mut app, 2); + assert_eq!((app.session.tool_id(), count(&app)), ("rectangle", 1), "back to the drawing tool"); + // A tool chosen by hand becomes the drawing tool a switch returns to. + app.select_tool("ellipse"); + tap(&mut app, 2); + tap(&mut app, 2); + assert_eq!((app.session.tool_id(), count(&app)), ("ellipse", 1)); + // The switches added no undo step: undo still takes the rectangle away, and three fingers redo it. + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(count(&app), 0); + tap(&mut app, 3); + assert_eq!(count(&app), 1, "three fingers redo in Touch too"); + // Presentation Mode runs neither. + app.ui.screen_mode = 3; + frame(&mut app, &ctx, vec![]); + tap(&mut app, 2); + assert_eq!(app.session.tool_id(), "ellipse", "Presentation Mode: no switch"); + app.ui.screen_mode = 0; + // Enable Touch Gestures off: no switch. + app.session.prefs.touch_gestures = false; + app.select_tool("selection"); + frame(&mut app, &ctx, vec![]); + tap(&mut app, 2); + assert_eq!(app.session.tool_id(), "selection", "off: no switch"); + app.session.prefs.touch_gestures = true; + // Another workspace: two fingers undo again and switch no tool. + crate::workspaces::switch(&mut app.ui, crate::workspaces::ESSENTIALS).unwrap(); + app.select_tool("rectangle"); + frame(&mut app, &ctx, vec![]); + let before = count(&app); + tap(&mut app, 2); + assert_eq!(app.session.tool_id(), "rectangle"); + assert_eq!(count(&app), before - 1, "Essentials: undone"); +} + +/// Switch Selection and Drawing Modes from the command: Pencil when no drawing tool was chosen yet, +/// a view tool is left for Selection without becoming the drawing tool, and every selection tool +/// switches to drawing mode. +#[test] +fn switch_selection_and_drawing_modes_command() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + assert!(app.ui.touch_drawing_tool.is_none()); + app.select_tool("selection"); + let r = app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(); + assert_eq!((r, app.session.tool_id()), (json!({"tool": "pencil"}), "pencil"), "no drawing tool chosen yet: Pencil"); + app.select_tool("hand"); + assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "selection"})); + assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "pencil"}), "the Hand tool is no drawing tool"); + app.select_tool("pen"); + for id in ["directSelection", "groupSelection", "magicWand", "lasso"] { + app.select_tool(id); + assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "pen"}), "{id}"); + } +} + /// The Artboard tool's label holds the artboard's name (never translated); the tools' own /// labels are interface text. #[test] @@ -3881,6 +3963,16 @@ fn the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas() { assert_eq!(guides_now(&app).left, 0.25, "one undo step"); app.run("edit.redo", json!({})).unwrap(); assert!((guides_now(&app).left - 0.1).abs() < 1e-3); + // Dragged on past the art's left edge at x = 100, the guide lies outside the art and is drawn + // there, along the art's height. + let (from, to) = (xf.to_screen(Point::new(110.0, 150.0)), xf.to_screen(Point::new(80.0, 150.0))); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); + for k in 1..=4 { + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) * k as f32 / 4.0)]); + } + frame(&mut app, &ctx, vec![button(to, false)]); + assert!((guides_now(&app).left + 0.2).abs() < 1e-3, "{:?}", guides_now(&app)); + assert!(dots(&mut app, Point::new(80.0, 100.0), Point::new(80.0, 200.0)) > 10, "the guide drawn outside the art"); app.run("symbol.exitEditing", json!({})).unwrap(); assert_eq!(dots(&mut app, Point::new(110.0, 100.0), Point::new(110.0, 200.0)), 0, "none outside the mode"); } diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index dae1060502..0304e28c17 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -532,7 +532,8 @@ pub(super) fn wheel( /// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second /// finger drops the press the first one began (its tool interaction rolled back; the rest of the -/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes. +/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes. In the Touch +/// workspace a tap of two fingers switches between selection and drawing modes instead. pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) { if !app.session.prefs.touch_gestures { return; @@ -564,12 +565,12 @@ pub(super) fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect drop_press(app, ui, view); ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); } - Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n), + Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n, app.ui.workspace == crate::workspaces::TOUCH), None => {} } } ui.data_mut(|d| d.insert_temp(key, taps)); - // In Presentation Mode a tap of two or three fingers undoes or redoes nothing. + // In Presentation Mode a tap of two or three fingers runs nothing. if let Some(cmd) = tap && app.ui.screen_mode < 3 { diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index b20d7beef1..f34363e5b7 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -942,27 +942,23 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) { } } -/// The key under which the dialog keeps its footer's height for the next frame: the dialog's own -/// id, so the stored value is found on every frame. -fn footer_id(ui: &egui::Ui) -> egui::Id { - ui.layer_id().id.with("char-style-footer") +/// The key under which the dialog keeps the height its section content was drawn at, for the next +/// frame. +fn content_id(ui: &egui::Ui) -> egui::Id { + ui.layer_id().id.with("char-style-content") } -/// The dialog's body: the section list beside the selected section's content, which scrolls when it -/// is taller than the window (General lists every attribute of the style and OpenType every -/// feature), and the footer below it - Reset Panel, the Preview checkbox and an invalid value's -/// error. The content's height is the space the footer leaves, measured last frame, so the buttons -/// stay on screen at any interface scale; the section list is outside the scroll area and stays put -/// while the content scrolls. Returns whether the dialog asks to be cancelled (never: Cancel comes -/// from the button row in `dialogs::show`). +/// The dialog's body: the section list beside the selected section's content, and below them +/// Reset Panel, the Preview checkbox and an invalid value's error. The content is at most 420 points +/// tall, and scrolls when it is taller than that or than the screen less the rest of the window +/// (as drawn last frame). The section list stays put while the content scrolls. Returns whether +/// the dialog asks to be canceled (never: Cancel comes from the button row in `dialogs::show`). fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - // The content scrolls inside a shorter window: General lists every attribute of the style and - // OpenType every feature, so the window stays within the screen and its buttons stay visible. - // The footer was measured last frame, so the room left for the content keeps it on screen at any - // interface scale; the section list stays put while the content scrolls. - let footer = ui.ctx().data(|m| m.get_temp::(footer_id(ui)).unwrap_or(120.0)); - let room = (ui.available_height() - footer - 8.0).clamp(0.0, 420.0); - ui.horizontal(|ui| { + let window = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)).map_or(0.0, |r| r.height()); + let content = ui.ctx().data(|m| m.get_temp::(content_id(ui))).unwrap_or(0.0); + let rest = (window - content).max(0.0); + let room = (ui.ctx().content_rect().height() - rest - 2.0 * super::EDGE_GAP).clamp(0.0, 420.0); + let drawn = ui.horizontal(|ui| { section_list(ui, d); ui.separator(); // The section list handles a click before this frame's content is laid out, so the section @@ -971,9 +967,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let content_w = ui.available_width(); // Long sections scroll both ways: at a large interface scale the window has fewer points, and // a wide row (a character-format grid) must stay inside it rather than widen the window. - egui::ScrollArea::both().id_salt(("char-style-sections", section)).max_height(room).max_width(content_w).auto_shrink([false, true]).show( - ui, - |ui| { + let shown = egui::ScrollArea::both() + .id_salt(("char-style-sections", section)) + .max_height(room) + .min_scrolled_height(room) + .max_width(content_w) + .auto_shrink([false, true]) + .show(ui, |ui| { ui.vertical(|ui| { if d.str("section") == "basic" { basic_body(ui, d); @@ -996,28 +996,24 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } } }); - }, - ); + }); + // `inner_rect` is the height offered before a short section shrinks the area to its content. + shown.inner_rect.height().min(shown.content_size.y) }); - let footer_h = ui - .scope(|ui| { - reset_button(ui, d); - match edited_attrs(d) { - Ok(attrs) => { - let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs}); - form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params); - } - Err(why) => attrs_error(ui, &why), - } - }) - .response - .rect - .height(); - let was = ui.ctx().data(|m| m.get_temp::(footer_id(ui))); - ui.ctx().data_mut(|m| m.insert_temp(footer_id(ui), footer_h)); - if was != Some(footer_h) { + let content_h = drawn.inner; + let was = ui.ctx().data(|m| m.get_temp::(content_id(ui))); + ui.ctx().data_mut(|m| m.insert_temp(content_id(ui), content_h)); + if was.is_none_or(|was| (was - content_h).abs() > 0.5) { ui.ctx().request_repaint(); } + reset_button(ui, d); + match edited_attrs(d) { + Ok(attrs) => { + let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs}); + form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params); + } + Err(why) => attrs_error(ui, &why), + } false } @@ -1127,6 +1123,98 @@ mod tests { let basic = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width(); assert!((basic - width).abs() < 0.5, "the width does not change with the section: {width} -> {basic}"); } + + /// On a 1920 by 1080 screen the dialog opens with its content 420 points tall when the General + /// list is longer than that: the first frame that draws it, with no input, already has the full + /// height, and later frames keep it. + #[test] + fn the_sections_open_at_their_full_height_without_input() { + let mut app = app(); + app.run( + "charStyle.new", + json!({"name": "Body", "attrs": { + "font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0, + "kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null, + "position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false, + "strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline", + "h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0, + "language": "English: USA", "overprint_fill": false, "overprint_stroke": false, + "stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"] + }}), + ) + .unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1920.0, 1080.0)); + let mut drawn = 0; + for _ in 0..6 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let texts = texts_of(&out); + let top = |label: &str| texts.iter().find(|(text, _)| text == label).map(|r| r.1.top()); + // The first frame only measures the window and draws no text. + let (Some(reset), Some(general)) = (top("Reset Panel"), top("General")) else { continue }; + drawn += 1; + let content = reset - general; + assert!(content >= 410.0, "the content is 420 points tall in drawn frame {drawn}: {content}"); + } + assert!(drawn >= 4, "the dialog is drawn: {drawn} frames"); + // On the smallest window the buttons are on the screen in the first frame that draws them. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 500.0)); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let texts = texts_of(&out); + let Some(ok) = texts.iter().find(|(text, _)| text == "OK").map(|r| r.1) else { continue }; + let cancel = texts.iter().find(|(text, _)| text == "Cancel").expect("Cancel is drawn with OK").1; + assert!(ok.bottom() <= 501.0 && cancel.bottom() <= 501.0, "the buttons are on an 800 by 500 screen when first drawn: {ok:?} {cancel:?}"); + return; + } + panic!("the dialog is drawn on an 800 by 500 screen"); + } + + /// Going back to the long General list from a shorter section keeps OK and Cancel on a small + /// screen in the first frame after the switch, not only once the window has measured itself again. + #[test] + fn switching_to_a_long_section_keeps_the_buttons_on_a_small_screen() { + let mut app = app(); + app.run( + "charStyle.new", + json!({"name": "Body", "attrs": { + "font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0, + "kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null, + "position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false, + "strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline", + "h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0, + "language": "English: USA", "overprint_fill": false, "overprint_stroke": false, + "stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"] + }}), + ) + .unwrap(); + open(&mut app, "Body").unwrap(); + for h in [560.0, 640.0, 720.0] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, h)); + for section in ["basic", "advanced", "color", "opentype"] { + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section)); + let _ = frame_texts_in(&ctx, &mut app, 900.0, h); + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("general")); + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let texts = texts_of(&out); + for label in ["OK", "Cancel"] { + let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1; + assert!(r.bottom() <= h + 1.0, "{label} stays on a {h}-point screen going from {section} to General: {r:?}"); + } + } + } + } + /// The dialog stays usable in the smallest window the app allows (800 by 500 points): in every /// section the section list, OK and Cancel are on the screen. #[test] diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 6c9d95b601..1513efc8f2 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -497,4 +497,19 @@ mod tests { assert_eq!(bar_of(&app).map(|b| b.1), Some(Bar::Mesh { rows: 3, cols: 2 })); bar_frame(&mut app, &ctx, vec![]); } + + #[test] + fn the_control_bar_shows_mesh_rows_and_columns_after_a_warp_point_is_moved() { + let (mut app, _) = app_with_rect(); + app.run(MAKE_WARP, json!({"style": "arc", "bend": 50})).unwrap(); + assert!(bar_of(&app).is_some_and(|(_, bar)| matches!(bar, Bar::Warp { ref style, .. } if style == "arc"))); + let (id, warp) = envelope(&app); + let grid = vectorcraft_doc::live::warp_grid(&warp).unwrap(); + let p = grid.points[6].p; + app.run("object.mesh.movePoint", json!({"id": id.0, "index": 6, "x": p.x + 10.0, "y": p.y + 10.0})).unwrap(); + assert_eq!(bar_of(&app), Some((false, Bar::Mesh { rows: grid.rows, cols: grid.cols }))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + bar_frame(&mut app, &ctx, vec![]); + } } diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index f2eb124776..c223c566fd 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -59,12 +59,13 @@ fn label_w(ui: &egui::Ui) -> f32 { CONTENT_W - FIELD_W - ui.spacing().item_spacing.x } -/// The top of the Fidelity slider (points); it starts at the lowest Fidelity the tool takes, and -/// the field takes up to 100. +/// The top of the Fidelity slider, in the tool's unit (screen pixels for the Pencil and +/// Paintbrush, points for Smooth and Blob Brush); it starts at the lowest Fidelity the tool takes, +/// and the field takes up to 100. const FIDELITY_MAX: f64 = 20.0; -/// Fidelity `v` stepped `n` steps by the slider's and the field's keys: an arrow steps hundredths up to 0.1 pt, -/// tenths up to 1 pt and whole points above (a step down from 0.1 or 1 takes the finer step below +/// Fidelity `v` stepped `n` steps by the slider's and the field's keys: an arrow steps hundredths up to 0.1, +/// tenths up to 1 and whole units above, in the tool's unit (a step down from 0.1 or 1 takes the finer step below /// it); ten steps at once (Shift with an arrow, Page Up/Down) multiply or divide by ten, so they go /// 0.01 → 0.1 → 1 → 10. fn fidelity_step(v: f64, n: f64) -> f64 { @@ -96,7 +97,7 @@ fn fidelity_step(v: f64, n: f64) -> f64 { /// Where Fidelity `v` sits on a slider over `(min, max)`, from 0 to 1. The rail is logarithmic, so /// the low values, where the stroke is followed closely, get as much of it as the high ones -/// (0.01–1 pt takes most of a 0.01–20 rail). +/// (0.01–1 takes most of a 0.01–20 rail, in the tool's unit). fn fidelity_at(v: f64, (min, max): (f64, f64)) -> f32 { if !(v.is_finite() && min > 0.0 && max > min) { return 0.0; @@ -104,7 +105,7 @@ fn fidelity_at(v: f64, (min, max): (f64, f64)) -> f32 { ((v.clamp(min, max) / min).ln() / (max / min).ln()) as f32 } -/// The Fidelity at `x` (0 to 1) on a slider over `(min, max)`: hundredths below 1 pt, tenths above. +/// The Fidelity at `x` (0 to 1) on a slider over `(min, max)`: hundredths below 1, tenths above, in the tool's unit. fn fidelity_from(x: f32, (min, max): (f64, f64)) -> f64 { let v = min * (max / min).powf(f64::from(x.clamp(0.0, 1.0))); let step = if v < 1.0 { 100.0 } else { 10.0 }; diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index 2ff2097975..25d17d3902 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -151,13 +151,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let start = move |app: &mut VectorcraftApp| background::run(app, enabled, label, None, work, move |app, r| packaged(app, r, web, in_background.get())); // Both in the background: the package, collected from the document as it is now, starts once - // the save is written. + // the save is written, with the automation roots in force at OK. if let Some(id) = saving.filter(|_| enabled) { later.set(true); app.ui.dialog = None; + let scope = vectorcraft_engine::file_access::ScopeHandle::capture(); background::after(app, id, move |app, r| { if saved(r) - && let Err(e) = start(app) + && let Err(e) = scope.run(|| start(app)) { app.status(e); } diff --git a/crates/ui-egui/src/dialogs/para_style_options.rs b/crates/ui-egui/src/dialogs/para_style_options.rs index c343e0682c..a78790d692 100644 --- a/crates/ui-egui/src/dialogs/para_style_options.rs +++ b/crates/ui-egui/src/dialogs/para_style_options.rs @@ -979,7 +979,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.separator(); let section = d.str("section"); let content_w = ui.available_width(); - egui::ScrollArea::both() + let shown = egui::ScrollArea::both() .id_salt(("para-style-sections", section)) .max_height(room) .min_scrolled_height(room) @@ -987,15 +987,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { .auto_shrink([false, true]) .show(ui, |ui| { ui.vertical(|ui| section_body(ui, d)); - }) - .inner_rect - .height() + }); + // `inner_rect` is the height offered before a short section shrinks the area to its content. + shown.inner_rect.height().min(shown.content_size.y) }); let content_h = drawn.inner; let was = ui.ctx().data(|m| m.get_temp::(content_id(ui))); ui.ctx().data_mut(|m| m.insert_temp(content_id(ui), content_h)); + // A new height lays the frame out again before it is shown, so the window is placed at its new + // size and OK and Cancel stay on the screen in the frame of a section switch. if was.is_none_or(|was| (was - content_h).abs() > 0.5) { - ui.ctx().request_repaint(); + ui.ctx().request_discard("paragraph style content height changed"); } reset_button(ui, d); match edited_attrs(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 0890491606..f3528b9ce6 100644 --- a/crates/ui-egui/src/dialogs/para_style_options/tests.rs +++ b/crates/ui-egui/src/dialogs/para_style_options/tests.rs @@ -578,3 +578,62 @@ fn every_section_draws_its_fields() { } } } + +/// The texts of one headless frame at a screen `w` by `h`, with their screen rects. +fn texts_at(ctx: &egui::Context, app: &mut VectorcraftApp, w: f32, h: f32) -> Vec<(String, egui::Rect)> { + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + let mut texts = vec![]; + for c in &out.shapes { + collect(&c.shape, &mut texts); + } + texts +} + +/// Going from a shorter section to a longer one keeps OK and Cancel on a small screen in the first +/// frame after the switch, not only once the window has measured itself again. +#[test] +fn switching_to_a_long_section_keeps_the_buttons_on_a_small_screen() { + let mut app = app(); + let mut attrs: Value = serde_json::from_str(KEPT).unwrap(); + for (key, value) in [ + ("right_indent", json!(4.0)), + ("first_line_indent", json!(12.0)), + ("space_after", json!(6.0)), + ("composer", json!("singleLine")), + ("roman_hanging", json!(true)), + ("hyphenate", json!(true)), + ("justification", json!({"auto_leading": 150.0})), + ("korean_line_break_anywhere", json!(true)), + ] { + attrs[key] = value; + } + app.run("paraStyle.new", json!({"name": "Body", "attrs": attrs})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + for h in [500.0, 560.0, 640.0, 720.0] { + for to in SECTIONS { + for from in SECTIONS.into_iter().filter(|s| *s != to) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set(&mut app, "section", json!(from)); + for _ in 0..5 { + let _ = texts_at(&ctx, &mut app, 900.0, h); + } + set(&mut app, "section", json!(to)); + let texts = texts_at(&ctx, &mut app, 900.0, h); + for label in ["OK", "Cancel"] { + let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1; + assert!(r.bottom() <= h + 1.0, "{label} stays on a {h}-point screen going from {from} to {to}: {r:?}"); + } + } + } + } +} diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index 79e26764d0..9e6fa98f82 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -4,7 +4,8 @@ //! (`symbol.options`). //! //! Fields: `name`, `nineSlice` (bool) with `sliceLeft` / `sliceTop` / `sliceRight` / `sliceBottom` -//! (percent of the symbol's size), `exportType` (`movieClip` | `graphic`), `type` (`dynamic` | +//! (percent of the symbol's size, -1600..1600: below 0 or above 100 for a guide outside the art), +//! `exportType` (`movieClip` | `graphic`), `type` (`dynamic` | //! `static`; a new symbol is dynamic), `registration` (0..8, the 9-point grid; //! 4 = centre), `__edit` (true when editing an existing symbol). Help: //! . @@ -26,20 +27,39 @@ const SLICE_KEYS: [&str; 4] = ["sliceLeft", "sliceTop", "sliceRight", "sliceBott /// A guide field's value when the dialog has none: a quarter of the symbol's size, in percent. const DEFAULT_SLICE_PERCENT: f64 = 25.0; -/// The 9-slice dialog fields for `guides` (percent of the symbol's size; a quarter each when off). +/// The field that keeps the guides the dialog opened with, as fractions, in `[left, top, right, +/// bottom]` order. +const SLICE_OPENED: &str = "__sliceOpened"; + +/// The range of a guide field, in percent: a guide may lie outside the art, up to +/// [`vectorcraft_doc::MAX_GUIDE_FRACTION`] times its size from its edge. +const SLICE_PERCENT_RANGE: std::ops::RangeInclusive = -vectorcraft_doc::MAX_GUIDE_FRACTION * 100.0..=vectorcraft_doc::MAX_GUIDE_FRACTION * 100.0; + +/// The 9-slice dialog fields for `guides` (percent of the symbol's size, unrounded; a quarter each +/// when off). fn slice_fields(guides: Option) -> Map { let g = guides.unwrap_or_default(); let mut m = Map::new(); m.insert("nineSlice".into(), json!(guides.is_some())); - for (key, v) in SLICE_KEYS.iter().zip([g.left, g.top, g.right, g.bottom]) { - m.insert((*key).to_string(), json!((v * 100.0).round())); + let opened = [g.left, g.top, g.right, g.bottom]; + for (key, v) in SLICE_KEYS.iter().zip(opened) { + m.insert((*key).to_string(), json!(v * 100.0)); } + m.insert(SLICE_OPENED.into(), json!(opened)); m } -/// The guides the dialog's fields describe, as the `nineSlice` / `nineSliceGuides` params. +/// The guides the dialog's fields describe, as the `nineSlice` / `nineSliceGuides` params. A field +/// still showing the guide it opened with gives that guide exactly. fn slice_params(d: &Dialog) -> Value { - let [l, t, r, b] = SLICE_KEYS.map(|key| d.f64(key, DEFAULT_SLICE_PERCENT) / 100.0); + let opened = d.fields.get(SLICE_OPENED).and_then(Value::as_array); + let [l, t, r, b] = std::array::from_fn(|i| { + let percent = d.f64(SLICE_KEYS[i], DEFAULT_SLICE_PERCENT); + match opened.and_then(|a| a.get(i)).and_then(Value::as_f64) { + Some(g) if g * 100.0 == percent => g, + _ => percent / 100.0, + } + }); if d.bool("nineSlice") { json!({ "nineSlice": true, "nineSliceGuides": [l, t, r, b] }) } else { json!({ "nineSlice": false }) } } @@ -134,7 +154,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { for (key, text) in SLICE_KEYS.into_iter().zip([tl!("Left:"), tl!("Top:"), tl!("Right:"), tl!("Bottom:")]) { label(ui, text); let v = d.f64(key, DEFAULT_SLICE_PERCENT); - if let Some(n) = widgets::range_field(ui, ("dlg-field", key), v, 0.0..=100.0, "%", 0, 64.0) { + if let Some(n) = widgets::range_field(ui, ("dlg-field", key), v, SLICE_PERCENT_RANGE, "%", 3, 64.0) { d.fields.insert(key.into(), json!(n)); } ui.end_row(); diff --git a/crates/ui-egui/src/dialogs/tests_package.rs b/crates/ui-egui/src/dialogs/tests_package.rs index 5c9e899354..acd1368ed4 100644 --- a/crates/ui-egui/src/dialogs/tests_package.rs +++ b/crates/ui-egui/src/dialogs/tests_package.rs @@ -248,6 +248,35 @@ fn a_failed_save_before_packaging_writes_no_package() { } } +/// A package that an automation request confined to its roots asks for, started once the +/// document's background save is written, is still confined to those roots, though the save's +/// result arrives outside the request. +#[test] +fn a_package_started_after_its_save_keeps_the_automation_roots() { + let (dir, elsewhere) = (Folder::new("scope"), Folder::new("scope-elsewhere")); + let (doc, out) = (dir.path("card.vectorcraft"), elsewhere.path("out")); + let root = std::path::Path::new(&out).join("card Folder"); + let mut app = saved(&dir); + let files = Files::default(); + let recorded = files.clone(); + app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| { + vectorcraft_engine::file_access::check_write(p)?; + recorded.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + let roots = AutomationRoots::new(&[dir.0.as_path()], &[dir.0.as_path()]).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + package_dialog(&mut app, &out); + let r = vectorcraft_engine::file_access::confine(roots.as_ref(), || confirm(&mut app)).unwrap(); + assert_eq!(r["background"], true, "{r}"); + // The save's result is applied outside the request, as the next frame does. + background::wait_all(&mut app); + assert_eq!(written(&files), [doc], "only the save, inside the roots"); + assert!(!root.exists(), "no package folder outside the roots"); + assert!(!app.session.active().unwrap().is_dirty(), "the save was written"); + assert!(app.ui.status.contains("failed"), "the package was refused: {}", app.ui.status); +} + #[test] fn a_package_made_at_once_waits_for_one_in_the_background() { let dir = Folder::new("twice"); diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 486cfd682f..4fbb7a7ffc 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -90,6 +90,9 @@ impl WheelOwner { pub struct MenuLook<'a> { /// Show each family's sample (Preferences › Type › Enable in-menu font previews). pub samples: bool, + /// Preferences › Type › Show samples on white: each sample is drawn in dark type on a white + /// area, whatever the interface's brightness (off by default). + pub on_white: bool, /// Preferences › Type › Show Font Names in English (on by default). pub english_names: bool, /// Row height in points (Font Preview Size). @@ -106,9 +109,9 @@ pub struct MenuLook<'a> { } impl Default for MenuLook<'static> { - /// Samples on, English names, medium rows, no favourites, no wheel. + /// Samples on in the theme's colors, English names, medium rows, no favorites, no wheel. fn default() -> Self { - Self { samples: true, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None } + Self { samples: true, on_white: false, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None } } } @@ -121,6 +124,7 @@ impl<'a> MenuLook<'a> { }; Self { samples: app.session.prefs.font_preview, + on_white: app.session.prefs.font_samples_on_white, english_names: app.session.prefs.font_names_in_english, row, favorites: &app.ui.favorite_fonts, @@ -296,8 +300,9 @@ struct MenuState { /// The highlighted row and the query and filters it belongs to. highlight: Option, query: String, - /// The filters (kept from one opening to the next): script (0..[`SCRIPTS`]), kind, font - /// technology (variable fonts only) and favourites only. + /// The filters (kept from one opening to the next, and the same in every font menu: + /// [`Filters`]): script (0..[`SCRIPTS`]), kind, font technology (variable fonts only) and + /// favorites only. script: usize, class: Option, variable_only: bool, @@ -320,6 +325,35 @@ struct MenuState { wheel_target: Option, } +/// The filters of [`MenuState`], one set for every font menu (the Character panel's, the Control +/// bar's, the Properties panel's…): a filter chosen in one menu is on in all of them. They last +/// for the session. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Filters { + script: usize, + class: Option, + variable_only: bool, + favorites_only: bool, +} + +impl Filters { + fn of(st: &MenuState) -> Self { + Self { script: st.script, class: st.class, variable_only: st.variable_only, favorites_only: st.favorites_only } + } + + fn apply(self, st: &mut MenuState) { + st.script = self.script; + st.class = self.class; + st.variable_only = self.variable_only; + st.favorites_only = self.favorites_only; + } +} + +/// Where the filters every font menu shares are kept ([`Filters`]). +fn filters_id() -> egui::Id { + egui::Id::unique("font-menu-filters") +} + /// The font family menu showing `current`, `width` points wide, rows sampling `sample` (the /// selected text; `None`: a generic sample). pub fn font_menu( @@ -332,6 +366,7 @@ pub fn font_menu( ) -> Option { let state_id = ui.scope_id().with(&id).with("font-menu"); let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); + ui.data(|d| d.get_temp::(filters_id())).unwrap_or_default().apply(&mut st); let target = ui.data(|d| d.get_temp(wheel_fields_id(ui.ctx().viewport_id()).with("target"))); let target_changed = st.wheel_target != target; if target_changed { @@ -412,7 +447,10 @@ pub fn font_menu( if matches!(out, Some(FontPick::Chosen(..))) { st.previewed = None; } - ui.data_mut(|d| d.insert_temp(state_id, st)); + ui.data_mut(|d| { + d.insert_temp(filters_id(), Filters::of(&st)); + d.insert_temp(state_id, st); + }); out } @@ -1235,6 +1273,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Optio let current = current.as_str(); let row_h = look.row; let t = Tokens::get(ui.ctx()); + let ink = sample_ink(look.on_white, t.text); let db = vectorcraft_text::FontDb::global(); let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f)); let widest = name_width(ui, look.english_names, &rows); @@ -1315,9 +1354,12 @@ fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Optio ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(NAME_SIZE), t.text); // The sample takes the row's whole height. let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 0.0), rect.max - vec2(STAR + 2.0, 0.0)); - if !look.samples { - } else if let Some(tex) = sample_texture(ui.ctx(), family, row.style(), sample, sample_rect.height()) { - paint_sample(ui, sample_rect, &tex, t.text); + if look.samples { + // The paper is drawn while the sample is still rendering, and for a family whose sample fails. + let area = sample_area(ui, sample_rect, ink, PAPER_INSET); + if let Some(tex) = sample_texture(ui.ctx(), family, row.style(), sample, area.height()) { + paint_sample(ui, area, &tex, ink); + } } if resp.clicked() && !on_triangle && !on_star { chosen = row.pick(); @@ -1366,19 +1408,67 @@ pub(crate) const MENU_SAMPLE_SIZE: egui::Vec2 = vec2(90.0, 16.0); /// A generic sample of `family` drawn in `slot` (the room a Type → Font item of the in-window /// menu bar keeps for it; the native macOS menu shows names only). -pub(crate) fn menu_item_sample(ui: &Ui, slot: egui::Rect, family: &str) { - if let Some(tex) = sample_texture(ui.ctx(), family, None, None, slot.height()) { - paint_sample(ui, slot, &tex, Tokens::get(ui.ctx()).text_dim); +/// With `on_white` (Show samples on white), the sample is drawn in dark type on a white slot. +pub(crate) fn menu_item_sample(ui: &Ui, slot: egui::Rect, family: &str, on_white: bool) { + let ink = sample_ink(on_white, Tokens::get(ui.ctx()).text_dim); + let area = sample_area(ui, slot, ink, 0.0); + if let Some(tex) = sample_texture(ui.ctx(), family, None, None, area.height()) { + paint_sample(ui, area, &tex, ink); } } -/// Draw sample `tex` (tinted `tint`) at the left of `area`, centred vertically, cut at its right. -fn paint_sample(ui: &Ui, area: egui::Rect, tex: &egui::TextureHandle, tint: Color32) { +/// What a sample is drawn on with Show samples on white. +pub(crate) const SAMPLE_PAPER: Color32 = Color32::WHITE; + +/// How a sample is drawn: in `theme` (an interface text color) on the menu's own background, or, +/// with Show samples on white, in the Light brightness's text color on [`SAMPLE_PAPER`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SampleInk { + pub ink: Color32, + pub paper: Option, +} + +pub(crate) fn sample_ink(on_white: bool, theme: Color32) -> SampleInk { + if on_white { + SampleInk { ink: Tokens::for_brightness(crate::theme::Brightness::Light).text, paper: Some(SAMPLE_PAPER) } + } else { + SampleInk { ink: theme, paper: None } + } +} + +/// Space between the left edge of a sample's paper and its type. +const PAPER_PAD: f32 = 3.0; + +/// Space above and below a menu row's paper, where the row's highlight color shows. +pub(crate) const PAPER_INSET: f32 = 2.0; + +/// The part of `slot` a sample is drawn in: `slot` itself, or, when `ink` has paper, the paper +/// painted in `slot` with `inset` points left above and below it. +fn sample_area(ui: &Ui, slot: egui::Rect, ink: SampleInk, inset: f32) -> egui::Rect { + let Some(paper) = ink.paper else { return slot }; + let area = slot.shrink2(vec2(0.0, inset)); + if area.is_positive() { + ui.painter_at(slot).rect_filled(area, 2.0, paper); + } + area +} + +/// Draw sample `tex` at the left of `area`, centered vertically, cut at its right; on paper, +/// [`PAPER_PAD`] in from its left edge. +fn paint_sample(ui: &Ui, area: egui::Rect, tex: &egui::TextureHandle, ink: SampleInk) { + let painter = ui.painter_at(area); + let mut start = area.left_center(); + if ink.paper.is_some() { + start.x += PAPER_PAD; + } let size = tex.size_vec2() / ui.ctx().pixels_per_point(); - let at = egui::Rect::from_min_size(area.left_center() - vec2(0.0, size.y * 0.5), size); + let at = egui::Rect::from_min_size(start - vec2(0.0, size.y * 0.5), size); let shown = at.intersect(area); + if !shown.is_positive() { + return; + } let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2((shown.width() / size.x).clamp(0.0, 1.0), 1.0)); - ui.painter_at(area).image(tex.id(), shown, uv, tint); + painter.image(tex.id(), shown, uv, ink.ink); } type Key = (String, String, String, u32); diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 69063e8e87..88a524e15b 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -790,6 +790,108 @@ fn a_modified_turn_that_starts_a_stream_keeps_it_off_the_field() { assert_eq!((left, notches), (1, 0), "the stream began as a modified turn"); } +/// The font menus of two panels (other ids, other scopes) share their filters: a filter chosen in +/// one menu is on when the other opens, and it lists its rows by it. +#[test] +fn a_filter_chosen_in_one_panels_menu_is_on_in_the_others() { + use vectorcraft_text::FontClass; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let fields = [("character", "character-font", 10.0), ("control-bar", "control-font", 60.0)]; + let field = |top: f32| egui::Rect::from_min_size(egui::pos2(10.0, top), egui::vec2(200.0, 30.0)); + // Draws both fields (and the open menu), and returns the texts drawn with their centers and + // each menu's state. + let frame = |events: Vec| { + let input = + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 900.0))), ..Default::default() }; + let mut ids = [egui::Id::NULL; 2]; + let mut out = ctx.run_ui(input, |ui| { + for (i, (scope, id, top)) in fields.into_iter().enumerate() { + ui.push_id(scope, |ui| { + ui.scope_builder(egui::UiBuilder::new().max_rect(field(top)), |ui| { + ids[i] = ui.scope_id().with(id).with("font-menu"); + super::font_menu(ui, id, "Inter", 200.0, None, super::MenuLook { samples: false, ..Default::default() }); + }); + }); + } + }); + out.textures_delta.clear(); + let texts: Vec<(String, egui::Pos2)> = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect().center())), + _ => None, + }) + .collect(); + let states = ids.map(|id| ctx.data(|d| d.get_temp::(id)).unwrap_or_default()); + (texts, states) + }; + let press = |at: egui::Pos2| { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![egui::Event::PointerMoved(at), button(true)]); + frame(vec![button(false)]); + for _ in 0..3 { + frame(vec![]); + } + }; + let open = |i: usize| press(field(fields[i].2).min + egui::vec2(10.0, 10.0)); + let close = || { + let escape = [true, false].map(|pressed| egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed, + repeat: false, + modifiers: Default::default(), + }); + frame(escape.to_vec()); + for _ in 0..3 { + frame(vec![]); + } + }; + // The topmost text `label`: a filter chip, above the rows. + let chip = |label: &str| { + let (texts, _) = frame(vec![]); + texts + .into_iter() + .filter(|(t, _)| t == label) + .map(|(_, at)| at) + .min_by(|a, b| a.y.total_cmp(&b.y)) + .unwrap_or_else(|| panic!("{label} is drawn")) + }; + let families = |st: &super::MenuState| st.rows.iter().filter(|r| matches!(r, Row::Family(_))).count(); + let filters = |st: &super::MenuState| (st.script, st.class, st.variable_only, st.favorites_only); + frame(vec![]); + // Favorites only, chosen in the first panel's menu, with no favorites: the second lists nothing. + open(0); + press(chip("★")); + close(); + let (_, [first, second]) = frame(vec![]); + assert!(first.favorites_only && second.favorites_only, "the second menu has the filter before it opens"); + open(1); + let (_, [_, second]) = frame(vec![]); + assert!(second.favorites_only); + assert_eq!(families(&second), 0, "no favorites, so the second menu lists no family"); + // A script, a kind and Variable chosen in the second panel's menu are on in the first. + press(chip(super::script_label(3))); + press(chip(super::class_label(FontClass::Monospaced))); + press(chip(super::variable_label())); + close(); + open(0); + let (_, [first, _]) = frame(vec![]); + assert_eq!(filters(&first), (3, Some(FontClass::Monospaced), true, true)); + // Every filter, turned off in the first menu, is off in the second, which lists families again. + press(chip("★")); + press(chip(super::script_label(0))); + press(chip(super::class_label(FontClass::Monospaced))); + press(chip(super::variable_label())); + close(); + open(1); + let (_, [_, second]) = frame(vec![]); + assert_eq!(filters(&second), (0, None, false, false)); + assert!(families(&second) > 0, "every family is listed again"); +} + /// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun. #[test] fn the_variable_filter_is_named_as_fonts_are() { @@ -882,3 +984,50 @@ fn the_variable_filter_lists_only_variable_families() { assert!(lasting.iter().all(|f| has(&again, f)), "{again:?}"); assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}"); } + +/// A Type › Font menu item's sample follows Show samples on white: dark type on a white slot, +/// or the interface's dimmed text color with nothing behind it. The slot is white while the +/// sample is still rendering too. +#[test] +fn a_menu_items_sample_follows_show_samples_on_white() { + use crate::theme::{Brightness, Tokens}; + let dark = Tokens::for_brightness(Brightness::Light).text; + for b in Brightness::ALL { + let t = Tokens::for_brightness(b); + for on_white in [false, true] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); + let slot = egui::Rect::from_min_size(egui::pos2(40.0, 20.0), super::MENU_SAMPLE_SIZE); + let frame = || { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::menu_item_sample(ui, slot, "Inter", on_white)); + out.textures_delta.clear(); + out.shapes + }; + frame(); + super::wait_for_samples(&ctx); + let shapes = frame(); + let tints: Vec<_> = shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => m.vertices.first().map(|v| v.color), + _ => None, + }) + .collect(); + let papers = + shapes.iter().filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == super::SAMPLE_PAPER && r.rect == slot)).count(); + assert_eq!(tints, [if on_white { dark } else { t.text_dim }], "{}, on white {on_white}", b.id()); + assert_eq!(papers, usize::from(on_white), "{}, on white {on_white}", b.id()); + } + } + // While a family's sample is still rendering, its slot is already white. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let slot = egui::Rect::from_min_size(egui::pos2(40.0, 20.0), super::MENU_SAMPLE_SIZE); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::menu_item_sample(ui, slot, "JetBrains Mono", true)); + out.textures_delta.clear(); + let shapes = out.shapes; + super::wait_for_samples(&ctx); + assert!(!shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default())), "no sample"); + assert!(shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == super::SAMPLE_PAPER && r.rect == slot)), "the slot is white"); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8ffd5a8cef..4ec89b8e32 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -660,7 +660,8 @@ const MESSAGE_DEPTH: usize = 5; /// Translate a status or error message for display (see the module docs). Unknown messages, and /// parts of them (file names, numbers, a reason with no entry, a JSON object or array such as /// `{"objects": [...]}`, a value the message quotes in `"…"`, `“…”` or backticks, such as a -/// symbol's name), stay as they are. +/// symbol's name), stay as they are. A part that starts with `{` or `[` is translated only +/// when it is a whole message with an exact entry, such as `[x, y] (two numbers)`. pub fn message(lang: Lang, s: &str) -> String { message_at(lang, s, MESSAGE_DEPTH) } @@ -671,21 +672,38 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { let form = count.map_or(forms.len().saturating_sub(1), |n| (lang.0.plural)(n).min(forms.len().saturating_sub(1))); let Some(template) = forms.get(form) else { return s.to_string() }; let quoted = |v: &str| [('"', '"'), ('“', '”'), ('`', '`')].iter().any(|(a, b)| s.contains(&format!("{a}{v}{b}"))); - let nested = |v: &str| depth > 1 && !v.starts_with(['{', '[']) && !quoted(v); - let values: Vec<(&str, String)> = - caps.into_iter().map(|(k, v)| (k, if nested(v) { message_at(lang, v, depth - 1) } else { v.to_string() })).collect(); + let nested = |v: &str| { + if depth <= 1 || quoted(v) { + v.to_string() + } else if v.starts_with(['{', '[']) { + lang.catalog().exact_message(v).unwrap_or(v).to_string() + } else { + message_at(lang, v, depth - 1) + } + }; + let values: Vec<(&str, String)> = caps.into_iter().map(|(k, v)| (k, nested(v))).collect(); let args: Vec<(&str, &str)> = values.iter().map(|(k, v)| (*k, v.as_str())).collect(); fill_once(template, &args) } /// Fill `{name}` placeholders in one pass: a value that holds `{name}` text stays as it is. -/// Unknown placeholders are left as written. +/// Unknown placeholders are left as written. `{{` and `}}` are a literal `{` and `}`. fn fill_once(template: &str, args: &[(&str, &str)]) -> String { let mut rest = template; let mut out = String::with_capacity(rest.len()); - while let Some(open) = rest.find('{') { + while let Some(open) = rest.find(['{', '}']) { out.push_str(&rest[..open]); let after = &rest[open + 1..]; + if rest[open..].starts_with('}') { + out.push('}'); + rest = after.strip_prefix('}').unwrap_or(after); + continue; + } + if let Some(escaped) = after.strip_prefix('{') { + out.push('{'); + rest = escaped; + continue; + } let filled = after.find('}').and_then(|close| args.iter().find(|(k, _)| *k == &after[..close]).map(|(_, v)| (*v, close))); match filled { Some((value, close)) => { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a9ff74ad2e..3828d9ba2d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -797,6 +797,8 @@ font-technology Variable Променливи Delete Width Point Изтриване на точка на ширината Delete Workspace Изтриване на работно пространство Delete {kind} Style Изтриване на стил ({kind}) + Delete the selected items and their artwork? Да се изтрият ли избраните елементи заедно с графиката им? + Delete this layer and its artwork? Да се изтрие ли този слой заедно с графиката му? Delete “{name}”? Изтриване на „{name}“? Depth: Дълбочина: Description Описание @@ -1009,12 +1011,14 @@ font-technology Variable Променливи Enable Contextual Task Bar Разрешаване на контекстуалната лента със задачи Enable copying of text, images and other content Разрешаване на копирането на текст, изображения и друго съдържание Enable in-menu font previews Разрешаване на прегледи на шрифтове в менюто + Show samples on white Показване на мострите на бял фон Enable Missing Glyph Protection Разрешаване на защита срещу липсващи глифи Enable Opacity Mask Разрешаване на маска за непрозрачност Enable plaintext metadata Разрешаване на метаданни в обикновен текст Enable Rubber Band for Разрешаване на ластик за Enable text access for screen readers Разрешаване на достъп до текст за екранни четци Enable Touch Gestures Разрешаване на докосващи жестове + Switch Selection and Drawing Modes Превключване между режим на селекция и рисуване Enable Touch Workspace Разрешаване на работно пространство за докосване Encoding Кодиране Encoding: Кодиране: @@ -1971,6 +1975,10 @@ font-technology Variable Променливи Long Дълго Stylistic Стилистично Mojikumi Set Набор Mojikumi + YakumonoHankaku YakumonoHankaku + GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku + GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku + YakumonoZenkaku YakumonoZenkaku Kinsoku Set Набор Kinsoku Kinsoku Shori Type Тип Kinsoku Shori Push In First Първо прибиране @@ -4185,6 +4193,7 @@ axis Both И двете @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` трябва да бъде цяло число между {min} и {max} (получено е {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` трябва да бъде между {min} и {max} (получено е {x}) @msg `{key}` must be one of {_1} `{key}` трябва да бъде едно от {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` трябва да бъде едно от {_1}, а не {got} @msg `{key}` must be true or false `{key}` трябва да бъде true или false @msg `{k}` must be [x, y] `{k}` трябва да бъде [x, y] @msg `{name}` has no options `{name}` няма опции diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ab1069477e..81fee3a079 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2006,6 +2006,10 @@ digits Malayalam Malajalam Long Dugo Stylistic Stilsko Mojikumi Set Mojikumi skup + YakumonoHankaku YakumonoHankaku + GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku + GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku + YakumonoZenkaku YakumonoZenkaku Kinsoku Set Kinsoku skup Kinsoku Shori Type Vrsta Kinsoku Shori Push In First Prvo uvlačenje @@ -2141,6 +2145,8 @@ digits Malayalam Malajalam Weight: Debljina: Art using a deleted global swatch keeps its colour. Crtež koji koristi izbrisani globalni uzorak boje zadržava svoju boju. Color Group: {name} Grupa boja: {name} + Delete the selected items and their artwork? Izbrisati odabrane stavke zajedno s njihovim crtežom? + Delete this layer and its artwork? Izbrisati ovaj sloj zajedno s njegovim crtežom? Delete “{name}”? Izbrisati „{name}”? Document Swatches Uzorci boja dokumenta Edit or Apply Color Group Uredi ili primijeni grupu boja @@ -3729,9 +3735,11 @@ theme Appearance Izgled Enable Background Export Omogući izvoz u pozadini Enable Background Save Omogući čuvanje u pozadini Enable in-menu font previews Omogući preglede fontova u meniju + Show samples on white Prikaži uzorke na bijeloj podlozi Enable Missing Glyph Protection Omogući zaštitu od nedostajućih glifova Enable Rubber Band for Omogući gumenu traku za Enable Touch Gestures Omogući pokrete dodirom + Switch Selection and Drawing Modes Prebaci između načina odabira i crtanja Enable Touch Workspace Omogući radni prostor za dodir English: UK Engleski: UK Every (minutes) Svakih (minuta) @@ -4555,6 +4563,7 @@ imageTrace Outline Obris @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` mora biti cijeli broj između {min} i {max} (dobiveno {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` mora biti između {min} i {max} (dobiveno {x}) @msg `{key}` must be one of {_1} `{key}` mora biti jedno od {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` mora biti jedno od {_1}, a ne {got} @msg `{key}` must be true or false `{key}` mora biti true ili false @msg `top` and `below` can't both be true `top` i `below` ne mogu oba biti true @msg `select` must be true or false `select` mora biti true ili false diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 2a71c4b658..02ae0f4a09 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1163,6 +1163,8 @@ antiAlias Sharp Nítid Delete Width Point Suprimeix el punt d’amplada Delete Workspace Suprimeix l’espai de treball Delete {kind} Style Suprimeix l’estil de {kind} + Delete the selected items and their artwork? Voleu suprimir els elements seleccionats i el seu art? + Delete this layer and its artwork? Voleu suprimir aquesta capa i el seu art? Delete “{name}”? Voleu suprimir “{name}”? Depth Profunditat Depth: Profunditat: @@ -1472,12 +1474,14 @@ antiAlias Sharp Nítid Enable copying of text, images and other content Activa la còpia de text, imatges i altre contingut Enable Guides for 9-Slice Scaling Activa les guies per a l’escalat de 9 talls Enable in-menu font previews Activa les previsualitzacions de tipus de lletra als menús + Show samples on white Mostra les mostres sobre fons blanc Enable Missing Glyph Protection Activa la protecció de glifs absents Enable Opacity Mask Activa la màscara d’opacitat Enable plaintext metadata Activa les metadades en text pla Enable Rubber Band for Activa la goma elàstica per a Enable text access for screen readers Activa l’accés al text per als lectors de pantalla Enable Touch Gestures Activa els gestos tàctils + Switch Selection and Drawing Modes Canvia entre els modes de selecció i de dibuix Enable Touch Workspace Activa l’espai de treball tàctil Encoding Codificació Encoding and writing the file… S’està codificant i escrivint el fitxer… @@ -2592,6 +2596,10 @@ digits Malayalam Malaiàlam Modify Modifica Mojikumi Ajust mojikumi Mojikumi Set Conjunt mojikumi + YakumonoHankaku YakumonoHankaku + GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku + GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku + YakumonoZenkaku YakumonoZenkaku Monitor RGB RGB del monitor Mono Monocrom Monochromatic Monocromàtic @@ -5821,6 +5829,7 @@ digits Malayalam Malaiàlam @msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`) @msg not an EMF or WMF file no és un fitxer EMF o WMF @msg not an HP-GL file no és un fitxer HP-GL +@msg not a PICT file no és un fitxer PICT @msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb @msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf @msg not found no s’ha trobat @@ -6034,6 +6043,7 @@ digits Malayalam Malaiàlam @msg t, dx and dy must be finite t, dx i dy han de ser finits @msg Targa files are RGB: colorModel rgb Els fitxers Targa són RGB: colorModel rgb @msg Text Wrap: select a wrap object Ajustament de text: selecciona un objecte d’ajustament +@msg the active document changed while the question was open; nothing was deleted el document actiu ha canviat mentre la pregunta era oberta; no s’ha suprimit res @msg that document is no longer open aquest document ja no està obert @msg Extrude & Bevel Extrudeix i bisella @msg Revolve Revoluciona @@ -6469,6 +6479,35 @@ digits Malayalam Malaiàlam @msg the path has no anchor points el traçat no té punts d’ancoratge @msg too many anchor points (limit {MAX_ANCHORS}) massa punts d’ancoratge (límit {MAX_ANCHORS}) @msg true or false true o false +@msg a number >= {_1} un nombre >= {_1} +@msg a number > {_1} un nombre > {_1} +@msg a number <= {_1} un nombre <= {_1} +@msg a number < {_1} un nombre < {_1} +@msg a number {_1} to {_2} un nombre de {_1} a {_2} +@msg a number >= {_1} and < {_2} un nombre >= {_1} i < {_2} +@msg a number > {_1} and <= {_2} un nombre > {_1} i <= {_2} +@msg a number > {_1} and < {_2} un nombre > {_1} i < {_2} +@msg a number ({_1}) un nombre ({_1}) +@msg a number >= {_1} ({_2}) un nombre >= {_1} ({_2}) +@msg a number > {_1} ({_2}) un nombre > {_1} ({_2}) +@msg a number <= {_1} ({_2}) un nombre <= {_1} ({_2}) +@msg a number < {_1} ({_2}) un nombre < {_1} ({_2}) +@msg a number {_1} to {_2} ({_3}) un nombre de {_1} a {_2} ({_3}) +@msg a number >= {_1} and < {_2} ({_3}) un nombre >= {_1} i < {_2} ({_3}) +@msg a number > {_1} and <= {_2} ({_3}) un nombre > {_1} i <= {_2} ({_3}) +@msg a number > {_1} and < {_2} ({_3}) un nombre > {_1} i < {_2} ({_3}) +@msg an integer un enter +@msg an integer >= {_1} un enter >= {_1} +@msg an integer <= {_1} un enter <= {_1} +@msg an integer {_1} to {_2} un enter de {_1} a {_2} +@msg one of {_1} un de {_1} +@msg `{path}` must be one of {_1}, not {got} `{path}` ha de ser un de {_1}, no {got} +@msg an object id (a non-negative integer) un identificador d’objecte (un enter no negatiu) +@msg a list of object ids una llista d’identificadors d’objecte +@msg a color (a string, [r, g, b] or an object) un color (una cadena, [r, g, b] o un objecte) +@msg [x, y] (two numbers) [x, y] (dos nombres) +@msg an object un objecte +@msg a list una llista @msg unknown key `{k}`; the keys here are {_1} clau desconeguda `{k}`; les claus aquí són {_1} @msg {_1} is listed twice {_1} apareix dues vegades @msg {_1} is not the id of an input object {_1} no és l’id d’un objecte d’entrada @@ -7201,7 +7240,9 @@ breakText Glyphs Glifs @msg “{shown}” opens {_1} files, not `{_2}` «{shown}» obre fitxers {_1}, no `{_2}` @msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` és un complement del tipus `{_2}`, no un importador @msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` és un complement del tipus `{_2}`, no un exportador -@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer"}"}} +@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer": "{_2}"}} +@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` és un exportador: exporteu amb document.export {{"format": "{_2}"}} +@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` és un efecte dinàmic: apliqueu-lo amb effect.apply {{"effect": "{_2}"}} @msg the importer returned no objects l’importador no ha retornat cap objecte @msg an importer returns `objects` and `warnings`, not `{key}` un importador retorna `objects` i `warnings`, no `{key}` @msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el fitxer supera els {MAX_INPUT_BYTES} bytes que llegeix un importador diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 138299643d..ab3abff6f5 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -47,23 +47,42 @@ const MAX_MESSAGE_LEN: usize = 2000; impl Template { /// `None` for a template that can't be matched reliably: no literal text, or two placeholders - /// in a row (where one ends and the next begins is unknowable). + /// in a row (where one ends and the next begins is unknowable). `{{` and `}}` are a literal `{` + /// and `}`, as in a Rust format string, and so is a `{` with no `}` after it. fn parse(source: &str, forms: Vec, counted: bool) -> Option