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/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/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/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 02cbecae1d..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 ), @@ -650,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"; @@ -719,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); diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 882b5c3b20..306aaa4797 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -664,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/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/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..454ec8d482 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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/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/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..ed98d620bf 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3943,6 +3943,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..1175779777 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -945,6 +945,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/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/text/src/justify.rs b/crates/text/src/justify.rs index 17edeb4d86..6be6a6ab4a 100644 --- a/crates/text/src/justify.rs +++ b/crates/text/src/justify.rs @@ -167,6 +167,8 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g g.sx *= g_des; g.dx *= g_des; g.kern_shift = (g.kern_shift - g.aki_shift) * g_des + g.aki_shift; + // The space Mojikumi took off before the glyph scales with it. + g.lead *= g_des; } f.width = g.adv - aki; f.scale = g_des; diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 21ab10c6e6..aae91be1ee 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -4,11 +4,13 @@ use super::breaking::{ roman_share, set_width, squeeze_em, tab_advance, word_width, }; use super::region::Region; -use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; +use super::vertical::{ + EM_CENTER, MojikumiRules, align_shift, compress_punctuation, dot_quarter, glyph_em, halve_punctuation, punct_half, space_japanese_and_latin, +}; use super::{ Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo, - ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, - fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, + ListMarker, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, + hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; use crate::justify::is_format_control; use crate::shape::{keep_inseparable_pairs, keep_korean_words}; @@ -76,7 +78,7 @@ fn compose_para( // The same rule as the line loop below: an opening bracket starting a wrapped line gives up // the space before it. let start_credit = |i: usize| { - let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf; + let flush = !rtl && MojikumiRules::of(para.mojikumi).flush_start; sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0) }; let mut sim = pen.clone(); @@ -195,7 +197,8 @@ struct ParagraphData<'a, 'text> { 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/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/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/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/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/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 486cfd682f..9a0ca9c1d5 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, @@ -1235,6 +1239,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 +1320,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 +1374,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..14b203b044 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -882,3 +882,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..50d41f094e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -679,13 +679,23 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { } /// 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..daed8ba259 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,6 +1011,7 @@ 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 Разрешаване на метаданни в обикновен текст @@ -1971,6 +1974,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 Първо прибиране diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index ab1069477e..e1e980e40b 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,6 +3735,7 @@ 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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 2a71c4b658..de51d9949f 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,6 +1474,7 @@ 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 @@ -2592,6 +2595,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 +5828,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 +6042,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 @@ -7201,7 +7210,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..ac659ac4b2 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