diff --git a/Cargo.lock b/Cargo.lock index 267130fc41..887b10ab11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6632,6 +6632,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "vectorcraft-brush", "vectorcraft-doc", "vectorcraft-geom 0.24.1", "vectorcraft-testkit", @@ -6929,6 +6930,7 @@ name = "vectorcraft-ui-egui" version = "0.24.1" dependencies = [ "bytemuck", + "chrono", "craft-keymap", "craft-ui", "craft-update", diff --git a/ROADMAP.md b/ROADMAP.md index 1de46b4b4d..ec3f6e9c76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (G5: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); G5 estimate unchanged (3–13 h); previously: Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -225,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-15 | minor | Graphic Styles: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); G5 estimate unchanged (3–13 h). | | 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. | diff --git a/crates/brush/src/stored_art.rs b/crates/brush/src/stored_art.rs index a386e2e181..e54fdd3121 100644 --- a/crates/brush/src/stored_art.rs +++ b/crates/brush/src/stored_art.rs @@ -120,6 +120,13 @@ impl StoredArt { doc.number_mounted_art(); } + /// Sets the art on `doc` as [`Document::mounted_art`] with the ids it is stored with, the id + /// counter left as it is: what a save looks through, writing back only the brushes whose art it + /// changed ([`Self::write_back`]). + pub fn mount_as_stored(&self, doc: &mut Document) { + doc.mounted_art = self.entries.iter().flat_map(|e| e.art.iter().cloned()).collect(); + } + /// Takes [`Document::mounted_art`] off `doc` and writes each brush whose art changed (new ids /// included) back into its library entry: the entry at its index when that entry is as it was /// read or reads as the brush as it was apart from its name, else the first entry that reads as diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 95c8ecfb7a..bb397d7eaa 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -596,6 +596,9 @@ struct Walk<'a> { clips: Vec, /// The points those masks hold ([`MAX_MASK_POINTS`]). mask_points: usize, + /// Set while the stroke pieces of a clipping path whose fill paints are drawn: they close, as + /// its fill closes its outline. + closing: bool, runs: Vec, points: usize, clip_tests: u64, @@ -624,6 +627,7 @@ impl<'a> Walk<'a> { tolerance, clips: vec![], mask_points: 0, + closing: false, runs: vec![], points: 0, clip_tests: 0, @@ -827,7 +831,9 @@ impl<'a> Walk<'a> { let mut paint = first.clip_paint(); // A stroke at 0% opacity isn't drawn, so the clipping path is drawn in its fill. let type_stroke = |s: &Node| matches!(&s.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.has_stroke())); - paint.stroke = paint.stroke.filter(|s| top_stroke(s).is_some() || type_stroke(s)); + // The strokes of a clipping path whose stroke effects were applied are a group of pieces, + // which effects around it may have put in groups of their own. + paint.stroke = paint.stroke.filter(|s| top_stroke(s).is_some() || type_stroke(s) || strokes_in_pieces(s)); if paint.stroke.is_none() && let Some(fill) = &paint.fill { @@ -852,9 +858,16 @@ impl<'a> Walk<'a> { None => {} } // Drawn whole where its stroke paints: a fill closes its outline, and type's characters - // without a stroke are drawn too. - if paint.stroke.is_some() { - self.node(first, false); + // without a stroke are drawn too. Pieces of a clipping path whose fill and stroke effects + // were applied are drawn in its stroke pieces alone, closed where its fill paints. + if let Some(stroke) = &paint.stroke { + if first.extras().clip_pieces { + let closing = std::mem::replace(&mut self.closing, paint.fill.as_ref().is_some_and(fills_in_pieces)); + self.node(stroke, false); + self.closing = closing; + } else { + self.node(first, false); + } } } @@ -874,7 +887,8 @@ impl<'a> Walk<'a> { /// A path drawn in its color: the topmost stroke's that paints, else the topmost fill's that /// paints (visible, with a paint and some opacity). A filled path's every subpath is closed, - /// as its fill closes it, also when its stroke gives the color. + /// as its fill closes it, also when its stroke gives the color, and so is each stroke piece + /// of a clipping path whose fill paints ([`Walk::closing`]). fn shape(&mut self, n: &Node, bp: &BezPath) { if bp.elements().is_empty() { return; @@ -896,7 +910,7 @@ impl<'a> Walk<'a> { (None, None) => return, }; let rgb = self.flat(paint); - self.draw(rgb, bp, fill.is_some()); + self.draw(rgb, bp, fill.is_some() || self.closing); } /// `bp` drawn in `rgb`: flattened (with `close` every subpath closed), then cut and added. @@ -1146,6 +1160,18 @@ fn top_stroke(n: &Node) -> Option<&StrokeLayer> { }) } +/// Whether a member of `n`, at any depth of its groups, has a stroke that paints ([`top_stroke`]), +/// with it and the groups around it shown and not fully transparent. +fn strokes_in_pieces(n: &Node) -> bool { + n.children().is_some_and(|ch| ch.iter().filter(|c| c.visible && c.opacity > 0.0).any(|c| top_stroke(c).is_some() || strokes_in_pieces(c))) +} + +/// Whether a member of `n`, at any depth of its groups, has a fill that paints ([`top_fill`]), +/// with it and the groups around it shown and not fully transparent. +fn fills_in_pieces(n: &Node) -> bool { + n.children().is_some_and(|ch| ch.iter().filter(|c| c.visible && c.opacity > 0.0).any(|c| top_fill(c).is_some() || fills_in_pieces(c))) +} + /// The paint of the topmost fill of `n` that paints: visible, with a paint and some opacity. fn top_fill(n: &Node) -> Option<&Paint> { n.appearance.items.iter().rev().find_map(|i| match i { diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 60f7cf5b3d..493e53906c 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -747,6 +747,86 @@ fn a_clipping_path_is_drawn_where_it_paints() { }); let t = text(&plot(&open_doc, &opts(&open_doc))); assert!(t.ends_with("SP2;PU282,1129;PD564,1129,564,847,282,1129;PU;SP0;"), "{t}"); + // A stroke with a geometry effect of its own is drawn once, over the art it clips, in its color. + let moved = doc_with(|d| { + let (blue, red) = (Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0))); + let mut clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::basic(blue, red, 1.0)); + for item in &mut clip.appearance.items { + if let AppearanceItem::Stroke(s) = item { + let params = serde_json::json!({"moveH": 5}); + s.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params, visible: true }); + } + } + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + assert_eq!(plot(&moved, &opts(&moved)).pens, [[0, 0, 0], [255, 0, 0]]); + // So are the stroke's pieces when an effect around them put them in a group of their own. + let grouped = |opacity: f32| { + doc_with(|d| { + // One path for each, carrying only that fill or stroke. + let piece = |d: &mut Document, fill: bool| { + let mut a = Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0); + a.items.retain(|i| matches!(i, AppearanceItem::Fill(_)) == fill); + Arc::new(Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), a)) + }; + let (fill, stroke) = (piece(d, true), piece(d, false)); + let mut copies = Node::group(d.alloc_id(), vec![stroke]); + copies.opacity = opacity; + let mut clip = Node::group(d.alloc_id(), vec![fill, Arc::new(copies)]); + clip.extras_mut().clip_pieces = true; + let black = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), black); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }) + }; + let shown = grouped(1.0); + assert_eq!(plot(&shown, &opts(&shown)).pens, [[0, 0, 0], [255, 0, 0]]); + // In a group at 0% opacity they are not drawn, and the clipping path is drawn in its fill. + let faded = grouped(0.0); + assert_eq!(plot(&faded, &opts(&faded)).pens, [[0, 0, 255], [0, 0, 0]]); + // An open clipping path with a fill is drawn closed also where its stroke's effect moved it. + let open_moved = |fill_opacity: f32| { + doc_with(|d| { + let mut open = BezPath::new(); + open.move_to((20.0, 20.0)); + open.line_to((40.0, 20.0)); + open.line_to((40.0, 40.0)); + let a = Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0); + let mut clip = Node::path(d.alloc_id(), PathData::from_bezpath(&open), a); + for item in &mut clip.appearance.items { + match item { + AppearanceItem::Fill(f) => f.opacity = fill_opacity, + AppearanceItem::Stroke(s) => { + let params = serde_json::json!({"moveH": 5}); + s.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params, visible: true }); + } + } + } + let black = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), black); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }) + }; + let filled = open_moved(1.0); + let t = text(&plot(&filled, &opts(&filled))); + assert!(t.ends_with("SP2;PU353,1129;PD635,1129,635,847,353,1129;PU;SP0;"), "{t}"); + // A fill at 0% opacity does not close it. + let unfilled = open_moved(0.0); + let t = text(&plot(&unfilled, &opts(&unfilled))); + assert!(t.ends_with("SP2;PU353,1129;PD635,1129,635,847;PU;SP0;"), "{t}"); } /// A symbol `art` and an instance of it at its place. diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index 7fd8707a99..3ad4820ce1 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -94,6 +94,50 @@ impl Intent { } } +/// A color management policy (Edit → Color Settings): what opening a document does with a profile +/// it is tagged with that is not the working space of its model. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Policy { + /// The document loses the profile; its color numbers stay as they are. + Off, + /// The document keeps the profile. + #[default] + PreserveEmbedded, + /// The colors are converted from the profile into the working space, which keeps their + /// appearance, and the document loses the profile. + Convert, + /// CMYK only: the document loses the profile; its color numbers stay as they are. + PreserveNumbers, +} + +impl Policy { + /// The RGB policies, in menu order. + pub const RGB: [Policy; 3] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert]; + /// The CMYK policies, in menu order. + pub const CMYK: [Policy; 4] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers]; + /// Parse an id of [`Policy::id`]. + pub fn parse(s: &str) -> Option { + [Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers].into_iter().find(|p| p.id() == s) + } + pub fn id(&self) -> &'static str { + match self { + Policy::Off => "off", + Policy::PreserveEmbedded => "preserveEmbedded", + Policy::Convert => "convert", + Policy::PreserveNumbers => "preserveNumbers", + } + } + pub fn label(&self) -> &'static str { + match self { + Policy::Off => "Off", + Policy::PreserveEmbedded => "Preserve Embedded Profiles", + Policy::Convert => "Convert to Working Space", + Policy::PreserveNumbers => "Preserve Numbers", + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProfileKind { @@ -134,11 +178,25 @@ pub struct ColorSettings { pub intent: Intent, /// Black-point compensation. pub bpc: bool, + /// The color management policy for RGB profiles (one of [`Policy::RGB`]). Settings saved + /// without one take the default. + #[serde(default)] + pub rgb_policy: Policy, + /// The color management policy for CMYK profiles. Settings saved without one take the default. + #[serde(default)] + pub cmyk_policy: Policy, } impl Default for ColorSettings { fn default() -> Self { - Self { rgb: SRGB.into(), cmyk: GENERIC_CMYK.into(), intent: Intent::RelativeColorimetric, bpc: true } + Self { + rgb: SRGB.into(), + cmyk: GENERIC_CMYK.into(), + intent: Intent::RelativeColorimetric, + bpc: true, + rgb_policy: Policy::default(), + cmyk_policy: Policy::default(), + } } } @@ -518,6 +576,41 @@ impl Cms { Color::Rgb { r, g, b } } + /// An RGB or CMYK colour of this context's space as the colour of the same model in `dest`'s + /// space that looks the same, converted with `intent` (Edit → Color Settings › Convert to + /// Working Space). RGB converts directly from profile to profile, and CMYK between two loaded + /// profiles through ProPhoto RGB ([`IccProfile::to_same_model_of`]), both with this context's + /// black-point compensation; CMYK goes through Lab when either space is built in. Grey and + /// Lab colours stay as they are. + pub fn convert_into(&self, dest: &Cms, c: &Color, intent: Intent) -> Color { + match *c { + Color::Rgb { r, g, b } => { + let icc = |s: &RgbSpace| match s { + RgbSpace::Icc(p) => Some(p.clone()), + RgbSpace::Srgb => builtin_rgb_cache(SRGB), + }; + let [r, g, b] = icc(&self.rgb) + .zip(icc(&dest.rgb)) + .and_then(|(s, d)| s.to_rgb_of(&d, &[r, g, b], intent, self.settings.bpc)) + .unwrap_or_else(|| dest.srgb_to_rgb(self.rgb_to_srgb([r, g, b]))); + Color::Rgb { r, g, b } + } + Color::Cmyk { c, m, y, k } => { + let inks = [c, m, y, k].map(|v| v.clamp(0.0, 1.0)); + let direct = match (&self.cmyk, &dest.cmyk) { + (CmykSpace::Icc(s), CmykSpace::Icc(d)) => s.to_same_model_of(d, &inks, intent, self.settings.bpc), + _ => None, + }; + let [c, m, y, k] = match direct.as_deref() { + Some(&[c, m, y, k]) => [c, m, y, k], + _ => dest.lab_to_cmyk(self.cmyk_to_lab(inks), intent), + }; + Color::Cmyk { c, m, y, k } + } + Color::Gray { .. } | Color::Lab { .. } => *c, + } + } + /// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours). pub fn gamut_error(&self, c: &Color) -> f32 { match c { @@ -966,8 +1059,11 @@ pub fn working_settings() -> ColorSettings { } /// Replace the working settings (Edit → Color Settings) and make them the active settings (fails, -/// leaving both unchanged, if a profile is unknown). +/// leaving both unchanged, if a profile is unknown or the RGB policy is Preserve Numbers). pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> { + if !Policy::RGB.contains(&settings.rgb_policy) { + return Err(CmsError::Unsupported(format!("the RGB policy can't be {}", settings.rgb_policy.id()))); + } let cms = Arc::new(Cms::new(settings)?); *WORKING.write().unwrap_or_else(|e| e.into_inner()) = Some(cms.clone()); install(cms); diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index f6a2316b89..ffddd6c955 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -34,6 +34,17 @@ type Xf = Option>; /// generation only lets the cache drop what reloads left behind. type DirectKey = (u64, u64, usize, bool); +/// The transforms of [`IccProfile::to_same_model_of`]: into ProPhoto RGB's primaries with linear +/// tone curves, where black-point compensation is a linear remap, and from there into the +/// destination. +struct SameModel { + into: Arc, + out_of: Arc, + /// The source's and the destination's black as luminance in the linear space (both 0 without + /// compensation). + black: [f32; 2], +} + /// A parsed ICC profile plus lazily-built transforms to/from sRGB. pub struct IccProfile { pub name: String, @@ -49,6 +60,9 @@ pub struct IccProfile { /// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent /// and black-point compensation. direct: Mutex>)>>, + /// Transforms into other profiles of the same model ([`Self::to_same_model_of`]), keyed as + /// [`Self::direct`] is. + same_model: Mutex>)>>, } /// A transform from one profile into an RGB profile without passing through sRGB: into the @@ -99,6 +113,7 @@ impl IccProfile { to_srgb: Default::default(), from_srgb: Default::default(), direct: Mutex::new(Vec::new()), + same_model: Mutex::new(Vec::new()), }) } @@ -264,6 +279,69 @@ impl IccProfile { Some(Direct { to_linear, to_dest: Some(to_dest), black }) } + /// Device values of this profile → device values of `dest`, a profile of the same model, + /// converted with `intent` through ProPhoto RGB rather than sRGB, so colours outside sRGB + /// keep their place. With `bpc` (and an intent other than absolute), this profile's black + /// maps onto the destination's black (black-point compensation). `None` when `dest` is of + /// another model or no transform can be built. + pub fn to_same_model_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option> { + if dest.kind != self.kind || v.len() != self.channels() { + return None; + } + let bpc = bpc && intent != Intent::AbsoluteColorimetric; + let generation = profiles_generation(); + let key = (dest.id, generation, intent_index(intent), bpc); + let t = { + let mut cache = self.same_model.lock().unwrap_or_else(|e| e.into_inner()); + cache.retain(|(k, _)| k.1 == generation); + match cache.iter().find(|(k, _)| *k == key) { + Some((_, t)) => t.clone(), + None => { + let t = self.build_same_model(dest, intent, bpc).map(Arc::new); + cache.push((key, t.clone())); + t + } + } + }?; + let mut lin = [0.0f32; 3]; + t.into.transform(v, &mut lin).ok()?; + let [from, to] = t.black; + if from > 0.0 || to > 0.0 { + lin = lin.map(|x| to + (x - from) * (1.0 - to) / (1.0 - from)); + } + let lin = lin.map(|x| x.clamp(0.0, 1.0)); + let mut out = vec![0.0f32; dest.channels()]; + t.out_of.transform(&lin, &mut out).ok()?; + Some(out.into_iter().map(|x| x.clamp(0.0, 1.0)).collect()) + } + + fn build_same_model(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option { + let mut linear = rgb_space(super::PROPHOTO_RGB)?; + for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] { + *trc = Some(ToneReprCurve::Parametric(vec![1.0])); + } + linear.cicp = None; + let into = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?; + let out_of = linear.create_transform_f32(Layout::Rgb, &dest.profile, dest.layout(), Self::opts(intent)).ok()?; + let black = if bpc { + // Each profile's black: the darkest colour it reproduces, found as `build_direct` + // finds it, as luminance in the linear space. + let luminance = |p: &IccProfile, xf: &Arc| -> Option { + let inks = p.from_srgb([0.0; 3], Intent::RelativeColorimetric)?; + let mut lin = [0.0f32; 3]; + xf.transform(&inks, &mut lin).ok()?; + let y = |c: &moxcms::Xyzd| c.y as f32; + let lum = lin[0] * y(&linear.red_colorant) + lin[1] * y(&linear.green_colorant) + lin[2] * y(&linear.blue_colorant); + Some(lum.clamp(0.0, 0.5)) + }; + let back = dest.profile.create_transform_f32(dest.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?; + [luminance(self, &into)?, luminance(dest, &back)?] + } else { + [0.0; 2] + }; + Some(SameModel { into, out_of, black }) + } + /// Whether transforms can be built for this profile (checked at registration). pub fn usable(&self) -> bool { self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some() diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 7b22163525..95e465b623 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -442,3 +442,39 @@ fn a_document_gets_the_same_context_for_the_same_profiles() { let unknown = for_document(Some("no such profile"), Some(SRGB)); assert_eq!(unknown.settings(), &working_settings(), "an unknown profile or one of the other model leaves the working space"); } + +/// The color management policies parse from their ids, Preserve Numbers is a CMYK policy only, and +/// settings saved before the policies existed read with the default policy. +#[test] +fn policies_parse_and_default_in_saved_settings() { + for p in Policy::CMYK { + assert_eq!(Policy::parse(p.id()), Some(p)); + } + assert_eq!(Policy::parse("bogus"), None); + assert!(!Policy::RGB.contains(&Policy::PreserveNumbers)); + let old: ColorSettings = + serde_json::from_str(r#"{"rgb": "sRGB IEC61966-2.1", "cmyk": "Device CMYK (uncalibrated)", "intent": "perceptual", "bpc": false}"#).unwrap(); + assert_eq!((old.rgb_policy, old.cmyk_policy), (Policy::PreserveEmbedded, Policy::PreserveEmbedded)); + let saved = serde_json::to_value(ColorSettings { cmyk_policy: Policy::PreserveNumbers, ..Default::default() }).unwrap(); + assert_eq!((saved["rgbPolicy"].as_str(), saved["cmykPolicy"].as_str()), (Some("preserveEmbedded"), Some("preserveNumbers"))); +} + +/// Convert to Working Space keeps a color's appearance: an RGB color of a wide space converts into +/// sRGB, and a CMYK color of one CMYK space into another, with numbers that look the same. Grey and +/// Lab colors stay as they are. +#[test] +fn convert_into_keeps_the_appearance() { + let wide = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap(); + let working = cms(); + let rgb = Color::Rgb { r: 0.25, g: 0.4, b: 0.6 }; + let moved = wide.convert_into(&working, &rgb, Intent::RelativeColorimetric); + assert_ne!(moved, rgb, "the numbers change"); + assert!(de(wide.display_rgb(&rgb), working.display_rgb(&moved)) < 1.0, "{rgb:?} → {moved:?}"); + let ink = Color::Cmyk { c: 0.2, m: 0.4, y: 0.6, k: 0.1 }; + let moved = wide.convert_into(&working, &ink, Intent::RelativeColorimetric); + assert!(matches!(moved, Color::Cmyk { .. }) && moved != ink, "{ink:?} → {moved:?}"); + assert!(delta_e2000(wide.lab(&ink), working.lab(&moved)) < 3.0, "{ink:?} → {moved:?}"); + for c in [Color::Gray { k: 0.3 }, Color::Lab { l: 50.0, a: 10.0, b: -20.0 }] { + assert_eq!(wide.convert_into(&working, &c, Intent::RelativeColorimetric), c); + } +} diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index b2920c7c7b..bee0f0f9fa 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -274,3 +274,55 @@ fn settings_built_before_a_reload_dont_leave_the_old_transform_to_the_new_ones() assert!(in_srgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "sRGB {in_srgb:?} vs ProPhoto {want:?}"); assert!(in_prophoto.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {in_prophoto:?}, want {want:?}"); } + +/// A CMYK profile converts into another CMYK profile, ink for ink, and refuses a profile +/// of another model or inks of the wrong count. +#[test] +fn cmyk_profiles_convert_into_each_other_directly() { + let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap(); + let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap(); + let srgb = IccProfile::from_bytes(None, &icc_bytes(SRGB).unwrap()).unwrap(); + let inks = [0.2, 0.4, 0.6, 0.1]; + let out = generic.to_same_model_of(&device, &inks, Intent::RelativeColorimetric, false).unwrap(); + assert_eq!(out.len(), 4); + assert!(out.iter().zip(inks).any(|(a, b)| (a - b).abs() > 0.01), "{out:?}"); + assert!(generic.to_same_model_of(&srgb, &inks, Intent::RelativeColorimetric, false).is_none()); + assert!(generic.to_same_model_of(&device, &inks[..3], Intent::RelativeColorimetric, false).is_none()); +} + +/// With black-point compensation, a CMYK profile's darkest ink converts into an ink as dark as a +/// darker profile's darkest ink, and a dark grey of a darker profile converts lighter into a +/// profile whose black is lighter; absolute colorimetric never compensates. +#[test] +fn cmyk_to_cmyk_compensates_the_black_point() { + let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap(); + let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap(); + let rel = Intent::RelativeColorimetric; + let lightness = |p: &IccProfile, inks: &[f32]| lab::srgb_to_lab(p.to_srgb(inks, rel).unwrap()).l; + let black = |p: &IccProfile| p.from_srgb([0.0; 3], rel).unwrap(); + // Generic CMYK's black is a dark grey, the uncalibrated profile's is black. + let (light, dark) = (lightness(&generic, &black(&generic)), lightness(&device, &black(&device))); + assert!(light > dark + 5.0, "{light} vs {dark}"); + let into_device = |bpc| lightness(&device, &generic.to_same_model_of(&device, &black(&generic), rel, bpc).unwrap()); + let (on, off) = (into_device(true), into_device(false)); + assert!((on - dark).abs() < 1.5 && off > dark + 5.0, "with {on}, without {off}, the black {dark}"); + let grey = device.from_srgb([0.2; 3], rel).unwrap(); + let into_generic = |bpc| lightness(&generic, &device.to_same_model_of(&generic, &grey, rel, bpc).unwrap()); + let (on, off) = (into_generic(true), into_generic(false)); + assert!(on > off + 1.0, "with {on}, without {off}"); + for (from, to, inks) in [(&generic, &device, black(&generic)), (&device, &generic, grey)] { + let abs = |bpc| from.to_same_model_of(to, &inks, Intent::AbsoluteColorimetric, bpc).unwrap(); + assert_eq!(abs(true), abs(false)); + } + // Convert to Working Space (Cms::convert_into) compensates as Color Settings say. + let device_name = register_icc(&icc_bytes(DEVICE_CMYK).unwrap(), Some("Test ICC Device CMYK".into())).unwrap().name; + let rich = Color::Cmyk { c: 0.9, m: 0.8, y: 0.8, k: 1.0 }; + let convert = |bpc| { + let dest = Cms::new(&ColorSettings { cmyk: device_name.clone(), bpc, ..ColorSettings::default() }).unwrap(); + icc_cmyk(bpc).convert_into(&dest, &rich, rel) + }; + assert_ne!(convert(true), convert(false)); + // White is untouched. + let white = generic.to_same_model_of(&device, &[0.0; 4], rel, true).unwrap(); + assert!(white.iter().all(|v| *v < 0.01), "{white:?}"); +} diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 71c6642846..6339f0a4ca 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -525,8 +525,8 @@ fn planar(d: &mut tiff::decoder::Decoder) - } /// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color -/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file -/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to +/// type has), pixel by pixel and 8 bits each (16-bit samples rounded to the nearest), whether the +/// file stores each pixel's samples together or one plane per channel. `None` when they don't decode to /// that many samples, which includes images whose planes together need more than the decoder's /// limit (256 MiB of samples), and, before any decoding, files with more than /// [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel, files whose samples aren't unsigned integers (a @@ -535,7 +535,7 @@ fn planar(d: &mut tiff::decoder::Decoder) - pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { Some(match read_samples_at_depth(d, n, per)? { Samples::Eight(v) => v, - Samples::Sixteen(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + Samples::Sixteen(v) => v.iter().map(|&s| u8::try_from((u32::from(s) + 128) / 257).unwrap_or(u8::MAX)).collect(), }) } @@ -618,13 +618,25 @@ pub fn icc_color_space(icc: &[u8]) -> Option<[u8; 4]> { icc.get(16..20)?.try_into().ok() } -/// The ICC profile of TIFF tag 34675 (ICCProfile) of the image `d` reads; `None` without one. +/// The most bytes of an ICC profile a TIFF's tag 34675 is read with (16 MiB). +const MAX_TIFF_ICC: u64 = 16 << 20; + +/// The ICC profile of TIFF tag 34675 (ICCProfile, of type UNDEFINED or BYTE) of the image `d` +/// reads, as its bytes; `None` without one and for one over [`MAX_TIFF_ICC`] bytes. fn tiff_icc(d: &mut tiff::decoder::Decoder>) -> Option> { - d.get_tag_u8_vec(tiff::tags::Tag::IccProfile).ok().filter(|icc| !icc.is_empty()) + use tiff::tags::{Tag, Type, ValueBuffer}; + let mut ifd = d.image_ifd(); + let entry = ifd.find_entry(Tag::IccProfile)?; + if !matches!(entry.field_type(), Type::UNDEFINED | Type::BYTE) || entry.count() == 0 || entry.count() > MAX_TIFF_ICC { + return None; + } + let mut buf = ValueBuffer::empty(Type::UNDEFINED); + ifd.find_tag_buf(Tag::IccProfile, &mut buf).ok()??; + Some(buf.as_bytes().to_vec()) } -/// The ICC profile of TIFF `bytes`' first image (tag 34675), read with the `tiff` decoder's own -/// limits, whatever the size of the image; `None` for other images and TIFFs without one. +/// The ICC profile of TIFF `bytes`' first image (tag 34675), up to [`MAX_TIFF_ICC`] bytes whatever +/// the size of the image; `None` for other images, TIFFs without one and a longer one. pub fn tiff_icc_profile(bytes: &[u8]) -> Option> { let (_, mut d) = tiff(bytes)?; tiff_icc(&mut d) @@ -638,6 +650,61 @@ fn jpeg_icc(bytes: &[u8]) -> Option> { d.icc_profile().filter(|icc| !icc.is_empty()) } +/// The bytes after an `ICC_PROFILE` APP2 segment's length: its name, then the segment's number and +/// the count of segments. +const ICC_SEGMENT: &[u8; 12] = b"ICC_PROFILE\0"; + +/// JPEG `bytes` carrying `icc` in place of the ICC profile they carry, if any: every +/// `ICC_PROFILE` APP2 segment before the scan taken out, `icc` written as numbered APP2 segments +/// after the leading application segments, and the other segments and the scan as they are. `None` +/// for bytes whose segments before the scan can't be read, an empty `icc`, and one longer than 255 +/// segments hold. +pub fn jpeg_with_icc(bytes: &[u8], icc: &[u8]) -> Option> { + // A segment's length counts itself (2 bytes) and the name, number and count (14). + const CHUNK: usize = 0xFFFF - 2 - ICC_SEGMENT.len() - 2; + let chunks: Vec<&[u8]> = icc.chunks(CHUNK).collect(); + let count = u8::try_from(chunks.len()).ok().filter(|n| *n > 0)?; + if !bytes.starts_with(&[0xFF, 0xD8]) { + return None; + } + let mut segments = Vec::with_capacity(icc.len() + 18 * chunks.len()); + for (i, chunk) in chunks.iter().enumerate() { + let len = u16::try_from(chunk.len() + 2 + ICC_SEGMENT.len() + 2).ok()?; + segments.extend_from_slice(&[0xFF, 0xE2]); + segments.extend_from_slice(&len.to_be_bytes()); + segments.extend_from_slice(ICC_SEGMENT); + segments.extend_from_slice(&[u8::try_from(i + 1).ok()?, count]); + segments.extend_from_slice(chunk); + } + let mut out = Vec::with_capacity(bytes.len() + segments.len()); + out.extend_from_slice(&[0xFF, 0xD8]); + let mut icc_segments = Some(segments); + let mut at = 2; + // The segments before the scan (SOS), each with its length; a marker without one ends them. + // Fill bytes (0xFF) before a marker are left out. + loop { + while bytes.get(at..at + 2) == Some(&[0xFF, 0xFF]) { + at += 1; + } + let (Some(&0xFF), Some(&marker)) = (bytes.get(at), bytes.get(at + 1)) else { return None }; + let app = (0xE0..=0xEF).contains(&marker); + if !app && let Some(s) = icc_segments.take() { + out.extend_from_slice(&s); + } + if matches!(marker, 0xDA | 0xD8 | 0xD9 | 0x01 | 0xD0..=0xD7) { + break; + } + let len = usize::from(u16::from_be_bytes(bytes.get(at + 2..at + 4)?.try_into().ok()?)); + let segment = bytes.get(at..(at + 2).checked_add(len)?).filter(|_| len >= 2)?; + if !(marker == 0xE2 && segment.get(4..16) == Some(ICC_SEGMENT.as_slice())) { + out.extend_from_slice(segment); + } + at += segment.len(); + } + out.extend_from_slice(bytes.get(at..)?); + Some(out) +} + /// `icc` when it is a profile of CMYK colors, which the samples of a CMYK image are in. fn cmyk_icc(icc: Option>) -> Option> { icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK")) @@ -746,6 +813,14 @@ impl ImageBlob { Some(Self::new("image/tiff", tiff)) } + /// [`ImageBlob::cmyk_tiff`], carrying `icc` as tag 34675 (ICCProfile) when it is a profile of + /// CMYK colors; any other profile is left out. The caller checks the rest of the profile. + pub fn cmyk_tiff_with_icc(inks: &Inks, icc: Option<&[u8]>) -> Option { + let icc = icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK")); + let tiff = write_cmyk_tiff(inks.width, inks.height, SampleSlice::Eight(&inks.data), None, icc)?; + Some(Self::new("image/tiff", tiff)) + } + /// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its /// header. pub fn is_cmyk(&self) -> bool { @@ -1968,6 +2043,35 @@ mod tests { assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]); } + /// A CMYK TIFF written with a CMYK profile carries it byte for byte as tag 34675; a profile of + /// other colors, and none, leave the TIFF untagged. The inks are the same either way. + #[test] + fn a_cmyk_tiff_carries_the_cmyk_profile_it_is_given() { + let profile = |space: &[u8; 4]| { + let mut icc = vec![0u8; 128]; + icc[0..4].copy_from_slice(&128u32.to_be_bytes()); + icc[16..20].copy_from_slice(space); + icc[36..40].copy_from_slice(b"acsp"); + icc + }; + let cmyk = profile(b"CMYK"); + let b = ImageBlob::cmyk_tiff_with_icc(&inks(), Some(&cmyk)).unwrap(); + assert_eq!(b.cmyk_icc_profile(), Some(cmyk), "the profile, byte for byte"); + assert_eq!(b.cmyk(), Some(inks())); + // Read back up to 16 MiB; a longer one isn't read. + for (len, read) in [(16 << 20, true), ((16 << 20) + 1, false)] { + let mut big = profile(b"CMYK"); + big.resize(len, 0); + let b = ImageBlob::cmyk_tiff_with_icc(&inks(), Some(&big)).unwrap(); + assert_eq!(b.cmyk_icc_profile().is_some_and(|p| p == big), read, "{len} bytes"); + } + for icc in [Some(profile(b"RGB ")), None] { + let b = ImageBlob::cmyk_tiff_with_icc(&inks(), icc.as_deref()).unwrap(); + assert_eq!(tiff_icc_profile(&b.bytes), None, "{icc:?}"); + assert_eq!(b.cmyk(), Some(inks())); + } + } + #[test] fn a_header_gives_the_size_without_the_samples() { assert_eq!(ImageBlob::cmyk_tiff(&inks()).unwrap().header_size(), Some((3, 2))); @@ -2031,6 +2135,53 @@ mod tests { } } + /// A CMYK JPEG given a profile carries it in place of its own, its samples as they were; a + /// profile longer than 255 segments hold, an empty one and bytes that aren't a JPEG give none. + #[test] + fn a_cmyk_jpeg_carries_the_profile_it_is_given() { + let profile = |len: usize, mark: u8| { + let mut icc = vec![mark; len]; + icc[16..20].copy_from_slice(b"CMYK"); + icc + }; + let px: Vec = (0..16 * 16).flat_map(|_| [10u8, 40, 100, 20]).collect(); + let mut jpg = Vec::new(); + let mut e = jpeg_encoder::Encoder::new(&mut jpg, 100); + e.add_icc_profile(&profile(300_000, 1)).unwrap(); + e.encode(&px, 16, 16, jpeg_encoder::ColorType::Cmyk).unwrap(); + let inks = ImageBlob::new("image/jpeg", jpg.clone()).cmyk().unwrap(); + // One segment holds 65,519 bytes of a profile. + for len in [128, 65_519, 65_520, 300_000, 1 << 20] { + let icc = profile(len, 2); + let b = ImageBlob::new("image/jpeg", jpeg_with_icc(&jpg, &icc).unwrap()); + assert!(b.cmyk_icc_profile() == Some(icc), "{len} bytes"); + assert!(b.cmyk() == Some(inks.clone()), "{len} bytes: the samples as they were"); + } + // A profile segment after the quantization table is taken out too. + let dqt = jpg.windows(2).position(|w| w == [0xFF, 0xDB]).unwrap(); + let after = dqt + 2 + usize::from(u16::from_be_bytes([jpg[dqt + 2], jpg[dqt + 3]])); + let mut late = jpg.clone(); + let stale = profile(128, 3); + let segment = [&[0xFF, 0xE2, 0, 144][..], ICC_SEGMENT, &[1, 1], &stale].concat(); + late.splice(after..after, segment); + let icc = profile(128, 2); + let b = jpeg_with_icc(&late, &icc).unwrap(); + assert_eq!(b.windows(ICC_SEGMENT.len()).filter(|w| *w == ICC_SEGMENT).count(), 1, "one profile segment"); + assert!(ImageBlob::new("image/jpeg", b).cmyk_icc_profile() == Some(icc)); + // Fill bytes before the quantization table's marker are passed over. + let mut filled = jpg.clone(); + filled.splice(dqt..dqt, [0xFF; 3]); + assert!(ImageBlob::new("image/jpeg", filled.clone()).cmyk() == Some(inks.clone()), "the decoder reads it"); + let icc = profile(128, 2); + let b = ImageBlob::new("image/jpeg", jpeg_with_icc(&filled, &icc).unwrap()); + assert!(b.cmyk_icc_profile() == Some(icc), "fill bytes"); + assert!(b.cmyk() == Some(inks.clone()), "fill bytes: the samples as they were"); + assert!(jpeg_with_icc(&jpg, &profile(255 * 65_519, 2)).is_some(), "255 segments"); + assert!(jpeg_with_icc(&jpg, &profile(255 * 65_519 + 1, 2)).is_none(), "256 segments"); + assert!(jpeg_with_icc(&jpg, &[]).is_none(), "empty"); + assert!(jpeg_with_icc(&ImageBlob::cmyk_tiff(&inks).unwrap().bytes, &profile(128, 2)).is_none(), "a TIFF"); + } + #[test] fn other_images_are_not_cmyk() { let mut png = Vec::new(); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 5885a0ff54..d49776b2cb 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -266,6 +266,7 @@ pub fn hit_test_visible_skipping(doc: &Document, p: Point, opt: HitOptions, skip /// (`opt.scope`) or on a locked artboard. On a layer whose Preview is off it counts by its /// outline, as a click there hits it. pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &[NodeId]) -> Option { + let _walk = ArtWalk::open(); let symbols = doc.symbol_boxes(); let locks = doc.locked_artboards(); selected.iter().rev().copied().find(|&id| { @@ -322,6 +323,7 @@ fn path_options(nodes: &[&Node], chain: &[NodeId], opt: HitOptions) -> Option) -> Vec { + let walk = ArtWalk::open(); let opt = HitOptions { scope: scope.or(opt.scope), ..opt }; // Normally one hit test per *object* suffices: layers and sublayers aren't selectable, // while a group (including a clipping group) is selected as one object. Repeatedly @@ -337,6 +339,7 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option Vec { + let _walk = ArtWalk::open(); let symbols = doc.symbol_boxes(); let mut out = Vec::new(); if let Some(scope) = opt.scope @@ -504,8 +510,9 @@ fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editab /// [`hit_test`] passing over the nodes `skip` names (and their contents), and with /// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of /// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art -/// ([`Node::geometric_bounds_with`]). +/// ([`Node::geometric_bounds_with`]), and are also hit outside that box where their art paints. fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option { + let _walk = ArtWalk::open(); let finish = |mut h: Hit| { h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count(); @@ -1055,9 +1062,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill), }) } - NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => { + NodeKind::Text(_) | NodeKind::PlacedDocument(_) => { n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } + // A symbol instance is hit within its bounds, and outside them where its art paints + // ([`instance_art_hit`]). In the art of another instance walked outside that instance's + // bounds, it is hit only where its own art paints ([`ArtOnly`]). + NodeKind::SymbolInstance { .. } => { + if !ArtOnly::on() && n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return Some(HitKind::Bounds); + } + instance_art_hit(n, p, opt, symbols) + } // A blend or an envelope is hit within its bounds, and outside them where its evaluated // art paints: a blend's keys and steps on a spine can reach past the keys and the spine, // and the strokes of an envelope's distorted contents past its bounds. @@ -1164,6 +1180,15 @@ impl<'a> InstanceArt<'a> { whole.or_else(|| self.map.as_ref()?.one_cell_with(n, &self.boxes)) } + /// How many objects placing the whole art makes: the objects of the art with their opacity + /// masks' art ([`Node::count_with_masks`]), or, through the guides, the most that + /// [`crate::NineSliceMap::most_placed_with`] counts. + fn size(&self) -> u64 { + self.map + .as_ref() + .map_or_else(|| u64::try_from(self.art.count_with_masks()).unwrap_or(u64::MAX), |m| m.most_placed_with(&self.art, &self.boxes)) + } + /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as @@ -1211,64 +1236,198 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// layer, is tested in outline ([`HitOptions::outline`]), as the canvas draws it. `None` when `inst` is not a symbol instance or /// no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { - struct Find<'a> { - art: &'a InstanceArt<'a>, - p: Point, - opt: HitOptions, - symbols: &'a SymbolBoxes<'a>, - } - impl Find<'_> { - fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> bool { - self.art.place(n, whole).is_some_and(|n| hit_any(&n, self.p, opt, self.symbols).is_some()) - } - fn skips_guide(&self, n: &Node) -> bool { - self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) - } - /// `outline`: the view is in Outline, or a layer around `n` has Preview off. - fn find(&self, n: &Node, path: &mut Vec, whole: Option, outline: bool) -> bool { - if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { - return false; - } - let outline = outline || previews_off(n); - let opt = HitOptions { outline, ..self.opt }; - let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; - if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { - return self.hit(n, whole, opt); - } - let whole = self.art.whole(n, whole); - if n.clips() - && let Some(first) = children.first() - { - let Some(clip) = self.art.place_unclipped(first, whole) else { return false }; - if self.opt.clip_outlines && first.visible && !first.locked && !self.skips_guide(first) && clip_outline(&clip, self.p, opt).is_some() - { - path.push(0); - return true; - } - if !clip_contains(&clip, self.p, outline) { - return false; - } - } - for (i, c) in children.iter().enumerate().rev() { - // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. - if shaper_hides(n, i, None) { - continue; - } - let Ok(i) = u32::try_from(i) else { continue }; - path.push(i); - if self.find(c, path, whole, outline) { - return true; - } - path.pop(); - } - false - } - } + let _walk = ArtWalk::open(); let art = InstanceArt::of(doc, inst)?; let symbols = doc.symbol_boxes(); let mut path = vec![]; let outline = opt.outline || below_preview_off(doc, inst, opt.scope); - Find { art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None, outline).then_some(path) + let find = FindPart { art: &art, p, opt, symbols: &symbols, whole_instance: false }; + find.find(&art.art, &mut path, None, outline).map(|_| path) +} + +/// The walk of a symbol instance's placed art that [`symbol_part_at`] and [`instance_art_hit`] make. +struct FindPart<'a> { + art: &'a InstanceArt<'a>, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + /// For [`instance_art_hit`]: locked and template objects of the art count, as the canvas draws + /// them, and clipping paths are not picked by their outline. + whole_instance: bool, +} + +impl FindPart<'_> { + fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> Option { + self.art.place(n, whole).and_then(|n| hit_any(&n, self.p, opt, self.symbols)) + } + + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + + /// What a click at `p` hits on object `n` of the art, with `path` the child indices that lead + /// to what it hits. `outline`: the view is in Outline, or a layer around `n` has Preview off. + fn find(&self, n: &Node, path: &mut Vec, whole: Option, outline: bool) -> Option { + if !n.visible || !self.whole_instance && (n.locked || n.is_template()) || self.skips_guide(n) || !ArtWalk::take(1) { + return None; + } + let outline = outline || previews_off(n); + let opt = HitOptions { outline, ..self.opt }; + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole, opt); + } + let whole = self.art.whole(n, whole); + if n.clips() + && let Some(first) = children.first() + { + let clip = self.art.place_unclipped(first, whole)?; + if !self.whole_instance + && self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && let Some(kind) = clip_outline(&clip, self.p, opt) + { + path.push(0); + return Some(kind); + } + if !clip_contains(&clip, self.p, outline) { + return None; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if let Some(kind) = self.find(c, path, whole, outline) { + return Some(kind); + } + path.pop(); + } + None + } +} + +/// What a click at `p` hits of symbol instance `n`'s art where the canvas draws it (`None` in +/// Outline, [`HitOptions::outline`]): its strokes, and the art that the live geometry effects of a +/// clipping path in it show past the art's box. `None` for an instance of a missing symbol, of a +/// symbol that holds an instance of itself or nests too deep ([`SymbolBoxes::art`]), and of art past +/// the 9-slice object limit ([`InstanceArt::of`]), when `p` is outside the instance's reach +/// ([`Node::reach_bounds_with`]), and once the click has visited as many objects of symbol art as +/// [`ArtWalk`] allows. +fn instance_art_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return None }; + if opt.outline || symbols.art(symbol).is_none() || !n.reach_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return None; + } + let _walk = ArtWalk::open(); + if ArtWalk::used_up() { + return None; + } + let art = InstanceArt::of(symbols.document(), n)?; + #[cfg(test)] + ARTS_WALKED.with(|c| c.set(c.get() + 1)); + let opt = HitOptions { clip_outlines: false, ..opt }; + let _art_only = ArtOnly::set(); + FindPart { art: &art, p, opt, symbols, whole_instance: true }.find(&art.art, &mut vec![], None, false) +} + +thread_local! { + /// Whether a symbol instance's art is being walked outside the instance's box ([`ArtOnly`]). + static ART_ONLY: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// While a guard lives, a symbol instance is hit ([`hit_leaf`]) and touched by a marquee +/// ([`marquee_with`]) only where its own art is, by the same rule at every level, and not anywhere +/// in its box. [`instance_art_hit`] and the marquee's walk of an instance's art hold one: they walk +/// the art outside the instance's box, where the box of an instance nested in the art can reach +/// past it over empty space. +struct ArtOnly { + was: bool, +} + +impl ArtOnly { + fn set() -> Self { + Self { was: ART_ONLY.replace(true) } + } + + fn on() -> bool { + ART_ONLY.get() + } +} + +impl Drop for ArtOnly { + fn drop(&mut self) { + ART_ONLY.set(self.was); + } +} + +thread_local! { + /// How many more objects of symbol art the click or marquee under way may visit ([`ArtWalk`]), + /// `None` outside one. + static ART_WALK_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +thread_local! { + /// The number of objects [`ArtWalk::open`] allows on this thread in place of + /// [`crate::MAX_SYMBOL_NODES`]. + static ART_WALK_LIMIT: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// How many symbol instances' art clicks and marquees walked on this thread. + static ARTS_WALKED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The objects of symbol art that one click or one marquee may visit: [`crate::MAX_SYMBOL_NODES`] +/// in all, the art of the instances nested in that art included, at every level. The first +/// [`ArtWalk::open`] of a click or a marquee sets the limit, and the walks opened while its guard +/// lives share it. Once the limit is used up, an instance is no longer hit or touched outside its +/// box; inside its box it still is. +struct ArtWalk { + outermost: bool, +} + +impl ArtWalk { + fn open() -> Self { + let walk = Self { outermost: ART_WALK_LEFT.get().is_none() }; + walk.restart(); + walk + } + + /// For the outermost guard, sets what is left back to the whole limit. + fn restart(&self) { + if self.outermost { + #[cfg(test)] + let limit = ART_WALK_LIMIT.get().unwrap_or(crate::MAX_SYMBOL_NODES); + #[cfg(not(test))] + let limit = crate::MAX_SYMBOL_NODES; + ART_WALK_LEFT.set(Some(limit)); + } + } + + /// Is nothing left? + fn used_up() -> bool { + ART_WALK_LEFT.get() == Some(0) + } + + /// Takes `n` objects from what is left. `false` when fewer are left, and nothing is left after. + fn take(n: u64) -> bool { + let Some(left) = ART_WALK_LEFT.get() else { return true }; + let rest = left.checked_sub(n); + ART_WALK_LEFT.set(Some(rest.unwrap_or(0))); + rest.is_some() + } +} + +impl Drop for ArtWalk { + fn drop(&mut self) { + if self.outermost { + ART_WALK_LEFT.set(None); + } + } } /// Is `n` below a layer whose Preview is off, so that the canvas draws it in outline? As for clicks @@ -1435,7 +1594,8 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving /// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured -/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a +/// by their art ([`Node::geometric_bounds_with`]), and outside that box, except in outline, they are +/// touched where the live geometry effects of a clipping path in their art show it. The art of a clip group or of a layer with a /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then /// only within that region's bounds. In isolation mode the clipping mask of the isolated container, /// or of the parent of the single object isolated, does not limit the marquee, as it does not limit @@ -1463,6 +1623,7 @@ fn previews_off(n: &Node) -> bool { } fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, whole_compounds: bool, outline: bool) -> Vec { + let _walk = ArtWalk::open(); let mut out = Vec::new(); // The objects the marquee may touch ([`Top`]). let tops: Vec = match scope.and_then(|s| doc.node(s)) { @@ -1573,14 +1734,45 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh } NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds, outline)), NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds, outline)), - // A box with no width or no height (an instance of a symbol that draws a straight - // line) is touched where the marquee crosses it. - _ => n.geometric_bounds_with(s).is_some_and(|b| { - let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; - b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses - }), + // A symbol instance is touched by its box, and outside it where the live geometry + // effects of a clipping path in its art show the art. In the art of another instance + // walked outside that instance's box, it is touched only by its own art ([`ArtOnly`]). + NodeKind::SymbolInstance { .. } => !ArtOnly::on() && box_touches(n, s, r) || !outline && instance_art_touches(n, s, r, whole_compounds), + _ => box_touches(n, s, r), } } + /// Does the marquee `r` touch the box of `n`? A box with no width or no height (an instance of + /// a symbol that draws a straight line) is touched where the marquee crosses it. + fn box_touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool { + n.geometric_bounds_with(s).is_some_and(|b| { + let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; + b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses + }) + } + /// Does the marquee `r` touch the art of symbol instance `n` placed as the canvas draws it, as + /// it touches a group of that art? Only for art whose clipping paths have live geometry effects + /// ([`SymbolBoxes::art`]), or for any art while [`ArtOnly`] is on, and only when `r` meets the + /// instance's reach ([`Node::reach_bounds_with`]): elsewhere the art lies within the instance's + /// box. Not once the marquee has placed as many objects of symbol art as [`ArtWalk`] allows. + fn instance_art_touches(n: &Node, s: &SymbolBoxes, r: Rect, whole_compounds: bool) -> bool { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return false }; + let meets = |b: Rect| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; + if s.art(symbol).is_none_or(|a| a.reshaped.is_none() && !ArtOnly::on()) || !n.reach_bounds_with(s).is_some_and(meets) { + return false; + } + let _walk = ArtWalk::open(); + if ArtWalk::used_up() { + return false; + } + let Some(art) = InstanceArt::of(s.document(), n) else { return false }; + if !ArtWalk::take(art.size()) { + return false; + } + #[cfg(test)] + ARTS_WALKED.with(|c| c.set(c.get() + 1)); + let _art_only = ArtOnly::set(); + art.place(&art.art, None).is_some_and(|placed| placed.visible && touches(&placed, s, r, None, whole_compounds, false)) + } fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, outline: bool, out: &mut Vec) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index dde7365424..74bd959a7f 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1852,6 +1852,294 @@ fn a_symbol_part_follows_the_region_its_clipping_layers_effects_give_it() { assert!(symbol_part_outline(&d, n, &[0, 1]).unwrap().elements().is_empty(), "nothing is drawn, so nothing is outlined"); } +/// A symbol instance whose art holds a clip group or a clipping layer with a live effect that moves +/// its clipping path is clicked, and touched by a marquee, where the moved region shows the art past +/// the art's box: inside a group, nested in another symbol's art, moved and scaled. In Outline view, +/// below a layer whose Preview is off, where the art is hidden and where no region is left, only the +/// box counts. +#[test] +fn a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art() { + use vectorcraft_geom::Affine; + set_clip_region(moved_clip_region); + // `effect` on the clipping path of a clip group (a clipping layer with `layer`), whose art is + // shown when `shown`; `xf` places the instance, which with `nest` shows a symbol whose art holds + // an instance of the clipped symbol. The instance is in a group: the group, the instance. + let build = |effect: &str, xf: Affine, nest: bool, shown: bool, layer: bool| { + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + let (mut mask, mut art) = half_clipped(&mut d); + mask.appearance.effects.push(crate::Effect { id: effect.into(), ..test_move() }); + art.visible = shown; + let mut clip = if layer { + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer + } else { + Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]) + }; + clip.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(clip)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(root) }); + let mut symbol = "Clipped"; + if nest { + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: Affine::IDENTITY }); + let root = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(root) }); + symbol = "Outer"; + } + let (g, inst) = (d.alloc_id(), d.alloc_id()); + let instance = Node::new(inst, NodeKind::SymbolInstance { symbol: symbol.into(), xf }); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(instance)])).unwrap(); + (d, g, inst) + }; + let opt = HitOptions::default(); + let outline = HitOptions { outline: true, ..opt }; + let at = |d: &Document, x: f64, y: f64, opt: HitOptions| hit_test(d, Point::new(x, y), opt).map(|h| h.leaf); + // Across the art's top edge in the moved region, and past the art's right end. + let (edge, past) = (Rect::new(140.0, -5.0, 160.0, 5.0), Rect::new(240.0, -5.0, 260.0, 5.0)); + for layer in [false, true] { + let (d, g, inst) = build("test.move", Affine::IDENTITY, false, true, layer); + assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "in the moved region, layer {layer}"); + assert_eq!(hit_test(&d, Point::new(150.0, 50.0), opt).map(|h| h.top_object(None)), Some(g)); + assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "in the box"); + assert_eq!(at(&d, 250.0, 50.0, opt), None, "past the art"); + assert_eq!(at(&d, 150.0, 150.0, opt), None, "below the art"); + assert_eq!(at(&d, 150.0, 50.0, outline), None, "Outline view draws the plain clipping path"); + assert_eq!(marquee(&d, edge, None, false), [g], "layer {layer}"); + assert_eq!(marquee(&d, edge, None, true), [inst], "layer {layer}"); + assert!(marquee(&d, past, None, false).is_empty()); + assert!(marquee_in_view(&d, edge, None, false, true).is_empty(), "Outline view"); + } + // Preview off for the layer the instance is on. + let (mut d, _, _) = build("test.move", Affine::IDENTITY, false, true, false); + if let NodeKind::Layer { preview, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind { + *preview = false; + } + assert_eq!(at(&d, 150.0, 50.0, opt), None, "Preview off"); + assert!(marquee(&d, edge, None, false).is_empty(), "Preview off"); + // Moved, scaled (the region moves 100 pt from the placed clipping path) and nested. + let (d, _, inst) = build("test.move", Affine::translate((10.0, 20.0)), false, true, false); + assert_eq!(at(&d, 160.0, 70.0, opt), Some(inst), "moved"); + let (d, _, inst) = build("test.move", Affine::scale(2.0), false, true, false); + assert_eq!(at(&d, 250.0, 100.0, opt), Some(inst), "scaled"); + assert_eq!(at(&d, 350.0, 100.0, opt), None, "scaled, past the region"); + let (d, g, inst) = build("test.move", Affine::IDENTITY, true, true, false); + assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "nested"); + assert_eq!(marquee(&d, edge, None, false), [g], "nested"); + // Hidden art, and an effect that leaves no region: clicks find only the box. The marquee still + // touches the moved clipping path on the edge of its region, as in a clip group, unless no + // region is left. + for (effect, shown) in [("test.move", false), ("test.empty", true)] { + let (d, g, inst) = build(effect, Affine::IDENTITY, false, shown, false); + assert_eq!(at(&d, 150.0, 50.0, opt), None, "{effect}, shown {shown}"); + assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "{effect}, shown {shown}: in the box"); + let touched: &[NodeId] = if shown { &[] } else { &[g] }; + assert_eq!(marquee(&d, edge, None, false), touched, "{effect}, shown {shown}"); + } +} + +/// A click on the stroke of a symbol's art where it paints past the instance's box selects the +/// instance; past the stroke, and in Outline view, it does not. +#[test] +fn a_symbol_instance_is_hit_on_the_stroke_of_its_art_past_its_box() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + // A 20 pt stroke reaches 10 pt past the 0..100 square. + let look = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 20.0); + let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), look); + let art = Node::group(d.alloc_id(), vec![Arc::new(square)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Stroked".into(), art: Arc::new(art) }); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::IDENTITY; + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Stroked".into(), xf })).unwrap(); + let at = |x: f64, opt: HitOptions| hit_test(&d, Point::new(x, 50.0), opt).map(|h| h.leaf); + assert_eq!(at(108.0, HitOptions::default()), Some(inst), "on the stroke past the box"); + assert_eq!(at(50.0, HitOptions::default()), Some(inst), "in the box"); + assert_eq!(at(116.0, HitOptions::default()), None, "past the stroke"); + assert_eq!(at(108.0, HitOptions { outline: true, ..HitOptions::default() }), None, "Outline view"); +} + +/// Outside a symbol instance's box, an instance nested in its art counts where its own art is, not +/// anywhere in its box: a click or a marquee on empty space in the nested box, where a moved +/// clipping path shows the art past the outer box, selects nothing, and on art drawn there it +/// selects the outer instance. +#[test] +fn a_nested_symbol_instances_box_past_the_outer_box_is_not_a_hit() { + set_clip_region(moved_clip_region); + // `Two` draws squares at the corners of 0..200 × 0..100, and with `middle` one at 140..160 × + // 40..60. The outer symbol clips an instance of it to 0..100 × 0..100, which `test.move` moves + // to 100..200: the outer box is 0..100, the nested box 0..200. + let build = |middle: bool| { + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + let square = |d: &mut Document, r: Rect| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default_art())); + let mut squares = vec![square(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0)), square(&mut d, Rect::new(190.0, 90.0, 200.0, 100.0))]; + if middle { + squares.push(square(&mut d, Rect::new(140.0, 40.0, 160.0, 60.0))); + } + group_symbol(&mut d, "Two", squares); + let (mut mask, _) = half_clipped(&mut d); + mask.appearance.effects.push(test_move()); + let nested = instance_of(&mut d, "Two"); + let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), nested]); + clip.set_clips(true); + group_symbol(&mut d, "Outer", vec![Arc::new(clip)]); + let inst = d.alloc_id(); + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Outer".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + (d, inst) + }; + let at = |d: &Document, x: f64, y: f64| hit_test(d, Point::new(x, y), HitOptions::default()).map(|h| h.leaf); + let inside = Rect::new(145.0, 45.0, 155.0, 55.0); + let (d, inst) = build(false); + assert_eq!(at(&d, 150.0, 50.0), None, "empty space in the nested box"); + assert!(marquee(&d, inside, None, false).is_empty(), "marquee on empty space in the nested box"); + assert_eq!(at(&d, 195.0, 95.0), Some(inst), "on the nested art past the outer box"); + assert_eq!(at(&d, 50.0, 50.0), Some(inst), "in the outer box"); + let (d, inst) = build(true); + assert_eq!(at(&d, 150.0, 50.0), Some(inst), "on the nested art"); + // Across the middle square's left edge. + assert_eq!(marquee(&d, Rect::new(135.0, 45.0, 145.0, 55.0), None, false), [inst], "marquee on the nested art"); +} + +/// Adds the symbol `Clipped`: a clip group whose clipping path's region `test.move` moves to +/// 100..200 × 0..100, past the 0..100 box, over a 0..200 × 0..100 rectangle shown when `shown`. +/// A click at (150, 50) hits an instance of it on the rectangle, or, with the rectangle hidden, +/// walks its art and finds nothing. +fn clipped_symbol(d: &mut Document, shown: bool) { + set_clip_region(moved_clip_region); + let (mut mask, mut art) = half_clipped(d); + mask.appearance.effects.push(test_move()); + art.visible = shown; + let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]); + clip.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(clip)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(root) }); +} + +/// A symbol named `name` whose art is a group of `members`. +fn group_symbol(d: &mut Document, name: &str, members: Vec>) { + let root = Node::group(d.alloc_id(), members); + d.symbols.push(crate::Symbol { dynamic: false, name: name.into(), art: Arc::new(root) }); +} + +/// An instance of the symbol named `symbol`, not moved. +fn instance_of(d: &mut Document, symbol: &str) -> Arc { + Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY })) +} + +/// 30 small squares at the origin, which a click at (150, 50) passes by. +fn far_squares(d: &mut Document) -> Vec> { + (0..30).map(|_| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()))).collect() +} + +/// Runs `f` with [`ArtWalk`] allowing `limit` objects, and returns its result with how many +/// leaves [`hit_leaf`] tested and how many instances' art was walked meanwhile. +fn with_art_limit(limit: u64, f: impl FnOnce() -> R) -> (R, usize, usize) { + ART_WALK_LIMIT.with(|c| c.set(Some(limit))); + LEAVES_TESTED.with(|c| c.set(0)); + ARTS_WALKED.with(|c| c.set(0)); + let r = f(); + ART_WALK_LIMIT.with(|c| c.set(None)); + (r, LEAVES_TESTED.with(std::cell::Cell::get), ARTS_WALKED.with(std::cell::Cell::get)) +} + +/// One click visits at most the art walk's limit of objects in a chain of nested symbol +/// instances, though each level's art alone is well within it. A click on the art of an +/// instance nested in another is still found. +#[test] +fn a_click_walks_a_chain_of_nested_symbol_instances_within_one_limit() { + // Level 0 is `Clipped`; each level above holds an instance of the level below and 30 squares: + // 32 objects to visit at each level. + let chain = |shown: bool| { + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + clipped_symbol(&mut d, shown); + for level in 1..12 { + let below = if level == 1 { "Clipped".to_string() } else { format!("Level {}", level - 1) }; + let mut members = vec![instance_of(&mut d, &below)]; + members.extend(far_squares(&mut d)); + group_symbol(&mut d, &format!("Level {level}"), members); + } + let inst = instance_of(&mut d, "Level 11"); + let id = inst.id; + d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap(); + (d, id) + }; + let at = |d: &Document, x: f64| hit_test(d, Point::new(x, 50.0), HitOptions::default()).map(|h| h.leaf); + // Without one limit for the click, the miss walks the art of all 12 levels. + let (d, inst) = chain(false); + let (hit, _, arts) = with_art_limit(100, || at(&d, 150.0)); + assert_eq!(hit, None); + assert!(arts <= 4, "{arts} levels walked"); + assert_eq!(with_art_limit(100, || at(&d, 50.0)).0, Some(inst), "in the box"); + // Past the limit, a click that the art would take finds only the box. + let (d, inst) = chain(true); + assert_eq!(with_art_limit(100, || at(&d, 150.0)).0, None, "past the limit"); + assert_eq!(at(&d, 150.0), Some(inst), "within the limit"); + // Two levels: a click on the inner instance's art is found within the limit. + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + clipped_symbol(&mut d, true); + let inner = instance_of(&mut d, "Clipped"); + group_symbol(&mut d, "Outer", vec![inner]); + let inst = instance_of(&mut d, "Outer"); + let id = inst.id; + d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap(); + assert_eq!(with_art_limit(100, || at(&d, 150.0)).0, Some(id), "nested"); +} + +/// One click, and one marquee, visit at most the art walk's limit of objects in art that repeats +/// one nested instance many times, though the art of each instance alone is well within it. A +/// click on one of the nested instances' art is still found. +#[test] +fn a_click_and_a_marquee_walk_repeated_nested_symbol_instances_within_one_limit() { + // 50 instances of `Clipped` in one symbol's art, its rectangle shown when `shown`. + let repeated = |shown: bool| { + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + clipped_symbol(&mut d, shown); + let members = (0..50).map(|_| instance_of(&mut d, "Clipped")).collect(); + group_symbol(&mut d, "Repeated", members); + let inst = instance_of(&mut d, "Repeated"); + let id = inst.id; + d.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap(); + (d, id) + }; + let (d, inst) = repeated(false); + // Without one limit for the click, the miss walks the art of all 51 instances. The outer art + // takes 1 object of the 100, and each nested instance with its art 4. + let (hit, _, arts) = with_art_limit(100, || hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).map(|h| h.leaf)); + assert_eq!(hit, None); + assert!(arts <= 26, "{arts} instances' art walked"); + // Select Behind over 60 instances of `Clipped` on the layer shares one limit too: each walk + // takes 3 objects of the 100. + let mut stack = Document::new(600.0, 400.0); + let l = stack.layers[0].id; + clipped_symbol(&mut stack, false); + for _ in 0..60 { + let inst = instance_of(&mut stack, "Clipped"); + stack.insert(Some(l), 0, Arc::unwrap_or_clone(inst)).unwrap(); + } + let (behind, _, arts) = with_art_limit(100, || objects_at(&stack, Point::new(150.0, 50.0), HitOptions::default(), None)); + assert!(behind.is_empty()); + assert!(arts <= 34, "Select Behind: {arts} instances' art walked"); + // Without one limit for the marquee, it places the art of all 51 instances, inside the moved + // region (within each instance's reach and outside its box). Placing the outer art takes 51 + // objects of the 100, and each nested art 4. + let inside = Rect::new(140.0, 40.0, 160.0, 60.0); + let (touched, _, arts) = with_art_limit(100, || marquee(&d, inside, None, false)); + assert!(touched.is_empty()); + assert!(arts <= 14, "{arts} instances' art placed"); + assert_eq!(with_art_limit(100, || marquee(&d, Rect::new(40.0, 40.0, 60.0, 60.0), None, false)).0, [inst], "in the box"); + // A click and a marquee on the nested instances' art are found within the limit. + let (d, inst) = repeated(true); + assert_eq!(with_art_limit(100, || hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).map(|h| h.leaf)).0, Some(inst), "click"); + let edge = Rect::new(140.0, -5.0, 160.0, 5.0); + assert_eq!(with_art_limit(100, || marquee(&d, edge, None, false)).0, [inst], "marquee across the art's edge"); +} + /// In a dynamic symbol's art that placing through its 9-slice guides could make more than /// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the /// canvas draws nothing of it. At the art's natural size the same art is picked. diff --git a/crates/doc/src/inks.rs b/crates/doc/src/inks.rs index a9fa5f3f80..ed920fbd41 100644 --- a/crates/doc/src/inks.rs +++ b/crates/doc/src/inks.rs @@ -80,16 +80,22 @@ pub fn spot_color(doc: &Document, name: &str) -> Option { /// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it. pub type Link<'a> = Option<(&'a str, f32)>; +/// An ink amount clamped to 0..1; an amount that is not a finite number prints no ink. +fn ink_amount(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + /// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at -/// its tint; one linked to the Registration swatch prints its tint on every plate. +/// its tint; one linked to the Registration swatch prints its tint on every plate. A tint that is +/// not a finite number prints no ink. pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks { if let Some((REGISTRATION, tint)) = link { - return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true }; + return Inks { cmyk: [ink_amount(tint); 4], spot: None, registration: true }; } if let Some((name, tint)) = link && doc.swatch(name).is_some_and(|s| s.spot && s.paint.color().is_some()) { - return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false }; + return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), ink_amount(tint))), registration: false }; } Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false } } @@ -104,7 +110,7 @@ pub type StopPlates = Option<[f32; 4]>; pub fn stop_inks(doc: &Document, c: &Cms, color: &Color, link: Link, plates: StopPlates, intent: Intent) -> Inks { let mut ink = inks(doc, c, color, link, intent); if let (Some(p), Some(_)) = (plates, &ink.spot) { - ink.cmyk = p.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); + ink.cmyk = p.map(ink_amount); } ink } @@ -227,6 +233,30 @@ mod tests { } } + #[test] + fn a_spot_or_registration_tint_that_is_not_a_finite_number_prints_no_ink() { + let d = gold_doc(); + let c = vectorcraft_color::cms::active(); + let look = Color::cmyk(0.25, 0.1, 0.8, 0.05); + let ink = |link: Link| inks(&d, &c, &look, link, Intent::RelativeColorimetric); + for t in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let spot = ink(Some(("Gold", t))); + assert_eq!(spot, Inks { cmyk: [0.0; 4], spot: Some(("Gold".into(), 0.0)), registration: false }, "{t}"); + assert_eq!((spot.on_plate("Gold"), spot.on_plate("Cyan")), (0.0, 0.0), "{t}"); + let reg = ink(Some((REGISTRATION, t))); + assert_eq!(reg, Inks { cmyk: [0.0; 4], spot: None, registration: true }, "{t}"); + assert_eq!((reg.on_plate("Gold"), reg.on_plate("Black")), (0.0, 0.0), "{t}"); + // A stop's process plates still print beside a spot tint that prints nothing. + let stop = stop_inks(&d, &c, &look, Some(("Gold", t)), Some([0.25, 0.0, 0.4, 0.0]), Intent::RelativeColorimetric); + assert_eq!((stop.cmyk, stop.on_plate("Gold")), ([0.25, 0.0, 0.4, 0.0], 0.0), "{t}"); + } + // Finite tints outside 0..1 are clamped. + assert_eq!(ink(Some(("Gold", 2.0))).on_plate("Gold"), 1.0); + assert_eq!(ink(Some(("Gold", -1.0))).on_plate("Gold"), 0.0); + assert_eq!(ink(Some((REGISTRATION, 2.0))).cmyk, [1.0; 4]); + assert_eq!(ink(Some((REGISTRATION, -1.0))).cmyk, [0.0; 4]); + } + #[test] fn gradient_stops_pass_their_plates_and_freeform_points_are_visited() { let mut stop = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b32a4485f4..7b7ef4fee7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -128,7 +128,7 @@ pub use text::{ RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; -pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts}; +pub use text::{CharClass, CompositeClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet}; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 7ff6cdeea2..68aaedd68e 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -105,8 +105,13 @@ impl Document { n += shared(&self.para_styles, &newer.para_styles, styles); n += shared(&self.composite_fonts, &newer.composite_fonts, |c| { let part = |p: &crate::CompositePart| p.font.capacity() + p.style.capacity(); + let custom = |f: &crate::CompositeFont| { + vec_bytes(&f.custom) + f.custom.iter().map(|s| s.name.capacity() + s.characters.capacity() + part(&s.part)).sum::() + }; vec_bytes(c) - + c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::()).sum::() + + c.iter() + .map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::() + custom(f)) + .sum::() }); n += shared(&self.saved_selections, &newer.saved_selections, |s| { vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::() @@ -276,7 +281,13 @@ fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet) -> u }); } b += match &n.kind { - NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live), + NodeKind::Path { path, live, .. } => { + let was = other.and_then(|o| match &o.kind { + NodeKind::Path { path, .. } => Some(path), + _ => None, + }); + path_bytes_besides(path, was) + boxed(live) + } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } @@ -293,8 +304,30 @@ fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet) -> u } fn path_bytes(p: &PathData) -> usize { - let anchors: usize = - p.subpaths.iter().map(|s| if s.anchors.capacity() == 0 { 0 } else { ALLOC + s.anchors.capacity() * size_of::() }).sum(); + path_bytes_besides(p, None) +} + +/// [`path_bytes`] without the anchor chunks that `other` (another version of the path) holds too: +/// a version made by changing one anchor shares every chunk but the one it changed. +fn path_bytes_besides(p: &PathData, other: Option<&PathData>) -> usize { + let mut shared: Vec = + other.map(|o| o.subpaths.iter().flat_map(|s| s.anchors.chunk_allocations()).map(|(id, _)| id).collect()).unwrap_or_default(); + shared.sort_unstable(); + let chunk = ALLOC + ARC + size_of::>() + ALLOC; + let anchors: usize = p + .subpaths + .iter() + .map(|s| { + let table = s.anchors.table_bytes(); + let own: usize = s + .anchors + .chunk_allocations() + .filter(|(id, _)| shared.binary_search(id).is_err()) + .map(|(_, capacity)| chunk + capacity * size_of::()) + .sum(); + if table == 0 { own } else { ALLOC + table + own } + }) + .sum(); anchors + if p.subpaths.capacity() == 0 { 0 } else { ALLOC + p.subpaths.capacity() * size_of::() } } diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs index cb66ba1148..b26fa52e1c 100644 --- a/crates/doc/src/node/clip.rs +++ b/crates/doc/src/node/clip.rs @@ -74,19 +74,32 @@ impl Node { /// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted /// over it (unclipped). Each part is this object keeping only those appearance items (type: /// also only its characters' fills or strokes); `None` where it paints nothing, as right after - /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths. + /// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths, and so does + /// a group of the pieces a clipping path's fills and strokes draw ([`crate::NodeExtras::clip_pieces`]), + /// even after effects around it split or copy those pieces into groups: each part is then that + /// group keeping only the pieces of those fills or strokes. pub fn clip_paint(&self) -> ClipPaint { use crate::appearance::AppearanceItem; + let item = |fill: bool, i: &AppearanceItem| match i { + AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, + }; + if matches!(self.kind, NodeKind::Group { clip: false, .. }) && self.extras().clip_pieces { + let part = |fill: bool| { + let mut paints = false; + let mut n = pieces_of(self, fill, &|i| item(fill, i), &mut paints)?; + n.extras_mut().clip_pieces = false; + paints.then_some(n) + }; + return ClipPaint { fill: part(true), stroke: part(false) }; + } let runs = match &self.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None, NodeKind::Text(t) => Some(&t.runs), _ => return ClipPaint::default(), }; let paints = |fill: bool| { - let item = |i: &AppearanceItem| match i { - AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(), - AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0, - }; + let item = |i: &AppearanceItem| item(fill, i); let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 }; self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run)) }; @@ -110,6 +123,30 @@ impl Node { } } +/// `n`, a group of a clipping path's pieces or one of them, keeping only the pieces of its fills +/// (`fill`) or of its strokes, at every depth of the groups an effect put them in; `None` when it +/// holds none. `paints` is set when one of the kept fills or strokes paints. +fn pieces_of(n: &Node, fill: bool, paints_item: &dyn Fn(&crate::appearance::AppearanceItem) -> bool, paints: &mut bool) -> Option { + use crate::appearance::AppearanceItem; + if let NodeKind::Group { children, clip: false } = &n.kind { + let kept: Vec<_> = children.iter().filter_map(|c| pieces_of(c, fill, paints_item, paints).map(std::sync::Arc::new)).collect(); + if kept.is_empty() { + return None; + } + let mut m = n.clone(); + if let Some(ch) = m.children_mut() { + *ch = kept; + } + return Some(m); + } + let items = &n.appearance.items; + if !items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)) == fill) { + return None; + } + *paints |= items.iter().any(paints_item); + Some(n.clone()) +} + /// What a clipping path paints ([`Node::clip_paint`]). #[derive(Clone, Debug, Default)] pub struct ClipPaint { diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index e13750457e..f75e473039 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -79,6 +79,10 @@ pub struct NodeExtras { /// knockout group its knockout shape is the whole area of its triangle, not only where the /// image is opaque ([`Node::knockout_outline`]). pub warp_piece: bool, + /// A group of the pieces a clipping path's fills and strokes draw once their geometry effects + /// are applied, one path or compound path per fill and stroke: as the clipping path of a clip group it paints + /// those pieces as the path paints its fills and strokes ([`Node::clip_paint`]). + pub clip_pieces: bool, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -108,6 +112,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { pixel_aligned: false, color_mask: None, warp_piece: false, + clip_pieces: false, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 1aad3a27cf..cd4798c76c 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -228,7 +228,11 @@ impl Node { /// grown by the farthest a stroke in the art can reach. For an instance placed through 9-slice /// guides, that box also holds the art box placed by each cell's map /// ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an instance of a - /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. + /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. An + /// instance whose art holds a clipping path with live geometry effects also takes in the region + /// they give it ([`crate::hit::set_clip_region`]): moved with the instance when it only moves its + /// art, otherwise placed by it and grown by as far as the region reaches past the art, times √2 + /// and the larger of 1 and the Frobenius norm of the transform's linear part. pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { symbols.quickly(|| self.reach(Some(symbols))) } @@ -268,6 +272,30 @@ impl Node { NodeKind::Layer { children, clip: true, .. } | NodeKind::Group { children, clip: true } => { vectorcraft_geom::union_opt(self.visual(symbols), children.first().and_then(|c| crate::hit::reshaped_clip_box(c))) } + // The live geometry effects of the clipping paths in the art can draw it past its box. + NodeKind::SymbolInstance { symbol, xf } => { + let Some((s, art, region)) = symbols.and_then(|s| { + let a = s.art(symbol)?; + Some((s, a, a.reshaped?)) + }) else { + return self.visual(symbols); + }; + let map = s.document().symbol_slice_map_of(symbol, art.geometric, *xf); + let placed = if moves_only(*xf) && map.is_none() { + xf.transform_rect_bbox(region) + } else { + // An effect can reach a fixed distance past the art or a distance in proportion + // to its size, as `SymbolBoxes::effects_reach` estimates it. + let g = art.geometric; + let all = region.union(g); + let past = [g.x0 - region.x0, region.x1 - g.x1, g.y0 - region.y0, region.y1 - g.y1].into_iter().fold(0.0_f64, f64::max); + let [a, b, c, d, _, _] = xf.as_coeffs(); + let grow = past * std::f64::consts::SQRT_2 * (a * a + b * b + c * c + d * d).sqrt().max(1.0); + map.map_or_else(|| xf.transform_rect_bbox(all), |m| m.placement_bounds(all)).inflate(grow, grow) + }; + let o = self.appearance.outset(); + vectorcraft_geom::union_opt(self.visual(symbols), self.projected(Some(placed)).map(|b| b.inflate(o, o))) + } _ => self.visual(symbols), } } @@ -369,7 +397,7 @@ impl Node { NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // A clipping path that paints (see `clip_paint`) strokes over the clip edge. let clip = children.first()?; - if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) { + if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) || clip.extras().clip_pieces { clip.visual(symbols) } else { clip.geometric(symbols) diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 06e68a79d0..db21762f61 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -24,6 +24,7 @@ fn extras_are_written_flat_in_order() { e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]); e.pixel_aligned = true; e.warp_piece = true; + e.clip_pieces = true; } /// The top-level keys of a JSON object, in the order written. struct Keys(Vec); @@ -71,11 +72,13 @@ fn extras_are_written_flat_in_order() { "envelope_edit_margin", "pixel_aligned", "warp_piece", + "clip_pieces", ]; assert_eq!(keys, order); assert_eq!(v["shape_mode"], "exclude"); assert_eq!(v["pixel_aligned"], true); assert_eq!(v["warp_piece"], true); + assert_eq!(v["clip_pieces"], true); assert_eq!(serde_json::from_value::(v).unwrap(), n); let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art()); let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap(); diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 885f9a5853..c47097b8cc 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -57,6 +57,7 @@ impl Serialize for Node { is_default(&e.pixel_aligned), e.color_mask.is_none(), is_default(&e.warp_piece), + is_default(&e.clip_pieces), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -102,6 +103,7 @@ impl Serialize for Node { field!("pixel_aligned", &e.pixel_aligned); field!("color_mask", &e.color_mask); field!("warp_piece", &e.warp_piece); + field!("clip_pieces", &e.clip_pieces); st.end() } } @@ -249,6 +251,8 @@ pub(super) struct NodeWire { color_mask: Option>, #[serde(default)] warp_piece: bool, + #[serde(default)] + clip_pieces: bool, } impl From for Node { @@ -279,6 +283,7 @@ impl From for Node { pixel_aligned: w.pixel_aligned, color_mask: w.color_mask, warp_piece: w.warp_piece, + clip_pieces: w.clip_pieces, }; Node { id: w.id, diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 4ef6446247..541e7801c2 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -49,6 +49,13 @@ pub struct Selection { /// it when no object is selected to align to it. #[serde(default, skip_serializing_if = "Option::is_none")] pub key_guide: Option, + /// The key anchor for Align: one of two or more anchor points selected with Direct Selection, + /// the last one a Shift-click added (path, anchor in [`Self::anchors`]). Align and Distribute + /// move the other selected points relative to it, and it stays in place. Never set with `key` + /// or `key_guide`; [`Self::prune`] clears it when the point is no longer a selected anchor, or + /// fewer than two points are selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_anchor: Option<(NodeId, AnchorRef)>, /// The new art on a locked artboard that this selection keeps editable /// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and /// the engine then sets the document's from it. Editing state, never saved. @@ -98,6 +105,7 @@ impl Selection { self.slices.clear(); self.guides.clear(); self.key_guide = None; + self.key_anchor = None; self.symbol_part = None; self.on_path_member = None; } @@ -113,9 +121,15 @@ impl Selection { self.set([instance]); self.symbol_part = Some(SymbolPart { instance, path }); } - /// Is there a key for Align: an object or a guide? + /// Is there a key for Align: an object, a guide or an anchor point? pub fn has_key(&self) -> bool { - self.key.is_some() || self.key_guide.is_some() + self.key.is_some() || self.key_guide.is_some() || self.key_anchor.is_some() + } + /// Where the key anchor ([`Self::key_anchor`]) is. `None` without one, or when the point is + /// gone from its path. + pub fn key_anchor_point(&self, doc: &Document) -> Option { + let (id, (si, ai)) = self.key_anchor?; + Some(doc.node(id)?.path_data()?.subpaths.get(si)?.anchors.get(ai)?.p) } pub fn set(&mut self, ids: impl IntoIterator) { self.clear(); @@ -143,6 +157,9 @@ impl Selection { if self.key == Some(id) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| k == id) { + self.key_anchor = None; + } } /// [`Self::remove`] for each of `ids`, going through the selected objects once. pub fn remove_all(&mut self, ids: impl IntoIterator) { @@ -162,6 +179,9 @@ impl Selection { if self.key.is_some_and(|k| ids.contains(&k)) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| ids.contains(&k)) { + self.key_anchor = None; + } } /// Select slices `ids` (see [`Selection::slices`]) and nothing else. pub fn set_slices(&mut self, ids: impl IntoIterator) { @@ -271,6 +291,40 @@ impl Selection { if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) { self.key_guide = None; } + // A key anchor needs its point on its path and selected as an anchor point (not as a + // selected segment's end), with another point selected too: another anchor, or a path + // selected whole (Align and Distribute act on path points only). It takes the place of a + // key object or key guide. + let path_of = |o: &NodeId| found.get(o).and_then(|p| doc.node_at(p)).and_then(|n| n.path_data()); + let whole_path = |o: &NodeId| !self.anchors.contains_key(o) && path_of(o).is_some_and(|path| path.anchors().next().is_some()); + // Selected anchor points that are on their paths, counted up to two. + let points = || { + self.anchors + .iter() + .flat_map(|(o, set)| { + let path = path_of(o); + set.iter().filter(move |&&(si, ai)| path.is_some_and(|p| p.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len()))) + }) + .take(2) + .count() + }; + let anchor_kept = self.key_anchor.is_some_and(|(id, (si, ai))| { + self.objects.contains(&id) + && !self.segments.contains_key(&id) + && self.anchors.get(&id).is_some_and(|set| set.contains(&(si, ai))) + && found + .get(&id) + .and_then(|p| doc.node_at(p)) + .and_then(|n| n.path_data()) + .is_some_and(|path| path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) + && (points() >= 2 || self.objects.iter().any(whole_path)) + }); + if anchor_kept { + self.key = None; + self.key_guide = None; + } else { + self.key_anchor = None; + } // A selected part needs its instance selected alone, with no guides, its symbol dynamic // and the object still in the symbol's art as a path, compound path or type, where neither // it nor a group or layer around it is hidden, locked or a template. @@ -348,6 +402,9 @@ impl Selection { if self.key.is_some_and(|k| gone.contains(&k)) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| gone.contains(&k)) { + self.key_anchor = None; + } } /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected /// (the art of its sublayers too), anything else is selected itself. diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 1952060663..34faf1f9c4 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -162,6 +162,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud }, BuiltinStyleLibrary { id: "status-effects", name: "Status Effects", make: status_effects }, + BuiltinStyleLibrary { id: "neon-glow", name: "Neon & Glow", make: neon_glow }, ]; /// Built-in library `id`, made once. @@ -619,6 +620,44 @@ fn status_effects() -> Vec { ] } +/// Neon & Glow: original multi-layer procedural styles with neon tubes, laser beams, and glowing halos. +fn neon_glow() -> Vec { + vec![ + style( + "Cyan Neon", + vec![S(StrokeLayer { opacity: 0.7, ..round("#00e5ff", 6.0) }), S(round("#ffffff", 1.5))], + vec![fx("stylize.dropShadow", json!({"mode": "screen", "opacity": 85.0, "x": 0.0, "y": 0.0, "blur": 8.0, "color": "#00e5ff"}))], + ), + GraphicStyle { + blend: BlendMode::Screen, + ..style( + "Magenta Laser", + vec![S(StrokeLayer { blend: BlendMode::Screen, ..round("#ff007f", 5.0) }), S(round("#ffffff", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 90.0, "blur": 8.0, "color": "#ff007f"}))], + ) + }, + style( + "Amber Tube", + vec![S(StrokeLayer { opacity: 0.8, ..round("#ff8c00", 6.0) }), S(round("#ffea00", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 85.0, "blur": 8.0, "color": "#ff8c00"}))], + ), + style( + "Electric Lime", + vec![S(StrokeLayer { opacity: 0.75, ..round("#39ff14", 6.0) }), S(round("#ffffff", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 90.0, "blur": 8.0, "color": "#39ff14"}))], + ), + style( + "Pulse Violet", + vec![S(StrokeLayer { opacity: 0.75, ..round("#7b2cbf", 5.0) }), S(round("#e0aaff", 1.5))], + vec![ + fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 75.0, "blur": 6.0, "color": "#c77dff", "source": "edge"})), + fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 85.0, "blur": 8.0, "color": "#7b2cbf"})), + ], + ), + style("Cobalt Phosphor", vec![S(round("#0047ab", 6.0)), S(round("#ffffff", 1.5))], vec![fx("blur.gaussian", json!({"radius": 2.0}))]), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -750,4 +789,36 @@ mod tests { let Paint::Gradient(gp) = g.appearance.fill_paint() else { panic!("a gradient") }; assert!(gp.swatch.is_none() && gp.gradient.stops.iter().all(|s| s.swatch.is_none() && s.tint == 1.0)); } + + #[test] + fn neon_and_glow_styles_are_defined_with_multi_strokes_and_effects() { + let lib = builtin_style_library("neon-glow").expect("neon-glow library exists"); + assert_eq!(lib.name, "Neon & Glow"); + assert_eq!(lib.len(), 6); + let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Cyan Neon", "Magenta Laser", "Amber Tube", "Electric Lime", "Pulse Violet", "Cobalt Phosphor"]); + for g in &lib.styles { + let strokes = g.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + assert!(strokes >= 2, "{} has {} strokes, expected >= 2", g.name, strokes); + assert!(!g.appearance.effects.is_empty(), "{} has effects", g.name); + } + let cyan = lib.style("Cyan Neon").unwrap(); + assert_eq!(cyan.appearance.effects[0].id, "stylize.dropShadow"); + let magenta = lib.style("Magenta Laser").unwrap(); + assert_eq!(magenta.blend, BlendMode::Screen); + let pulse = lib.style("Pulse Violet").unwrap(); + let pulse_fx: Vec<&str> = pulse.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(pulse_fx, ["stylize.innerGlow", "stylize.outerGlow"]); + let cobalt = lib.style("Cobalt Phosphor").unwrap(); + assert_eq!(cobalt.appearance.effects[0].id, "blur.gaussian"); + } + + #[test] + fn neon_glow_round_trips_through_vcstyles() { + let lib = builtin_style_library("neon-glow").unwrap(); + let text = write(&lib); + assert!(sniff(&text)); + let read_back = read(&text, "Fallback").unwrap(); + assert_eq!(*lib, read_back); + } } diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 944accfe4b..99a65ae1f7 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -47,6 +47,11 @@ pub(crate) struct ArtBox { /// Whether the art holds an instance of a symbol with 9-slice guides, or of a symbol whose art /// holds one at any depth. pub(crate) sliced: bool, + /// The box of the regions that the live geometry effects of the clipping paths in the art give + /// them ([`crate::hit::set_clip_region`]), and of the instances in the art whose art has such + /// regions, each measured by [`Node::reach_bounds_with`]. `None` when nothing in the art has + /// one. + pub(crate) reshaped: Option, } /// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`], @@ -562,7 +567,18 @@ fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { sliced |= symbols.doc.symbol_nine_slice(symbol).is_some() || symbols.art(symbol).is_some_and(|a| a.sliced); } }); - Some(ArtBox { geometric, visual, reach, effects, brush, sliced }) + let mut reshaped = None; + if effects { + art.walk(&mut |n| { + let b = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if symbols.art(symbol).is_some_and(|a| a.reshaped.is_some()) => n.reach(Some(symbols)), + _ if n.clips() => n.children().and_then(|c| c.first()).and_then(|c| crate::hit::reshaped_clip_box(c)), + _ => None, + }; + reshaped = vectorcraft_geom::union_opt(reshaped, b.filter(finite)); + }); + } + Some(ArtBox { geometric, visual, reach, effects, brush, sliced, reshaped }) } #[cfg(test)] diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 06b71f8fc8..d165a086fb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -22,8 +22,8 @@ pub use char_style::{ WARICHU_LINES, WARICHU_SCALE, WarichuAlign, }; pub use composite::{ - COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, - CompositeParts, + COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX, CharClass, + CompositeClass, CompositeClasses, CompositeFont, CompositePart, CompositeParts, CustomCharSet, }; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel, diff --git a/crates/doc/src/text/composite.rs b/crates/doc/src/text/composite.rs index e95428c2f5..93a7e27808 100644 --- a/crates/doc/src/text/composite.rs +++ b/crates/doc/src/text/composite.rs @@ -1,11 +1,16 @@ //! Type › Composite Fonts…: a font made of other fonts, one for each class of character (the //! Kanji font is the base; kana, punctuation, symbols, Roman letters and numerals each take a font -//! of their own, sized, raised and scaled against it). Type set in a composite font names it as its -//! family; the layout sets each character in its class's font. +//! of their own, sized, raised and scaled against it). Custom character sets give characters the +//! user picks a font of their own too, ahead of their class; of two sets that list a character, +//! the later one sets it. Type set in a composite font names it as its family; the layout sets each +//! character in its set's or its class's font. //! //! Behavior reference (public documentation): -//! . -//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own. +//! , +//! custom character sets: +//! . +//! Which characters fall in which class, the ranges, the limits and the defaults are VectorCraft's +//! own. use serde::{Deserialize, Serialize}; @@ -179,12 +184,76 @@ impl CompositeParts { } } +/// A custom character set of a composite font: characters chosen by the user, set in a part of +/// their own instead of their class's part. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CustomCharSet { + /// The set's name, unique in its composite font in any case. + pub name: String, + /// The set's characters, each once, in the order they were added. + #[serde(default)] + pub characters: String, + /// The font, style, size, baseline and scales its characters take. + #[serde(flatten)] + pub part: CompositePart, +} + +impl CustomCharSet { + /// An empty set named `name`, its characters set in `part`. + pub fn new(name: impl Into, part: CompositePart) -> Self { + Self { name: name.into(), characters: String::new(), part } + } + + /// Whether `c` can be in a custom character set: not a control character, and in the Basic + /// Multilingual Plane (U+0000 to U+FFFF). + pub fn takes(c: char) -> bool { + !c.is_control() && (c as u32) <= 0xFFFF + } + + /// The character a Unicode code names: hexadecimal digits, optionally after `U+` or `0x` + /// (`3042`, `U+3042`). `None` for anything else or a code [`Self::takes`] refuses. + pub fn char_from_code(code: &str) -> Option { + let code = code.trim(); + let hex = ["U+", "u+", "0x", "0X"].iter().find_map(|p| code.strip_prefix(p)).unwrap_or(code); + if hex.is_empty() || hex.len() > 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + u32::from_str_radix(hex, 16).ok().and_then(char::from_u32).filter(|&c| Self::takes(c)) + } +} + +/// Where a composite font sets a character: the part of one of its six classes, or the part of +/// one of its custom character sets (an index into [`CompositeFont::custom`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CompositeClass { + Fixed(CharClass), + Custom(usize), +} + +/// The custom character set each character of a composite font's sets falls in +/// ([`CompositeFont::classes`]): the last set that lists it. +#[derive(Clone, Debug, Default)] +pub struct CompositeClasses { + custom: std::collections::HashMap, +} + +impl CompositeClasses { + /// Where the composite font sets `c`: the last custom set that lists it, else its class. + pub fn of(&self, c: char) -> CompositeClass { + self.custom.get(&c).map_or(CompositeClass::Fixed(CharClass::of(c)), |&i| CompositeClass::Custom(i)) + } +} + /// A composite font (Type › Composite Fonts…), saved with the document. Its name is the family /// that type set in it names. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CompositeFont { pub name: String, pub parts: CompositeParts, + /// Custom character sets, checked before the classes. A character two sets list is set in the + /// later set's part. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom: Vec, } /// The size a part can take, in percent of the type's size. @@ -197,11 +266,40 @@ pub const COMPOSITE_SCALE: std::ops::RangeInclusive = 10.0..=400.0; pub const COMPOSITE_NAME_MAX: usize = 255; /// The most composite fonts a document or a file keeps. pub const COMPOSITE_FONTS_MAX: usize = 256; +/// The most custom character sets a composite font keeps. +pub const CUSTOM_SETS_MAX: usize = 64; +/// The most characters a custom character set keeps. +pub const CUSTOM_CHARS_MAX: usize = 8192; impl CompositeFont { /// A composite font named `name` setting every class in `font` and `style`. pub fn new(name: impl Into, font: impl Into, style: impl Into) -> Self { - Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) } + Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)), custom: Vec::new() } + } + + /// The part that sets the characters of `class`, `None` for a custom set the font doesn't have. + pub fn part(&self, class: CompositeClass) -> Option<&CompositePart> { + match class { + CompositeClass::Fixed(c) => Some(self.parts.get(c)), + CompositeClass::Custom(i) => self.custom.get(i).map(|s| &s.part), + } + } + + /// Where this font sets character `c`: the last custom set that lists it, else its class. For + /// many characters, [`Self::classes`] looks each up faster. + pub fn class_of(&self, c: char) -> CompositeClass { + self.custom.iter().rposition(|s| s.characters.contains(c)).map_or(CompositeClass::Fixed(CharClass::of(c)), CompositeClass::Custom) + } + + /// The custom set each character of this font's sets falls in, to look up many characters. + pub fn classes(&self) -> CompositeClasses { + let mut custom = std::collections::HashMap::new(); + for (i, s) in self.custom.iter().enumerate() { + for c in s.characters.chars() { + custom.insert(c, i); + } + } + CompositeClasses { custom } } /// Do `a` and `b` name the same composite font: equal in any case? @@ -223,36 +321,50 @@ impl CompositeFont { } /// Why this composite font can't be used, if it can't: a name [`Self::clean_name`] refuses, a - /// part without a font, a value out of its range, or a scale or Scale at Center on a class - /// that has none. + /// part without a font, a value out of its range, a scale or Scale at Center on a class that + /// has none, or a custom character set [`Self::check_custom`] refuses. pub fn check(&self) -> Result<(), String> { if Self::clean_name(&self.name).as_deref() != Some(self.name.as_str()) { return Err(format!("`{}` can't name a composite font", self.name)); } for class in CharClass::ALL { - let p = self.parts.get(class); - let k = class.key(); - if p.font.trim().is_empty() { - return Err(format!("{k}: give a font")); + check_part(self.parts.get(class), class.key(), class.scales(), class == CharClass::Kana)?; + } + self.check_custom() + } + + /// Why this font's custom character sets can't be used, if they can't: more than + /// [`CUSTOM_SETS_MAX`], a name [`Self::clean_name`] refuses or two sets share (in any case), a + /// character [`CustomCharSet::takes`] refuses or a set lists twice, more than + /// [`CUSTOM_CHARS_MAX`] characters in a set, or a part [`Self::check`] would refuse. A set's + /// characters can be scaled across and up; only kana are scaled at the center. + pub fn check_custom(&self) -> Result<(), String> { + if self.custom.len() > CUSTOM_SETS_MAX { + return Err(format!("a composite font keeps at most {CUSTOM_SETS_MAX} character sets")); + } + let mut names = std::collections::HashSet::new(); + for s in &self.custom { + let name = &s.name; + if Self::clean_name(name).as_deref() != Some(name.as_str()) { + return Err(format!("`{name}` can't name a character set")); } - let within = |v: f64, r: &std::ops::RangeInclusive| v.is_finite() && r.contains(&v); - if !within(p.size, &COMPOSITE_SIZE) { - return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end())); + if !names.insert(name.to_lowercase()) { + return Err(format!("two character sets are named `{name}`")); } - if !within(p.baseline, &COMPOSITE_BASELINE) { - return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end())); - } - for v in [p.h_scale, p.v_scale] { - if !within(v, &COMPOSITE_SCALE) { - return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end())); + let mut seen = std::collections::HashSet::new(); + for (n, c) in s.characters.chars().enumerate() { + if n == CUSTOM_CHARS_MAX { + return Err(format!("`{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters")); } - if v != 100.0 && !class.scales() { - return Err(format!("{k}: only kana, roman and numerals can be scaled")); + let code = c as u32; + if !CustomCharSet::takes(c) { + return Err(format!("`{name}`: U+{code:04X} is a control character or past U+FFFF")); + } + if !seen.insert(c) { + return Err(format!("`{name}`: U+{code:04X} is listed twice")); } } - if p.scale_at_center && class != CharClass::Kana { - return Err(format!("{k}: only kana can be scaled at the center")); - } + check_part(&s.part, name, true, false)?; } Ok(()) } @@ -264,9 +376,41 @@ impl CompositeFont { let mut names = std::collections::HashSet::new(); let mut kept: Vec = fonts.iter().filter(|f| f.check().is_ok() && names.insert(f.name.to_lowercase())).take(COMPOSITE_FONTS_MAX).cloned().collect(); - kept.retain(|f| CharClass::ALL.iter().all(|&c| !names.contains(&f.parts.get(c).font.to_lowercase()))); + kept.retain(|f| f.part_fonts().all(|font| !names.contains(&font.to_lowercase()))); (kept.len() != fonts.len()).then_some(kept) } + + /// The fonts of the parts, each class's then each custom set's. + pub fn part_fonts(&self) -> impl Iterator { + CharClass::ALL.into_iter().map(|c| self.parts.get(c).font.as_str()).chain(self.custom.iter().map(|s| s.part.font.as_str())) + } +} + +/// Why part `p` (of the class or set named `k`) can't be used, if it can't: no font, a value out +/// of its range, a scale when `scales` is false, or Scale at Center when `center` is false. +fn check_part(p: &CompositePart, k: &str, scales: bool, center: bool) -> Result<(), String> { + if p.font.trim().is_empty() { + return Err(format!("{k}: give a font")); + } + let within = |v: f64, r: &std::ops::RangeInclusive| v.is_finite() && r.contains(&v); + if !within(p.size, &COMPOSITE_SIZE) { + return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end())); + } + if !within(p.baseline, &COMPOSITE_BASELINE) { + return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end())); + } + for v in [p.h_scale, p.v_scale] { + if !within(v, &COMPOSITE_SCALE) { + return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end())); + } + if v != 100.0 && !scales { + return Err(format!("{k}: only kana, roman and numerals can be scaled")); + } + } + if p.scale_at_center && !center { + return Err(format!("{k}: only kana can be scaled at the center")); + } + Ok(()) } #[cfg(test)] @@ -341,4 +485,81 @@ mod tests { spaced.name = " Mixed".into(); assert!(spaced.check().is_err()); } + + /// A character a custom set lists falls in that set ahead of its class, the later of two sets + /// that list it wins, and the quick lookup agrees with the one-character one. + #[test] + fn custom_sets_take_their_characters_before_the_classes() { + let mut f = CompositeFont::new("Mixed", "Inter", "Regular"); + let mut a = CustomCharSet::new("Marks", CompositePart::new("Source Sans 3", "Bold")); + a.characters = "※A".into(); + let mut b = CustomCharSet::new("Letters", CompositePart::new("Inter", "Italic")); + b.characters = "AB".into(); + f.custom = vec![a, b]; + let classes = f.classes(); + for (c, want) in [ + ('※', CompositeClass::Custom(0)), + ('A', CompositeClass::Custom(1)), + ('B', CompositeClass::Custom(1)), + ('C', CompositeClass::Fixed(CharClass::Roman)), + ('漢', CompositeClass::Fixed(CharClass::Kanji)), + ] { + assert_eq!(f.class_of(c), want, "{c:?}"); + assert_eq!(classes.of(c), want, "{c:?}"); + } + assert_eq!(f.part(CompositeClass::Custom(0)).map(|p| p.style.as_str()), Some("Bold")); + assert_eq!(f.part(CompositeClass::Custom(2)), None); + assert_eq!(f.check(), Ok(())); + // Read and written with the part's fields beside the set's name and characters. + let json = serde_json::to_value(&f).unwrap(); + assert_eq!(json["custom"][1]["characters"], "AB"); + assert_eq!(json["custom"][1]["style"], "Italic"); + assert_eq!(serde_json::from_value::(json).unwrap(), f); + // A font without sets is written as before. + assert!(serde_json::to_value(CompositeFont::new("Plain", "Inter", "Regular")).unwrap().get("custom").is_none()); + } + + /// Set names, characters and limits are checked; sets scale like Roman letters. + #[test] + fn custom_sets_check_their_names_and_characters() { + let ok = CompositeFont::new("Mixed", "Inter", "Regular"); + let with = |sets: Vec<(&str, &str)>, edit: fn(&mut CompositePart)| { + let mut f = ok.clone(); + for (name, chars) in sets { + let mut s = CustomCharSet::new(name, CompositePart::new("Inter", "Regular")); + s.characters = chars.into(); + edit(&mut s.part); + f.custom.push(s); + } + f.check() + }; + let none = |_: &mut CompositePart| {}; + assert_eq!(with(vec![("Empty", "")], none), Ok(())); + assert_eq!(with(vec![("Wide", "AB")], |p| p.h_scale = 80.0), Ok(())); + assert!(with(vec![("Wide", "AB")], |p| p.scale_at_center = true).is_err()); + assert!(with(vec![("Big", "A")], |p| p.size = 401.0).is_err()); + assert!(with(vec![("Same", "A"), ("same", "B")], none).unwrap_err().contains("two character sets")); + assert!(with(vec![(" Pad", "A")], none).is_err()); + assert!(with(vec![("Twice", "ABA")], none).unwrap_err().contains("U+0041 is listed twice")); + assert!(with(vec![("Tab", "A\t")], none).unwrap_err().contains("U+0009")); + assert!(with(vec![("Astral", "𠀋")], none).unwrap_err().contains("U+2000B")); + let many: String = (0x4E00u32..).filter_map(char::from_u32).take(CUSTOM_CHARS_MAX + 1).collect(); + let mut f = ok.clone(); + f.custom.push(CustomCharSet { name: "Many".into(), characters: many, part: CompositePart::new("Inter", "Regular") }); + assert!(f.check().unwrap_err().contains("at most")); + let mut f = ok.clone(); + f.custom = (0..=CUSTOM_SETS_MAX).map(|i| CustomCharSet::new(format!("S{i}"), CompositePart::new("Inter", "Regular"))).collect(); + assert!(f.check().is_err()); + } + + /// Codes are hexadecimal, with or without `U+` or `0x`, inside the Basic Multilingual Plane. + #[test] + fn custom_set_codes_name_characters() { + assert_eq!(CustomCharSet::char_from_code("3042"), Some('あ')); + assert_eq!(CustomCharSet::char_from_code(" U+203B "), Some('※')); + assert_eq!(CustomCharSet::char_from_code("0x41"), Some('A')); + for bad in ["", "U+", "zz", "20000B", "D800", "0009", "1F600", "1234567", "+41"] { + assert_eq!(CustomCharSet::char_from_code(bad), None, "{bad:?}"); + } + } } diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index 45cf181f18..c3db242ed0 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -17,22 +17,52 @@ pub enum Justify { JustifyAll, } -/// Paragraph composer (Paragraph panel menu): how lines are broken. +/// Paragraph composer (Paragraph panel menu): how lines are broken, and whether complex scripts +/// (Indic, Arabic, Hebrew) are shaped. `SingleLine` and `EveryLine` are the Middle Eastern & South +/// Asian composers: they shape complex scripts with the font's layout tables, as all type is set +/// here, and are what documents saved before the four composers existed use. The `Standard` +/// composers break lines the same way but set a complex script glyph by glyph as the font maps its +/// characters, with no conjuncts, marks placed or joining forms: what fonts that keep a script's +/// glyphs on Latin character codes (legacy ANSI Indic fonts) need. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Composer { - /// Break each line as soon as it is full. + /// Middle Eastern & South Asian Single-line Composer: break each line as soon as it is full. SingleLine, - /// Total fit over the whole paragraph (Illustrator's default): justified lines get even word - /// spacing, ragged lines an even rag. + /// Middle Eastern & South Asian Every-line Composer: total fit over the whole paragraph + /// (Illustrator's default): justified lines get even word spacing, ragged lines an even rag. #[default] EveryLine, + /// Standard Single-line Composer: [`Composer::SingleLine`]'s breaks, complex scripts not shaped. + StandardSingleLine, + /// Standard Every-line Composer: [`Composer::EveryLine`]'s breaks, complex scripts not shaped. + StandardEveryLine, } impl Composer { fn is_default(&self) -> bool { *self == Self::default() } + + /// Does this composer break lines one at a time (as opposed to a total fit)? + pub fn is_single_line(self) -> bool { + matches!(self, Self::SingleLine | Self::StandardSingleLine) + } + + /// Does this composer shape complex scripts ([`Composer::SingleLine`], [`Composer::EveryLine`])? + pub fn shapes_complex_scripts(self) -> bool { + matches!(self, Self::SingleLine | Self::EveryLine) + } + + /// The same shaping with the other line breaking (`single_line`: one line at a time). + pub fn with_breaking(self, single_line: bool) -> Self { + match (single_line, self.shapes_complex_scripts()) { + (true, true) => Self::SingleLine, + (true, false) => Self::StandardSingleLine, + (false, true) => Self::EveryLine, + (false, false) => Self::StandardEveryLine, + } + } } /// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 54d97a4527..d8f3ae0fe4 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -27,7 +27,9 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{ + as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, paint_type_clip, reshape_leaves, +}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, @@ -689,6 +691,9 @@ fn moved_type_group(n: &Node) -> Option { /// them. `None` when no type stays type. fn container_keeping_type(d: &Document, n: &Node) -> Option { fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { + if !clipping { + paint_type_clip(Some(d), n); + } let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 28f077cd1c..313d4f65ed 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -182,50 +182,50 @@ pub fn effect_catalog() -> Vec { REVOLVE, "Revolve…", &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[]}), + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps), render: object ({mode: shaded}; mode: shaded|wireframe, wireframe draws the edges of the faces the effect draws, each once, stroked 0.5 pt black with round joins, with no fill, shadow or graphics, whatever the opacity of the fills, strokes and group members, while the effect stays live; the stroke is VectorCraft's own)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[],"render":{"mode":"shaded"}}), ), g( EXTRUDE, "Extrude & Bevel…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", - json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), + "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", + json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[],"render":{"mode":"shaded"}}), ), g( INFLATE, "Inflate…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Painted with the top fill (else the top stroke).", - json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), + "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. Painted with the top fill (else the top stroke).", + json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"render":{"mode":"shaded"}}), ), g( ROTATE, "Rotate…", &["Effect", "3D and Materials"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[],"render":{"mode":"shaded"}}), ), g( CLASSIC_EXTRUDE, "Extrude & Bevel (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", - json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}), + "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", + json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false,"drawHiddenFaces":false}), ), g( CLASSIC_REVOLVE, "Revolve (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", - json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", + json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false}), ), g( CLASSIC_ROTATE, "Rotate (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, with black multiplied and overprinting where the art darkens, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false}), ), g( "convertToShape.rectangle", @@ -859,7 +859,7 @@ pub fn effect_catalog() -> Vec { crate::SVG_FILTER, "SVG Filter", &["Effect", "SVG Filters"], - "{name: string (the filter's id), xml: string (one SVG element with an id and 1..64 primitives, at most 64 KB), scale: number 0.001..1000 (1; points per unit of the filter's user space), origin: [x, y] pt ([0, 0]; where the filter's user space starts in the document)} runs the SVG filter on the object's pixels, its lengths in points times scale, at the Document Raster Effects resolution or finer; svgFilter.apply applies one of the document's filters (svgFilter.list)", + "{name: string (the filter's id), xml: string (one SVG element with an id and 1..64 primitives, at most 64 KB), scale: number 0.001..1000 (1; points per unit of the filter's user space), origin: [x, y] pt ([0, 0]; where the filter's user space starts in the document), axes?: [a, b, c, d] (absent or [1, 0, 0, 1]: the document's axes, and not null; the document directions of one unit along the filter's x axis [a, b] and y axis [c, d] before scale, each -1000..1000, with a·d - b·c at least 0.001 from 0; a turned, skewed or unevenly scaled user space, as SVG import gives a filtered element under a rotate, skew or uneven scale; the filter's offsets, blurs and region run along these axes; moving, turning or skewing the object moves origin and leaves axes)} runs the SVG filter on the object's pixels, its lengths in points times scale, at the Document Raster Effects resolution or finer; svgFilter.apply applies one of the document's filters (svgFilter.list)", json!({"scale": 1.0, "origin": [0.0, 0.0]}), )); v diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index cd9d67fc43..8a8730f66d 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -31,9 +31,10 @@ pub fn reshaped_clip_outline(clip: &Node) -> Option> /// become a path of their reshaped region. A path or compound path whose fills or strokes have /// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects /// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their -/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the -/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a -/// geometry effect. Effects on the clip group or around it then reshape the result, after its own. +/// union, and the group is marked as such pieces +/// ([`vectorcraft_doc::NodeExtras::clip_pieces`]), so a clipping path that paints still paints +/// them ([`Node::clip_paint`]). `None` when nothing in it has a geometry effect. Effects on the +/// clip group or around it then reshape the result, after its own. pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { with_clip_effects_applied_while(n, || true) } @@ -42,18 +43,20 @@ pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { /// or compound path in `n`, at every depth: once `take` returns `false`, that member and the /// members after it are not visited. The members of a group or layer that are not visited stay as /// they are, with their effects. A compound path with geometry effects of its own still becomes a -/// path of its reshaped geometry. In a clipping path that does not paint, one whose fills or -/// strokes have geometry effects still becomes a group of one path per fill and stroke. In both -/// cases the paths in it that are not visited go -/// into that geometry as they are, and their own effects are not applied. +/// path of its reshaped geometry. A path or compound path whose fills or strokes have geometry +/// effects still becomes a group of one path per fill and stroke. In both cases the paths in it +/// that are not visited go into that geometry as they are, and their own effects are not applied. pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option { - let paint = n.clip_paint(); - clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take) + let mut m = clip_effects_applied(n, true, take)?; + if matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) && matches!(m.kind, NodeKind::Group { clip: false, .. }) { + m.extras_mut().clip_pieces = true; + } + Some(m) } -/// [`with_clip_effects_applied_while`]; with `items` unset (a clipping path that paints, and the -/// paths of a compound path, which draw with the compound path's fills and strokes), the geometry -/// effects of `n`'s own fills and strokes are left out. +/// [`with_clip_effects_applied_while`]; with `items` unset (the paths of a compound path, which +/// draw with the compound path's fills and strokes), the geometry effects of `n`'s own fills and +/// strokes are left out. fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option { let mut m: Option = None; let members: &[std::sync::Arc] = match &n.kind { @@ -88,6 +91,17 @@ fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option); -/// The members of `n` holding or leading to the ruler guides of sublayers, found by looking at each -/// member at every depth once: the ruler guides (`is_guide`) of `n` when it is a layer, each -/// sublayer holding one directly or in its sublayers, and each member of another kind with such a -/// sublayer among its members or its groups' members. With them, whether `n` is a layer holding a -/// ruler guide directly or in its sublayers. The other members of a layer holding none are not -/// looked into, unless the layer is the one whose sublayers are gathered (`top`). -fn held_guides(n: &Node, top: bool, is_guide: &impl Fn(&Node) -> bool) -> (bool, HeldGuides) { - let layer = n.is_layer(); - let (mut holds, mut held, mut other) = (false, vec![], vec![]); +/// The members of `n` that are ruler guides (`is_guide`), and those holding one at any depth, in +/// order. Every member at every depth is looked at once: a sublayer or group with no guide of its +/// own is still looked into, and a guide directly in a group counts as one in a layer does. A +/// member holding no guide gets no entry. +fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> HeldGuides { + let mut held = vec![]; for (i, c) in n.children().into_iter().flatten().enumerate() { if is_guide(c) { - if layer { - holds = true; - held.push((i, HeldGuides::default())); - } - } else if c.is_layer() { - let (sub_holds, inner) = held_guides(c, false, is_guide); - if sub_holds { - holds = true; - held.push((i, inner)); - } - } else { - other.push((i, c)); + held.push((i, HeldGuides::default())); + continue; + } + let inner = held_guides(c, is_guide); + if !inner.0.is_empty() { + held.push((i, inner)); } } - if !layer || holds || top { - for (i, c) in other { - let (_, inner) = held_guides(c, false, is_guide); - if !inner.0.is_empty() { - held.push((i, inner)); - } - } - held.sort_unstable_by_key(|(i, _)| *i); - } - (layer && holds, HeldGuides(held)) + HeldGuides(held) } /// Move the ruler guides of `n` and of its members that [`held_guides`] found (`held`) to `out`: @@ -818,15 +801,14 @@ fn take_held_guides(n: &mut Node, held: HeldGuides, out: &mut Vec>, hi out.append(&mut deeper); } -/// Move the ruler guides of the sublayers among the members of `n`, and among its groups' -/// members, to `out`, in order: a sublayer's own guides, then those of its members. A guide taken -/// from a hidden or locked sublayer, or from one inside such a sublayer or group, is hidden or -/// locked itself. Each member at every depth is looked at once at most (`is_guide`), and only -/// containers holding one are copied. +/// Move the ruler guides of `n` and those held at any depth by the sublayers and groups among its +/// members to `out`, in order: a container's own guides, then those of its members. A sublayer or +/// group with no guide of its own still gives up those of its members. A guide taken from a hidden +/// or locked sublayer or group, or from one inside such a container, is hidden or locked itself. +/// Each member at every depth is looked at once (`is_guide`), and only containers holding a guide +/// are copied. fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, is_guide: &impl Fn(&Node) -> bool) { - let (_, mut held) = held_guides(n, true, is_guide); - // The ruler guides of `n` itself stay where they are. - held.0.retain(|(_, inner)| !inner.0.is_empty()); + let held = held_guides(n, is_guide); take_held_guides(n, held, out, false, false); } @@ -850,12 +832,15 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { return None; } let mut m = n.clone(); - // A layer's ruler guides take no part: they go back above the evaluated art at the end. - let mut guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); - // So do its sublayers' guides, which would otherwise go with the art the effects replace or - // copy: they stay on this layer, hidden and locked as their sublayers were. - if m.is_layer() && (pathfinder || !fx.is_empty()) { + // Its ruler guides take no part: they go back above the evaluated art at the end. With effects + // that replace or copy the art, so do its guides among the art and those in its sublayers and + // groups, which would otherwise go with that art: they stay on `n`, in order, hidden and + // locked as their containers were. + let mut guides = vec![]; + if pathfinder || !fx.is_empty() { take_sublayer_guides(&mut m, &mut guides, &Node::is_ruler_guide); + } else if let Some(ch) = m.children_mut() { + guides = ch.split_off(Node::guide_run_start(ch)); } if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 519323e5f0..50fbf8f759 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1808,7 +1808,7 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() } /// Gathering the ruler guides of nested sublayers, and of a sublayer inside nested groups, looks -/// at each member at every depth once at most, and takes the same guides, in the same order and +/// at each member at every depth once, and takes the same guides, in the same order and /// state, as a layer's effects keep. #[test] fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { @@ -1840,7 +1840,7 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { groups.locked = depth == 3; } let free = Arc::new(Node::group(NodeId(500), vec![sq(501, 0.0, Color::BLACK), sq(502, 5.0, Color::BLACK)])); - // A sublayer with no guide: the art in its group is not looked at. + // A sublayer with no guide, its group holding none either. let quiet = Node::group(NodeId(601), vec![sq(602, 0.0, Color::BLACK), sq(603, 5.0, Color::BLACK)]); let quiet = Arc::new(layer(600, vec![Arc::new(quiet)])); let mut root = layer(10, vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(chain), Arc::new(groups), free.clone(), quiet.clone(), guide(20)]); @@ -1858,15 +1858,16 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { }; let mut taken = vec![]; take_sublayer_guides(&mut m, &mut taken, &is_guide); - assert_eq!(looked.get(), members(&root) - 2, "each member is looked at once, none in the quiet sublayer's group"); - assert_eq!(state(&taken), expected, "a sublayer's own guides, then its sublayers', each once"); - assert!(Arc::ptr_eq(&taken[0], &first_guide), "a guide with nothing to change is not copied"); - assert!(!Arc::ptr_eq(&taken[1], &second_guide), "a guide made hidden is copied"); + assert_eq!(looked.get(), members(&root), "each member is looked at once, those of guide-free containers included"); + assert_eq!(taken[0].id, NodeId(20), "the layer's own guide comes first"); + assert_eq!(state(&taken[1..]), expected, "a sublayer's own guides, then its sublayers', each once"); + assert!(Arc::ptr_eq(&taken[1], &first_guide), "a guide with nothing to change is not copied"); + assert!(!Arc::ptr_eq(&taken[2], &second_guide), "a guide made hidden is copied"); assert!(second_guide.visible, "the guide as it was stays as it was"); let ch = m.children().unwrap(); + assert_eq!(ch.len(), 5, "the layer's own guide is taken"); assert!(Arc::ptr_eq(&ch[3], &free), "a member with no sublayer guide is not copied"); assert!(Arc::ptr_eq(&ch[4], &quiet), "a sublayer with no guide is not copied"); - assert!(ch[5].is_ruler_guide(), "the layer's own guide stays where it is"); let ids = |n: &Node| n.children().into_iter().flatten().map(|c| c.id).collect::>(); assert_eq!(ids(&ch[1]), [NodeId(102), NodeId(301)], "the sublayer's art stays, in order"); assert_eq!(ids(&root.children().unwrap()[1]), [NodeId(102), NodeId(301), NodeId(201)], "the source keeps its guides"); @@ -1892,6 +1893,140 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { } } +/// Where each ruler guide in `n` is, at every depth, with its position, visibility and lock. +fn ruler_guides_in(n: &Node, out: &mut Vec<(NodeId, NodeKind, bool, bool)>) { + for c in n.children().into_iter().flatten() { + if c.is_ruler_guide() { + out.push((c.id, c.kind.clone(), c.visible, c.locked)); + } + ruler_guides_in(c, out); + } +} + +/// A ruler guide in a layer inside a group inside a sublayer that holds no guide of its own, at +/// any depth of that pattern, survives its layer's Pathfinder effect or Transform copies once, last +/// in the layer, hidden or locked when any container on its way was. +#[test] +fn a_ruler_guide_below_a_guide_free_sublayer_and_a_group_survives_its_layers_effects() { + use vectorcraft_doc::LayerColor; + let layer = |id, children: Vec>, (hidden, locked): (bool, bool)| { + let mut l = Node::layer(NodeId(id), "Sub", LayerColor::Preset(1)); + if let Some(ch) = l.children_mut() { + *ch = children; + } + (l.visible, l.locked) = (!hidden, locked); + Arc::new(l) + }; + let group = |id, children: Vec>, (hidden, locked): (bool, bool)| { + let mut g = Node::group(NodeId(id), children); + (g.visible, g.locked) = (!hidden, locked); + Arc::new(g) + }; + let kind = NodeKind::RulerGuide { vertical: false, pos: 7.0, artboard: None }; + let states = [(false, false), (true, false), (false, true)]; + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + for depth in 1..=3_u64 { + for sub in states { + for grp in states { + for inner in states { + // Layer 10 → sublayer → group → … → layer holding the guide, the pattern + // repeated `depth` times; only the innermost layer holds a guide. + let mut n = layer(900, vec![sq(901, 0.0, Color::BLACK), Arc::new(Node::new(NodeId(20), kind.clone()))], inner); + for d in (0..depth).rev() { + let (s, g) = if d == 0 { (sub, grp) } else { ((false, false), (false, false)) }; + n = group(700 + d, vec![sq(710 + d, 5.0, Color::BLACK), n], g); + n = layer(800 + d, vec![n, sq(810 + d, 2.0, Color::BLACK)], s); + } + let mut root = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = root.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), n]; + } + root.appearance.effects.push(Effect { id: effect.into(), params: params.clone(), visible: true }); + let art = evaluate_container(None, &root).unwrap(); + let (hidden, locked) = (sub.0 || grp.0 || inner.0, sub.1 || grp.1 || inner.1); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + let case = format!("{effect}, depth {depth}, sublayer {sub:?}, group {grp:?}, layer {inner:?}"); + assert_eq!(all, [(NodeId(20), kind.clone(), !hidden, locked)], "{case}: the guide, once, unchanged"); + let last = art.children().and_then(|c| c.last()).unwrap(); + assert_eq!(last.id, NodeId(20), "{case}: last in the layer"); + } + } + } + } + } +} + +/// A ruler guide directly in a group, in a layer or in a group with a Pathfinder effect or +/// Transform copies, survives that effect once, last in the container, hidden or locked as its +/// groups were. So does one among a group's art rather than after it. +#[test] +fn a_ruler_guide_in_a_group_survives_its_containers_effects() { + use vectorcraft_doc::LayerColor; + let kind = NodeKind::RulerGuide { vertical: true, pos: 4.0, artboard: None }; + let guide = || Arc::new(Node::new(NodeId(20), kind.clone())); + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + for (hidden, locked) in [(false, false), (true, false), (false, true)] { + let mut inner = Node::group(NodeId(12), vec![sq(2, 5.0, Color::BLACK), guide(), sq(3, 8.0, Color::BLACK)]); + (inner.visible, inner.locked) = (!hidden, locked); + let outer = Arc::new(Node::group(NodeId(11), vec![Arc::new(inner)])); + let fx = Effect { id: effect.into(), params: params.clone(), visible: true }; + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), outer.clone()]; + } + l.appearance.effects.push(fx.clone()); + let mut g = Node::group(NodeId(13), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), outer]); + g.appearance.effects.push(fx.clone()); + for (what, n) in [("layer", l), ("group", g)] { + let art = evaluate_container(None, &n).unwrap(); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + let case = format!("{effect} on a {what}, hidden {hidden}, locked {locked}"); + assert_eq!(all, [(NodeId(20), kind.clone(), !hidden, locked)], "{case}: the guide, once, unchanged"); + assert_eq!(art.children().and_then(|c| c.last()).map(|c| c.id), Some(NodeId(20)), "{case}: last"); + } + } + // A group's guide placed before its art. + let mut g = Node::group(NodeId(13), vec![guide(), sq(1, 0.0, Color::BLACK), sq(2, 5.0, Color::BLACK)]); + g.appearance.effects.push(Effect { id: effect.into(), params: params.clone(), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + assert_eq!(all, [(NodeId(20), kind.clone(), true, false)], "{effect}: a group's leading guide, once"); + assert_eq!(art.children().and_then(|c| c.last()).map(|c| c.id), Some(NodeId(20)), "{effect}: moved last"); + } +} + +/// The ruler guides of a layer or group with a Pathfinder effect or Transform copies, one among +/// its art and one after it, go after the evaluated art in the order they were in. +#[test] +fn a_containers_ruler_guides_keep_their_order_through_its_effects() { + use vectorcraft_doc::LayerColor; + let guide = |id| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None })); + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + let members = || vec![sq(1, 0.0, Color::BLACK), guide(20), sq(2, 5.0, Color::BLACK), guide(21)]; + let fx = Effect { id: effect.into(), params: params.clone(), visible: true }; + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = members(); + } + l.appearance.effects.push(fx.clone()); + let mut g = Node::group(NodeId(11), members()); + g.appearance.effects.push(fx); + for (what, n) in [("layer", l), ("group", g)] { + let art = evaluate_container(None, &n).unwrap(); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + let ids = all.iter().map(|(id, ..)| *id).collect::>(); + assert_eq!(ids, [NodeId(20), NodeId(21)], "{effect} on a {what}: the guides, in order"); + let ch = art.children().unwrap(); + let tail = ch[ch.len() - 2..].iter().map(|c| c.id).collect::>(); + assert_eq!(tail, [NodeId(20), NodeId(21)], "{effect} on a {what}: last, in order"); + } + } +} + /// A group's own Fill + Offset Path below its contents follows the outline of a clip group among /// its members, its clipping path, not the clipped art it hides outside that path (#7289). #[test] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a326c85cf2..e3915797f2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -81,7 +81,10 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry}; +pub use raster::{ + RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, rasterize_stage, stage_map, staged_effects, + without_geometry, +}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outer_effects, outline_art, @@ -91,12 +94,13 @@ pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, }; pub use solids::{ - CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, ROTATE, ROTATE_SURFACES, - ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, graphic_bounds, - graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol, - names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, solid_art, solid_art_in, takes_graphics, three_d_meshes, - three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, validate_solid_in, - validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, RENDER, RENDER_MODES, ROTATE, + ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, + graphic_bounds, graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol, + names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, renders_wireframe, solid_art, solid_art_in, takes_graphics, + three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, + validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + with_graphic, }; pub use solids::{ GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, GRAPHICS_NOT_TAKEN, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index 7f57abe4bf..ef8b32d56e 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -606,9 +606,62 @@ fn transform_step(e: &Effect) -> Option { (copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO)) } +/// The Transform effects of `effects` that move the image of its last visible Rasterize effect +/// instead of the art it is made from: the indices of the visible Transform effects listed after +/// it that move the art, when from the first visible Transform effect after it on every visible +/// effect is a Transform effect that moves the art once (no copies, not at random). Effects apply +/// in the order they are listed, so the image is made of the art before those Transform effects +/// and moves with them, its pixel grid and room around the art included. `None` when there is no +/// such Transform effect, or the effects after the Rasterize effect hold anything else from there +/// on. +pub fn rasterize_stage(effects: &[Effect]) -> Option> { + let r = effects.iter().rposition(|e| e.visible && e.id == RASTERIZE)?; + let first = r + 1 + effects.get(r + 1..)?.iter().position(|e| e.visible && e.id == "distort.transform")?; + let mut at = vec![]; + for (i, e) in effects.iter().enumerate().skip(first).filter(|(_, e)| e.visible) { + if e.id != "distort.transform" || transform_step(e).is_none() { + return None; + } + if crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).0 != Affine::IDENTITY { + at.push(i); + } + } + (!at.is_empty()).then_some(at) +} + +/// `effects` with the Transform effects at `at` hidden ([`rasterize_stage`]): what the image of +/// the Rasterize effect before them is made from. +pub fn staged_effects(effects: &[Effect], at: &[usize]) -> Vec { + let mut out = effects.to_vec(); + for &i in at { + if let Some(e) = out.get_mut(i) { + e.visible = false; + } + } + out +} + +/// The map the Transform effects at `at` of `effects` ([`rasterize_stage`]) move the Rasterize +/// image by, for `staged`, the geometry the effects before them make: each one's map about its +/// reference point on the bounds of the geometry it takes, as the geometry pass applies it, the +/// last one outermost. `None` when `staged` is empty or the map is not a number. +pub fn stage_map(effects: &[Effect], at: &[usize], staged: &vectorcraft_geom::PathData) -> Option { + let mut cur = staged.clone(); + let mut b = staged.bounds()?; + let mut map = Affine::IDENTITY; + for &i in at { + let e = effects.get(i)?; + let (m, _) = crate::distort::transform_map(b, &merged_params(&e.id, &e.params), 0); + map = m * map; + cur = cur.transformed(m); + b = cur.bounds().unwrap_or(b); + } + map.is_finite().then_some(map) +} + /// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG /// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room -/// around the art. +/// around the art: the image moves with those Transform effects instead ([`rasterize_stage`]). pub(crate) fn follows(id: &str) -> bool { crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 70e5994cb5..4c8191fd2b 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -746,9 +746,13 @@ pub(crate) fn clipping_text_outline(t: &Node) -> Option { /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes -/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and -/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`. +/// its outlines with its visibility and effects ([`clipping_text_outline`]), and its fills and +/// strokes become art of their own ([`paint_type_clip`]). The swatches and patterns that +/// live-effect plug-ins painting their pieces name are looked up in `doc`. pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { + if !clipping { + paint_type_clip(doc, n); + } let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { @@ -766,6 +770,36 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping } } +/// When `n` is a clip group or clipping layer whose clipping path is type that paints +/// ([`Node::clip_paint`]), make it the art the canvas draws for it: the type's outlines clip +/// ([`clipping_text_outline`]), its fills as art ([`as_art`]) paint behind the clipped art inside +/// the clip, and its strokes as art paint over it, outside the clip, in a group that `n` becomes +/// (keeping its id, name and transparency) with the clip group inside. The other members stay as +/// they are. Hidden type paints nothing. +pub(crate) fn paint_type_clip(doc: Option<&Document>, n: &mut Node) { + let Some((first, rest)) = n.children().and_then(|ch| ch.split_first()) else { return }; + if !n.clips() || !matches!(first.kind, NodeKind::Text(_)) || !first.visible { + return; + } + let paint = first.clip_paint(); + if paint.fill.is_none() && paint.stroke.is_none() { + return; + } + let Some(clip) = clipping_text_outline(first) else { return }; + let art = |part: Option| part.and_then(|p| as_art(doc, &p, None)).map(Arc::new); + let members: Vec> = std::iter::once(Arc::new(clip)).chain(art(paint.fill)).chain(rest.iter().cloned()).collect(); + let stroke = art(paint.stroke); + let id = n.id; + match (&mut n.kind, stroke) { + (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }, None) => *children = members, + (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }, Some(stroke)) => { + *children = vec![Arc::new(Node::new(id, NodeKind::Group { children: members, clip: true })), stroke]; + *clip = false; + } + _ => {} + } +} + /// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied: /// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped /// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index a9c9482d1d..a49e817ef2 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, N use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve, SolidFaces}; -use crate::solids::wireframe::WireFace; +use crate::solids::wireframe::{WireFace, wireframe_params}; use crate::solids::{ Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, }; @@ -154,7 +154,12 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let b = union_bounds(&shapes).ok_or(EMPTY)?; let options = shared_options(&shapes, options_of(id, p))?; let mut copies = 0; - shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; + // In wireframe mode the edges are drawn with no paint copies, graphics or shadow. + let wire = wireframe_params(id, p); + shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items).filter(|_| !wire), &mut copies))?; + if wire { + return Ok(()); + } // Without graphics, or without the document they draw from, no member is measured for them. if let Some(doc) = doc.filter(|_| id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty()) { match graphics_member(&shapes, b, options).and_then(|k| shapes.get(k)) { @@ -166,7 +171,7 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) } } } - let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0)); + let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| crate::solids::shadow::casts(paint_of(i).1 * s.opacity))); return check_shadow(p, casting.map(|s| &s.path), b, options); } let (path, _) = object_profile(n, "Revolve")?; @@ -176,7 +181,11 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let path = geometry(&path, before, &GeomContext::of(n)); let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); - check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?; + let wire = wireframe_params(id, p); + check_surface(&path, b, options_of(id, p), surface_item(&paints).filter(|_| !wire), &mut 0)?; + if wire { + return Ok(()); + } check_graphics(doc, id, &path, b, options_of(id, p), p, &mut 0)?; check_shadow(p, [&path], b, options_of(id, p)) } @@ -261,7 +270,11 @@ pub(crate) fn validate_item_of(doc: Option<&Document>, n: &Node, id: &str, p: &V let options = options_of(kind, ¶ms); let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; - check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + let wire = wireframe_params(kind, ¶ms); + check_surface(&path, b, options, (crate::paints(item) && !wire).then_some(item), &mut copies)?; + if wire { + continue; + } // The graphics of every item add their paints' copies to the graphics' budget, as they // are drawn; only the edited item's graphics are refused here. let graphics = check_graphics(doc, kind, &path, b, options, ¶ms, &mut graphic_copies); @@ -324,6 +337,7 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; crate::solids::graphics::validate(id, p)?; + crate::solids::wireframe::validate_render(id, p)?; if id == CLASSIC_REVOLVE { classic::validate(p)?; } @@ -336,8 +350,8 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// The shadow of Revolve `fx` cast by the surface of `path` in `item`'s paint about the axis of /// `b`, revolved with `options`, as dark as `item` is opaque and in its blending mode; `None` with -/// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when -/// the surface it is cast from is past the limits. +/// the shadow off, when `item` is fully transparent or its opacity is not a finite number, or when +/// it casts nothing, and an error when the surface it is cast from is past the limits. /// /// `kept` holds the faces the surface drawn kept for its shadow ([`surface`]); without them the /// surface is swept again for its shadow alone. @@ -352,7 +366,7 @@ fn shadow_of( ) -> Result, String> { let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(None) }; let (_, opacity, blend) = paint_of(item); - if opacity <= 0.0 { + if !crate::solids::shadow::casts(opacity) { return Ok(None); } let mut caster = vectorcraft_three_d::ShadowCaster::new(); @@ -363,7 +377,7 @@ fn shadow_of( /// Does Revolve `fx` cast a shadow, so a surface in paint as opaque as `opacity` keeps the faces /// its shadow is cast from ([`surface`])? fn casts(fx: &Effect, opacity: f32) -> bool { - opacity > 0.0 && crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)).is_some() + crate::solids::shadow::casts(opacity) && crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)).is_some() } /// Add the surface of `path` about the axis of `b` to `caster`: the faces it kept when it was @@ -406,9 +420,9 @@ fn group_shadow( for s in shapes { let kept = kept.next().flatten(); let Some((_, opacity, blend)) = surface_item(&s.items).map(paint_of) else { continue }; - // A member whose surface is fully transparent casts nothing. + // A member whose surface is fully transparent, or whose opacity is not a finite number, casts nothing. let ink = crate::solids::shadow::Ink { opacity: opacity * s.opacity, blend }; - if ink.opacity <= 0.0 { + if !ink.casts() { continue; } let faces = cast_surface(&mut caster, &s.path, b, options, kept)?; @@ -728,14 +742,7 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul /// or stroke's Revolve are looked up in `doc`. Each face carries the width of the stroke the canvas /// draws around it ([`surface`]). pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result { - let faces = |path: &PathData, b: Rect, fx: &Effect, options: Revolve| -> Result, String> { - if fx.id == CLASSIC_REVOLVE { - let p = merged_params(&fx.id, &fx.params); - let facets = vectorcraft_three_d::revolve_facets(path, b, options)?; - return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect()); - } - Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect()) - }; + let faces = wire_faces; if is_container(n) { let index = n .appearance @@ -752,7 +759,9 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node) -> Result Result, String> { + if fx.id == CLASSIC_REVOLVE { + let p = merged_params(&fx.id, &fx.params); + let facets = vectorcraft_three_d::revolve_facets(path, b, options)?; + return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect()); + } + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect()) +} + +/// The art Revolve `fx` in wireframe mode draws for object `id` from the profiles `paths`, all swept +/// about the bounds `b` with `options`: the edges of their faces ([`crate::solids::wireframe::wire_art`]). +/// `None` when a profile can't be swept or past the edge limit, when the canvas draws the source art. +fn wire_art<'a>(id: NodeId, paths: impl IntoIterator, b: Rect, fx: &Effect, options: Revolve) -> Option> { + let mut faces = vec![]; + for path in paths { + faces.extend(wire_faces(path, b, fx, options).ok()?.into_iter().map(|f| f.path)); + } + crate::solids::wireframe::wire_art(id, &faces) +} + /// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let index = n @@ -824,13 +855,18 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; - if let Some(b) = path.bounds() - && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()? - { - children.push(Arc::new(shadow)); + if crate::solids::renders_wireframe(fx) { + // In wireframe mode the edges of the faces, with no shadow. + children.push(wire_art(n.id, [&path], path.bounds()?, fx, fx_options(fx))?); + } else { + let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; + if let Some(b) = path.bounds() + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()? + { + children.push(Arc::new(shadow)); + } + children.push(Arc::new(surface)); } - children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = past_effect(&n.appearance.effects, index); remaining.retain(|e| !is_revolve(&e.id)); @@ -850,6 +886,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + if crate::solids::renders_wireframe(fx) { + let mut art = wire_art(n.id, [&p], p.bounds()?, fx, fx_options(fx))?; + Arc::make_mut(&mut art).appearance.effects = + item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); + children.push(art); + continue; + } let (mut art, kept) = object_surface(doc, n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() @@ -999,6 +1042,14 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let b = union_bounds(&shapes)?; let options = shared_options(&shapes, fx_options(fx)).ok()?; + if crate::solids::renders_wireframe(fx) { + // In wireframe mode the edges of every member's faces, with no shadow. + let paths = shapes.iter().filter(|s| surface_item(&s.items).is_some()).map(|s| &s.path); + let mut art = n.clone(); + art.kind = NodeKind::Group { children: vec![wire_art(n.id, paths, b, fx, options)?], clip: false }; + art.appearance = Appearance { effects: past_effect(&n.appearance.effects, index), ..Default::default() }.into(); + return Some(art); + } let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut copies = Copies::default(); let mut surfaces = vec![]; @@ -1810,6 +1861,55 @@ mod tests { } } } + + /// A surface whose fill, or whose member, is at NaN or infinite opacity casts no shadow, as one + /// at 0 % does, whether the shadow is cast from the surface drawn or swept for it alone; the + /// member beside it still casts its part. + #[test] + fn a_surface_whose_opacity_is_not_a_finite_number_casts_no_shadow() { + let fx = crate::new_effect(REVOLVE, &json!({"shadow": true})).unwrap(); + let group = |opacity: f32| { + let mut other = profile_node(); + other.id = NodeId(2); + other.transform(Affine::translate((120.0, 0.0)), false); + other.opacity = opacity; + let mut g = Node::group(NodeId(9), vec![Arc::new(profile_node()), Arc::new(other)]); + g.appearance.effects.push(fx.clone()); + g + }; + let group_shadows = |g: &Node| { + let shapes = members(g, "").unwrap(); + let b = union_bounds(&shapes).unwrap(); + let options = shared_options(&shapes, fx_options(&fx)).unwrap(); + let kept: Vec> = shapes + .iter() + .map(|s| { + let (paint, opacity, blend) = paint_of(surface_item(&s.items).unwrap()); + let cast = casts(&fx, opacity * s.opacity); + surface(None, g, &s.path, b, options, &fx, paint, opacity, blend, false, &mut Copies::default(), cast).unwrap().2 + }) + .collect(); + [group_shadow(g.id, &fx, &shapes, b, options, kept).unwrap(), group_shadow(g.id, &fx, &shapes, b, options, vec![]).unwrap()] + .map(|art| format!("{art:?}")) + }; + let expected = group_shadows(&group(0.0)); + for bad in [f32::NAN, f32::INFINITY] { + let mut n = profile_node(); + if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] { + f.opacity = bad; + } + let (path, _) = profile(&n).unwrap(); + let b = path.bounds().unwrap(); + let item = surface_item(&n.appearance.items).unwrap(); + assert!(!casts(&fx, bad), "{bad}: the surface keeps no faces for a shadow"); + let (_, kept) = object_surface(None, &n, &path, &fx, item, false, &mut Copies::default()).unwrap(); + assert_eq!(shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), kept).unwrap(), None, "{bad} drawn"); + assert_eq!(shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), None).unwrap(), None, "{bad} alone"); + let shadows = group_shadows(&group(bad)); + assert!(!expected[0].is_empty() && expected[0] != "[]", "the opaque member casts"); + assert_eq!(shadows, expected, "{bad}: only the opaque member casts"); + } + } } #[cfg(test)] diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs index 1881381567..8dc013e9dc 100644 --- a/crates/effects/src/revolve/classic.rs +++ b/crates/effects/src/revolve/classic.rs @@ -23,7 +23,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [ ("blendSteps", 1.0, 256.0, true), ]; -const FLAGS: [&str; 2] = ["cap", "lightBehind"]; +const FLAGS: [&str; 3] = ["cap", "lightBehind", "preserveSpotColors"]; /// The surface Revolve (Classic)'s parameters `p` sweep: Revolve's angle, cap, offset, edge, /// rotation and perspective, built unshaded, since the classic surfaces light its faces. @@ -118,6 +118,52 @@ mod tests { } } + /// With Preserve Spot Colors, a fill linked to a spot swatch stays linked on every face: the + /// highlight lowers its tint, and the shadow is black multiplied and overprinting over it. + #[test] + fn preserve_spot_colors_keeps_the_swatch_on_every_face() { + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2); + let spot = |params: Value| { + let mut n = profile(); + *n.appearance = Appearance::basic(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 0.5 }, Paint::None, 0.0); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, ¶ms).unwrap()); + crate::revolve_art(&n).unwrap() + }; + let art = spot(json!({"preserveSpotColors": true})); + let mut darkened = 0; + for face in faces(&art) { + let mut fills = face.appearance.items.iter().filter_map(|i| match i { + AppearanceItem::Fill(f) => Some(f), + AppearanceItem::Stroke(_) => None, + }); + match fills.next().map(|f| &f.paint) { + Some(Paint::Solid { color, swatch: Some(name), tint }) => { + assert_eq!(name, "Ink"); + assert!(*tint > 0.0 && *tint <= 0.5, "{tint}"); + assert_eq!(*color, ink.tinted(*tint / 0.5), "the color is the swatch at the face's tint"); + } + other => panic!("the face keeps its swatch: {other:?}"), + } + if let Some(shadow) = fills.next() { + assert!(matches!(shadow.paint.color(), Some(Color::Gray { .. })), "{:?}", shadow.paint); + assert_eq!((shadow.blend, shadow.overprint), (vectorcraft_doc::color::BlendMode::Multiply, true)); + darkened += 1; + } + } + assert!(darkened > 0, "faces turned from the light are darkened"); + // Off, and with a custom shading color, the faces are process mixes again. + for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#336699"})] { + let art = spot(params.clone()); + let linked = faces(&art) + .iter() + .filter(|f| { + f.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Solid { swatch: Some(_), .. }))) + }) + .count(); + assert!(linked < faces(&art).len(), "{params}: shaded faces lose the swatch"); + } + } + #[test] fn angle_offset_edge_and_cap_shape_the_surface() { let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap(); @@ -207,6 +253,7 @@ mod tests { (json!({"rotationY": -181}), "`rotationY` must be between -180 and 180"), (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number"), (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"preserveSpotColors": "on"}), "`preserveSpotColors` must be true or false"), (json!({"surface": "glossy"}), "`surface` must be one of"), (json!({"shadingColor": "white"}), "`shadingColor` must be one of"), (json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"), diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index c0d44947d7..cdc2376724 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -36,7 +36,7 @@ pub use graphics::{ pub(crate) mod shadow; pub(crate) mod wireframe; -pub use wireframe::three_d_wireframe_in; +pub use wireframe::{RENDER, RENDER_MODES, renders_wireframe, three_d_wireframe_in}; pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; @@ -274,6 +274,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { crate::lighting::validate(p)?; shadow::validate(p)?; graphics::validate(id, p)?; + wireframe::validate_render(id, p)?; if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { @@ -324,7 +325,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; - return cast_shadow(id, p, &shapes).map(|_| ()); + return check_cast(id, p, &shapes); } let (path, rule) = object_profile(n, label)?; let anchors = || format!("{label} requires a shape with at least two anchors"); @@ -342,7 +343,16 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; - cast_shadow(id, p, &shapes).map(|_| ()) + check_cast(id, p, &shapes) +} + +/// With the shadow of 3D effect `id` with parameters `p` on, an error when the shadow of `shapes` +/// is past the limits ([`cast_shadow`]). In wireframe mode no shadow is drawn, and none is checked. +fn check_cast(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> { + if wireframe::wireframe_params(id, p) { + return Ok(()); + } + cast_shadow(id, p, shapes).map(|_| ()) } /// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b` @@ -1420,6 +1430,10 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// off), cast from the faces the solids drawn kept ([`cast_built`]), to be drawn behind every /// surface of the object, and the surfaces. fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { + // In wireframe mode the effect draws the edges of the faces it builds, with no shadow. + if wireframe::renders_wireframe(fx) { + return Some((vec![], wireframe::wire_surfaces(n.id, fx, shapes)?)); + } let p = crate::merged_params(&fx.id, &fx.params); let cast = !is_classic_three_d(&fx.id) && shadow::options(&p).is_some(); let (art, built) = surfaces_for(doc, n, fx, shapes, cast)?; @@ -1434,8 +1448,11 @@ fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> } /// The shadow [`art_for`] draws for effect `fx` on `shapes` behind the surfaces of object `id`: -/// none with the shadow off, and an error when it is past the limits. +/// none with the shadow off or in wireframe mode, and an error when it is past the limits. pub(crate) fn shadow_for(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Result>, String> { + if wireframe::renders_wireframe(fx) { + return Ok(vec![]); + } Ok(match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes)? { Some((caster, settings, cast)) => shadow::scoped_art(id, &caster, settings, cast, has_masks(shapes)), None => vec![], @@ -1457,8 +1474,8 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result = vec![]; let ink = |item: &AppearanceItem, member: f32| { let (_, opacity, blend) = paint_of(item, member); @@ -1466,7 +1483,7 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result 0.0) { + for item in s.items.iter().filter(|i| ink(i, s.opacity).casts()) { let (area, rule) = region(&s.path, s.rule, item); #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); @@ -1480,7 +1497,7 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result, String>> = if id == INFLATE { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - let casts: Vec = inks.iter().map(|i| i.opacity > 0.0).collect(); + let casts: Vec = inks.iter().map(|i| i.casts()).collect(); #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + shapes.len())); caster.inflate(&shapes, &casts, bounds, inflate_options(p), view) @@ -1488,13 +1505,13 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result 0.0))); - added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); + SOLIDS_BUILT.with(|n| n.set(n.get() + usize::from(ink.casts()))); + added.push(if ink.casts() { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); } added }; for (((item, _, _, _, scopes, _), ink), faces) in parts.iter().zip(inks).zip(added) { - if ink.opacity <= 0.0 { + if !ink.casts() { continue; } // A stroke whose own solid is past the limits is left out; past the shadow's limit for @@ -1535,7 +1552,7 @@ fn cast_built<'a>(id: &str, p: &Value, shapes: &'a [Profile], built: Vec(id: &str, p: &Value, shapes: &'a [Profile], built: Vec = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect(); - if parts.len() != built.len() || (id == INFLATE && inks.iter().zip(&built).any(|(ink, kept)| ink.opacity > 0.0 && kept.is_none())) { + if parts.len() != built.len() || (id == INFLATE && inks.iter().zip(&built).any(|(ink, kept)| ink.casts() && kept.is_none())) { return cast_shadow(id, p, shapes); } for (((item, _, area, rule, scopes, _), ink), kept) in parts.iter().zip(inks).zip(built) { - if ink.opacity <= 0.0 { + if !ink.casts() { continue; } let faces = match kept { diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 29c05406a2..f48b94bce9 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -102,7 +102,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [ ("blendSteps", 1.0, 256.0, true), ]; -const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"]; +const FLAGS: [&str; 5] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces", "preserveSpotColors"]; /// Check Extrude & Bevel (Classic)'s parameters `p` (its defaults filled in): every key is one of /// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and @@ -127,7 +127,7 @@ const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [ /// Check Rotate (Classic)'s parameters `p` (its defaults filled in) as [`validate`] checks Extrude & /// Bevel (Classic)'s, with `surface` No Shading or Diffuse only. pub fn validate_rotate(p: &Value) -> Result<(), String> { - validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind"])?; + validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind", "preserveSpotColors"])?; if p.get("surface").is_some_and(|v| !v.as_str().is_some_and(|s| ROTATE_SURFACES.contains(&s))) { return Err(format!("`surface` must be one of {}", ROTATE_SURFACES.join(", "))); } @@ -192,6 +192,35 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color { Color::rgb(r, g, b) } +/// Whether Preserve Spot Colors applies with parameters `p`: `preserveSpotColors` is on and the +/// shading color is not Custom, which cannot keep a spot color. +fn preserves_spot_colors(p: &Value) -> bool { + flag(p, "preserveSpotColors", false) && text(p, "shadingColor", "black") != "custom" +} + +/// A face of `paint`, a color linked to a swatch, with Preserve Spot Colors: the swatch at a lower +/// tint for the highlight `spec`, its link kept so a spot color stays on its own plate, and, where +/// tone `tone` is below full light and the faces darken (`shade`), black over it at `1 - tone`, +/// multiplied and overprinting, so it prints on the black plate. `seam` is the width of the seam +/// stroke each layer draws around the face. +fn linked_face(paint: &Paint, tone: f64, spec: f64, shade: Option, seam: f64) -> Vec { + let Paint::Solid { color, swatch, tint } = paint else { return vec![] }; + let keep = (1.0 - spec).clamp(0.0, 1.0) as f32; + let fill = Paint::Solid { color: color.tinted(keep), swatch: swatch.clone(), tint: tint * keep }; + let mut items = vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))]; + if shade.is_some() && tone < 1.0 { + let black = Paint::solid(Color::gray((1.0 - tone).clamp(0.0, 1.0) as f32)); + let mut layer = FillLayer::new(black.clone()); + layer.blend = BlendMode::Multiply; + layer.overprint = true; + let mut edge = crate::lighting::seam_stroke(black, seam); + edge.blend = BlendMode::Multiply; + edge.overprint = true; + items.extend([AppearanceItem::Fill(layer), AppearanceItem::Stroke(edge)]); + } + items +} + /// The width of the stroke [`surface`] draws around `face` with parameters `p`: a wire edge on /// Wireframe, else the seam between faces. pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { @@ -200,12 +229,14 @@ pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { /// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child /// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by -/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by +/// its tone and highlight (with Preserve Spot Colors, a color linked to a swatch as [`linked_face`] +/// draws it). A gradient with transparent stops is shaded opaque and then masked by /// its own transparency, so the shading follows the gradient's coverage. pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { let shading = classic_shading(p); let lights = lights(p); let shade = shading_color(p); + let preserve = preserves_spot_colors(p); let wire = shading.surface == ClassicSurface::Wireframe; let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) }; let paint = &paint; @@ -229,6 +260,10 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), ] } + // Preserve Spot Colors: a color linked to a swatch stays a tint of that swatch. + Some(_) if preserve && matches!(paint, Paint::Solid { swatch: Some(_), .. }) && !paint.is_registration() => { + linked_face(paint, tone, spec, shade, seam) + } Some(c) => { let fill = Paint::solid(shaded(c, tone, spec, shade)); vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))] diff --git a/crates/effects/src/solids/shadow.rs b/crates/effects/src/solids/shadow.rs index 3906ac2d3c..81542a3722 100644 --- a/crates/effects/src/solids/shadow.rs +++ b/crates/effects/src/solids/shadow.rs @@ -50,6 +50,19 @@ pub(crate) struct Ink { pub(crate) blend: BlendMode, } +impl Ink { + /// Whether art in this ink casts part of a shadow ([`casts`]). + pub(crate) fn casts(self) -> bool { + casts(self.opacity) + } +} + +/// Whether art at `opacity` casts part of a shadow: its opacity is a finite number above zero. +/// Art at zero, negative, NaN or infinite opacity casts nothing. +pub(crate) fn casts(opacity: f32) -> bool { + opacity.is_finite() && opacity > 0.0 +} + /// The shadow of the faces in `caster` as a path of object `id`, cast by art in `ink`: filled /// black at [`SHADOW_OPACITY`] times its opacity, in its blending mode, with the nonzero rule; /// `None` when it casts nothing. diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 945eb639aa..de1ec75de3 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_doc::{Document, NodeId, StrokeLayer}; +use vectorcraft_doc::{Document, FillLayer, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, Point, Shape}; use super::*; @@ -2015,6 +2015,62 @@ fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { } } +/// The fills of each face of `art`: the first is the face's paint, a second the shadow over it. +fn face_fills(art: &Node) -> Vec> { + faces(art) + .iter() + .map(|f| { + f.appearance + .items + .iter() + .filter_map(|i| match i { + AppearanceItem::Fill(f) => Some(f.clone()), + AppearanceItem::Stroke(_) => None, + }) + .collect() + }) + .collect() +} + +#[test] +fn classic_preserve_spot_colors_keeps_a_linked_fill_on_every_face() { + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2); + let art = |params: Value| { + let mut n = classic(params); + n.appearance.items = vec![AppearanceItem::Fill(FillLayer::new(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 }))]; + solid_art(&n).unwrap() + }; + let lit = json!({"preserveSpotColors": true, "highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60}); + let (mut highlighted, mut shaded) = (false, false); + for layers in face_fills(&art(lit)) { + let Paint::Solid { color, swatch: Some(name), tint } = &layers[0].paint else { panic!("the swatch is kept: {layers:?}") }; + assert_eq!(name, "Ink"); + assert_eq!(*color, ink.tinted(*tint), "the color is the swatch at the face's tint"); + highlighted |= *tint < 1.0; + if let Some(shadow) = layers.get(1) { + let Some(Color::Gray { k }) = shadow.paint.color() else { panic!("black shading: {:?}", shadow.paint) }; + assert!(k > 0.0 && k <= 1.0, "{k}"); + assert_eq!((shadow.blend, shadow.overprint, shadow.opacity), (BlendMode::Multiply, true, 1.0)); + shaded = true; + } + assert!(layers.len() <= 2, "{layers:?}"); + } + assert!(highlighted && shaded, "the highlight lowers the tint and the shadow adds black"); + // No shading color: the highlight only, as tints of the swatch. + for layers in face_fills(&art(json!({"preserveSpotColors": true, "shadingColor": "none"}))) { + assert!(matches!(&layers[..], [f] if matches!(&f.paint, Paint::Solid { swatch: Some(_), .. })), "{layers:?}"); + } + // Off, or with a custom shading color, shaded faces are process mixes, as before. + for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#3366cc"})] { + let unlinked = face_fills(&art(params.clone())).iter().filter(|l| matches!(l[0].paint, Paint::Solid { swatch: None, .. })).count(); + assert!(unlinked > 0, "{params}"); + } + // An unlinked fill is shaded as it is with the option off. + let off: Vec<[f32; 3]> = classic_faces(json!({})).iter().map(fill_rgb).collect(); + let on: Vec<[f32; 3]> = classic_faces(json!({"preserveSpotColors": true})).iter().map(fill_rgb).collect(); + assert_eq!(off, on); +} + #[test] fn classic_parameters_are_checked() { let n = classic(json!({})); @@ -2034,6 +2090,7 @@ fn classic_parameters_are_checked() { (json!({"highlightSize": "big"}), "`highlightSize` must be a number"), (json!({"cap": "yes"}), "`cap` must be true or false"), (json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"), + (json!({"preserveSpotColors": "yes"}), "`preserveSpotColors` must be true or false"), (json!({"bevel": "fancy"}), "bevel must be none or one of"), (json!({"twist": 10}), "unknown parameter `twist`"), (json!({"lights": []}), "lights must contain one to ten lights"), @@ -2110,6 +2167,7 @@ fn classic_rotate_parameters_are_checked() { (json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"), (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"), (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"preserveSpotColors": null}), "`preserveSpotColors` must be true or false"), (json!({"highlightSize": 50}), "unknown parameter `highlightSize`"), (json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"), (json!({"shadow": true}), "unknown parameter `shadow`"), diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 4d62961612..7dfd78f28d 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -312,3 +312,63 @@ fn the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it() } } } + +/// The opacity of each part of the shadow of `n` (its 3D effect's shadow on) and the shadow's art, +/// as [`cast_shadow`] casts it from solids built for the shadow alone and as [`cast_built`] casts +/// it from the faces the solids drawn kept. +fn cast_parts(n: &Node) -> [(Vec, String); 2] { + let (_, fx) = solid(&n.appearance.effects).unwrap(); + let shapes = if is_container(n) { + members(n, "").unwrap() + } else { + let (path, rule) = profile(n).unwrap(); + vec![Profile { path, rule, items: solid_paint_sources(n).1, opacity: 1.0, scopes: vec![] }] + }; + let p = crate::merged_params(&fx.id, &fx.params); + let (_, built) = surfaces_for(None, n, fx, &shapes, true).unwrap(); + [cast_shadow(&fx.id, &p, &shapes), cast_built(&fx.id, &p, &shapes, built)].map(|c| { + let (caster, settings, cast) = c.unwrap().unwrap(); + let inks = cast.iter().map(|(_, ink, _)| ink.opacity).collect(); + (inks, format!("{:?}", shadow::scoped_art(n.id, &caster, settings, cast, has_masks(&shapes)))) + }) +} + +/// A fill, a stroke or a member whose opacity is NaN or infinite casts no part of the shadow, as one +/// at 0 % does, whether the shadow is cast from solids built for it or from the solids drawn; the +/// art beside it still casts its part. +#[test] +fn art_whose_opacity_is_not_a_finite_number_casts_no_shadow() { + // The art with opacity `opacity` in one place: a square's stroke, the fill of a group's second + // member, and a group's second member. + let objects = |opacity: f32| { + let mut fill = square_node(2, 200.0); + for item in &mut fill.appearance.items { + if let AppearanceItem::Fill(f) = item { + f.opacity = opacity; + } + } + let mut member = square_node(2, 200.0); + member.opacity = opacity; + [ + stroked_square(1, 0.0, opacity), + Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(fill)]), + Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(member)]), + ] + }; + for id in [EXTRUDE, INFLATE, ROTATE] { + for bad in [f32::NAN, f32::INFINITY] { + for (i, (mut n, mut clear)) in objects(bad).into_iter().zip(objects(0.0)).enumerate() { + n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + clear.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + let expected = cast_parts(&clear); + for (k, (inks, shadow)) in cast_parts(&n).into_iter().enumerate() { + assert_eq!(inks, vec![1.0], "{id} {bad} object {i} cast {k}: only the opaque art casts"); + assert_eq!(shadow, expected[k].1, "{id} {bad} object {i} cast {k}"); + } + let mut opacities = vec![]; + art(&n, id).unwrap().walk(&mut |c| opacities.extend(shadow_opacity(c))); + assert_eq!(opacities, vec![0.5], "{id} {bad} object {i}"); + } + } + } +} diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 410d51443f..65ac8b0f7e 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -1,22 +1,91 @@ //! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that -//! can be edited on their own. +//! can be edited on their own. Also the 3D and Materials panel's Render Settings › Wireframe, which +//! draws those edges in place of the shaded faces while the effect stays live. use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; +use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, StrokeLayer}; use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; use super::{ - CLASSIC_EXTRUDE, CLASSIC_ROTATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, - members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, + is_three_d, label_of, members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, + union_bounds, }; use crate::{GeomContext, is_geometry}; /// The most distinct edges one object's wireframe may have. pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; +/// The parameter that holds a 3D effect's render settings: an object whose `mode` is one of +/// [`RENDER_MODES`]. +pub const RENDER: &str = "render"; + +/// The render modes of a 3D effect: `shaded` draws the lit faces, `wireframe` the edges of every +/// face ([`renders_wireframe`]). +pub const RENDER_MODES: [&str; 2] = ["shaded", "wireframe"]; + +/// The 3D effects that take [`RENDER`]: the 3D and Materials panel's Extrude & Bevel, Revolve, +/// Inflate and Rotate. The 3D (Classic) effects have their own Wireframe surface. +const TAKES_RENDER: [&str; 4] = [EXTRUDE, crate::REVOLVE, INFLATE, ROTATE]; + +/// The weight of a rendered wireframe edge, in points: the weight Expand as Wireframes strokes its +/// edges with (VectorCraft's own). +pub(crate) const WIRE_WEIGHT: f64 = 0.5; + +/// Does effect `fx` draw as a wireframe: is it one of the effects that take [`RENDER`], with its +/// `render.mode` set to `wireframe`? +pub fn renders_wireframe(fx: &Effect) -> bool { + wireframe_params(&fx.id, &fx.params) +} + +/// Do 3D effect `id`'s parameters `p` set it to draw as a wireframe ([`renders_wireframe`])? A +/// command that sets them is checked against the art that mode draws: the faces' edges, with no +/// shadow, graphics or paint copies. +pub(crate) fn wireframe_params(id: &str, p: &Value) -> bool { + TAKES_RENDER.contains(&id) && p.get(RENDER).and_then(|r| r.get("mode")).and_then(Value::as_str) == Some("wireframe") +} + +/// Check the render settings in 3D effect `id`'s parameters `p`: `render`, when given to one of the +/// effects in [`TAKES_RENDER`], is an object that holds only `mode`, one of [`RENDER_MODES`] (a +/// missing `mode` is `shaded`). The 3D (Classic) effects refuse it as a parameter they do not take. +pub(crate) fn validate_render(id: &str, p: &Value) -> Result<(), String> { + let Some(render) = p.get(RENDER).filter(|_| TAKES_RENDER.contains(&id)) else { return Ok(()) }; + let valid = render + .as_object() + .is_some_and(|fields| fields.iter().all(|(key, value)| key == "mode" && value.as_str().is_some_and(|m| RENDER_MODES.contains(&m)))); + if !valid { + return Err("`render` must be an object holding only `mode`, set to shaded or wireframe".into()); + } + Ok(()) +} + +/// The art a 3D effect in wireframe mode draws for object `id` in place of its shaded faces: a group +/// of the edges of `faces`, each edge once ([`edges`]), stroked [`WIRE_WEIGHT`] black with round +/// joins and no fill, as Expand as Wireframes strokes them. Like Expand as Wireframes and the +/// Outline view, the mesh shows every edge whatever the opacity, blending mode or opacity mask of +/// the fills, strokes and group members it is built from; the object's own transparency applies to +/// the art as a whole. `None` past [`MAX_WIRE_EDGES`] edges, when the canvas draws the source art, +/// as it does for a solid past its limits. +pub(crate) fn wire_art(id: NodeId, faces: &[PathData]) -> Option> { + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), WIRE_WEIGHT); + stroke.join = LineJoin::Round; + let appearance = Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() }; + let children = edges(faces).ok()?.into_iter().map(|path| Arc::new(Node::path(id, path, appearance.clone()))).collect(); + Some(Arc::new(Node::group(id, children))) +} + +/// The art 3D effect `fx` in wireframe mode draws for object `id` from `shapes`: the edges of the +/// faces it builds ([`wire_art`]). `None` when a solid can't be built or past the edge limit, when +/// the canvas draws the source art. +pub(crate) fn wire_surfaces(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Option>> { + let faces: Vec = facet_paths(fx, shapes).ok()?.into_iter().map(|f| f.path).collect(); + Some(vec![wire_art(id, &faces)?]) +} + /// The grid points are rounded to before edges are compared, in points: two faces that share an /// edge give it once. const GRID: f64 = 1.0 / 1024.0; @@ -171,7 +240,7 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result Result, String> { +pub(crate) fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { let label = label_of(&fx.id); let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; let p = crate::merged_params(&fx.id, &fx.params); diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 735be60d6b..cb92d59e72 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -281,6 +281,210 @@ fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() { assert_eq!(both, alone); } +/// The leaves `art` draws: each path with its fill and stroke. +fn leaves(art: &Node) -> Vec<(PathData, bool, Option)> { + let mut out = vec![]; + art.walk(&mut |c| { + if let Some(p) = c.path_data() { + out.push((p.clone(), c.appearance.fill().is_some(), c.appearance.stroke().cloned())); + } + }); + out +} + +/// The art the canvas draws for `n`: Revolve's, else the other 3D effects'. +fn drawn(n: &Node) -> Node { + crate::revolve_art(n).or_else(|| super::super::solid_art(n)).unwrap_or_else(|| panic!("no art for {n:?}")) +} + +const WIRE: [&str; 4] = [super::super::EXTRUDE, crate::REVOLVE, super::super::INFLATE, ROTATE]; + +/// Render Settings › Wireframe: each of the panel's four effects draws the edges Expand as +/// Wireframes gives, each a 0.5 pt black stroke with round joins and no fill, in place of its +/// shaded faces; shaded again, it draws filled faces. +#[test] +fn wireframe_mode_draws_the_expanded_edges_as_strokes() { + for id in WIRE { + let n = solid_square(id, json!({"render": {"mode": "wireframe"}})); + let art = leaves(&drawn(&n)); + let wire = three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(art.len(), wire.len(), "{id}"); + for ((path, filled, stroke), edge) in art.iter().zip(&wire) { + assert!(!filled, "{id}: an edge has no fill"); + let stroke = stroke.as_ref().unwrap_or_else(|| panic!("{id}: an edge is stroked")); + assert_eq!((stroke.width, stroke.join, &stroke.paint), (WIRE_WEIGHT, LineJoin::Round, &Paint::solid(Color::BLACK)), "{id}"); + assert_eq!(segments(std::slice::from_ref(path)), segments(std::slice::from_ref(edge)), "{id}"); + } + let shaded = leaves(&drawn(&solid_square(id, json!({"render": {"mode": "shaded"}})))); + assert!(shaded.iter().any(|(_, filled, _)| *filled), "{id}: shaded faces are filled"); + } + // The box turned off axis shows the nine edges of its three faces. + let n = solid_square(super::super::EXTRUDE, json!({"render": {"mode": "wireframe"}})); + let paths: Vec = leaves(&drawn(&n)).into_iter().map(|(p, ..)| p).collect(); + assert_eq!(segments(&paths).len(), 9); +} + +/// The wireframe stays live: a deeper extrusion and a turned view redraw the edges. +#[test] +fn wireframe_mode_follows_the_effects_settings() { + let paths = |p: Value| -> Vec { leaves(&drawn(&solid_square(super::super::EXTRUDE, p))).into_iter().map(|(p, ..)| p).collect() }; + let flat = bounds(&paths(json!({"render": {"mode": "wireframe"}, "depth": 10.0}))); + let deep = bounds(&paths(json!({"render": {"mode": "wireframe"}, "depth": 200.0}))); + assert!(deep.width() > flat.width() + 1.0 || deep.height() > flat.height() + 1.0, "{flat:?} {deep:?}"); + let turned = paths(json!({"render": {"mode": "wireframe"}, "rotationY": 60.0})); + assert_ne!(segments(&turned), segments(&paths(json!({"render": {"mode": "wireframe"}})))); +} + +/// In wireframe mode no shadow and no graphic is drawn: only the edges. +#[test] +fn wireframe_mode_draws_no_shadow() { + for id in WIRE { + let n = solid_square(id, json!({"render": {"mode": "wireframe"}, "shadow": true})); + let art = leaves(&drawn(&n)); + assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id}"); + } +} + +/// The mode works where the effect is on a fill and on a group: the fill's solid and every member's +/// solid draw as edges, and the geometry effects after a fill's effect still apply to them. +#[test] +fn wireframe_mode_on_a_fill_and_on_a_group() { + for id in WIRE { + let mut n = solid_square(id, json!({})); + let mut fx = n.appearance.effects.remove(0); + fx.params["render"] = json!({"mode": "wireframe"}); + let fill = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))).unwrap(); + fill.effects_mut().push(fx.clone()); + fill.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 500.0})).unwrap()); + let art = leaves(&drawn(&n)); + assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id} on a fill"); + + let member = |x: f64, id: u64| { + Arc::new(Node::path(NodeId(id), square(x, 100.0, 50.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0))) + }; + let mut g = Node::group(NodeId(1), vec![member(100.0, 2), member(200.0, 3)]); + g.appearance.effects.push(fx); + let art = leaves(&drawn(&g)); + assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id} on a group"); + // Both members' edges are drawn. + let paths: Vec = art.into_iter().map(|(p, ..)| p).collect(); + assert!(bounds(&paths).width() > 100.0, "{id}"); + } +} + +/// The mesh shows every edge, as Expand as Wireframes gives them, whatever the opacity of the fill +/// and the opacity masks of the group's members; the object's own opacity stays on the art. Two +/// members that touch, one at 50% and one masked, draw their shared edge once: seven edges. +#[test] +fn the_mesh_shows_every_edge_whatever_the_members_transparency() { + let member = |x: f64, id: u64, opacity: f32| { + let mut n = Node::path(NodeId(id), square(x, 100.0, 50.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.opacity = opacity; + } + n + }; + let mut masked = member(150.0, 3, 1.0); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(member(0.0, 900, 1.0), true))); + let mut g = Node::group(NodeId(1), vec![Arc::new(member(100.0, 2, 0.5)), Arc::new(masked)]); + g.opacity = 0.75; + g.appearance.effects.push(crate::new_effect(ROTATE, &json!({"render": {"mode": "wireframe"}})).unwrap()); + let art = drawn(&g); + assert_eq!(art.opacity, 0.75); + let paths: Vec = leaves(&art).into_iter().map(|(p, ..)| p).collect(); + assert_eq!(segments(&paths).len(), 7, "{paths:?}"); + assert_eq!(segments(&paths).len(), segments(&three_d_wireframe_in(None, &g).unwrap()).len()); + let mut scoped = 0; + for c in art.children().into_iter().flatten() { + c.walk(&mut |c| scoped += usize::from(c.extras().mask.is_some() || c.opacity < 1.0)); + } + assert_eq!(scoped, 0, "no part of the mesh is masked or faded"); + // A fill at 0% still shows its mesh. + for id in WIRE { + let mut n = solid_square(id, json!({"render": {"mode": "wireframe"}})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.opacity = 0.0; + } + assert!(!leaves(&drawn(&n)).is_empty(), "{id}"); + } +} + +/// Expand Appearance of a Revolve in wireframe mode gives the stroked edges, not the shaded faces. +#[test] +fn expanding_a_revolve_in_wireframe_mode_gives_the_edges() { + let n = solid_square(crate::REVOLVE, json!({"render": {"mode": "wireframe"}})); + let art = leaves(&crate::revolve::expand_revolve_art(&n).unwrap()); + assert_eq!(art.len(), three_d_wireframe_in(None, &n).unwrap().len()); + assert!(art.iter().all(|(_, filled, stroke)| !filled && stroke.as_ref().is_some_and(|s| s.width == WIRE_WEIGHT))); +} + +/// `render` is checked before an edit: an object holding only `mode`, `shaded` or `wireframe`, and +/// only on the panel's four effects. +#[test] +fn render_settings_are_validated() { + for id in WIRE { + for good in [json!({}), json!({"render": {}}), json!({"render": {"mode": "shaded"}}), json!({"render": {"mode": "wireframe"}})] { + assert_eq!(crate::validate_three_d_params(id, &good), Ok(()), "{id} {good}"); + } + for bad in [ + json!({"render": "wireframe"}), + json!({"render": null}), + json!({"render": {"mode": "raytrace"}}), + json!({"render": {"mode": true}}), + json!({"render": {"mode": "wireframe", "quality": "high"}}), + ] { + let e = crate::validate_three_d_params(id, &bad).unwrap_err(); + assert_eq!(e, "`render` must be an object holding only `mode`, set to shaded or wireframe", "{id} {bad}"); + } + // The command check on the object refuses it too. + let n = solid_square(id, json!({})); + assert!(crate::validate_three_d(&n, id, &json!({"render": {"mode": "x"}})).is_err(), "{id}"); + } + for id in [super::super::CLASSIC_EXTRUDE, crate::CLASSIC_REVOLVE, super::super::CLASSIC_ROTATE] { + let e = crate::validate_three_d_params(id, &json!({"render": {"mode": "wireframe"}})).unwrap_err(); + assert!(e.contains("`render`"), "{id}: {e}"); + } +} + +/// A Revolve whose shaded surface is past the budget of paint copies (a gradient with many stops) is +/// refused shaded, but in wireframe mode, which draws only the black edges, it is accepted and drawn: +/// on the object and on a fill. +#[test] +fn wireframe_mode_is_checked_against_the_edges_it_draws() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let n = solid_square(crate::REVOLVE, json!({})); + let path = n.path_data().unwrap().clone(); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), crate::revolve::revolve_options(&json!({}))).unwrap().len(); + let stops = crate::revolve::MAX_PAINT_COPIES / (faces * 4) / std::mem::size_of::() + 1; + let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect(); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut n = n; + n.appearance = Appearance::basic(gradient, Paint::None, 0.0).into(); + let shaded = json!({}); + let wire = json!({"render": {"mode": "wireframe"}}); + let refused = crate::validate_revolve(&n, &shaded).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + assert_eq!(crate::validate_revolve(&n, &wire), Ok(())); + n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &wire).unwrap()); + assert!(leaves(&drawn(&n)).iter().all(|(_, filled, stroke)| !filled && stroke.is_some())); + // On the fill. + let fx = n.appearance.effects.remove(0); + let at = n.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Fill(_))).unwrap(); + assert!(crate::revolve::validate_item_revolve(&n, &shaded, at, 0).is_err()); + assert_eq!(crate::revolve::validate_item_revolve(&n, &wire, at, 0), Ok(())); + n.appearance.items[at].effects_mut().push(fx); + assert!(leaves(&drawn(&n)).iter().all(|(_, filled, stroke)| !filled && stroke.is_some())); +} + +/// A 3D (Classic) effect ignores a `render` it does not take in a loaded file: it draws as before. +#[test] +fn a_classic_effect_is_not_drawn_as_a_render_wireframe() { + let mut n = solid_square(super::super::CLASSIC_EXTRUDE, json!({})); + n.appearance.effects[0].params["render"] = json!({"mode": "wireframe"}); + assert!(!renders_wireframe(&n.appearance.effects[0])); + assert!(leaves(&drawn(&n)).iter().any(|(_, filled, _)| *filled)); +} + #[test] fn an_object_without_a_visible_3d_effect_is_an_error() { let mut n = solid_square(super::super::EXTRUDE, json!({})); diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 0c4bff0f1d..67826614e1 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -1,7 +1,8 @@ //! Effect › SVG Filters (`svg.filter`): an SVG `` element applied as a live raster effect. //! The filter runs on the object's pixels as SVG 1.1 describes it //! (), drawn by resvg. Its lengths are in points times -//! the effect's `scale`, its user space starts at the effect's `origin`, its object bounding box is the box of the drawn pixels, and it runs at the +//! the effect's `scale`, its user space starts at the effect's `origin`, its axes point as the effect's `axes` say (a turned or +//! skewed user space), its object bounding box is the box of the drawn pixels, and it runs at the //! Document Raster Effects resolution when the view shows the object at a finer one. use std::sync::Arc; @@ -1224,13 +1225,16 @@ fn region_length(v: Option<&str>, default: f64, user: bool) -> Length { } /// An SVG filter effect: the filter's XML, how many points one of its units is, where its user -/// space's origin lies in the document, and its region. +/// space's origin lies in the document, which way its axes point, and its region. #[derive(Clone, Debug, PartialEq)] pub struct SvgFilterFx { /// The filter as it is drawn and written ([`rebuilt`]). xml: Arc, scale: f64, origin: Vec2, + /// The directions of the user space's axes in the document, before `scale` ([`axes_param`]): + /// the identity unless the user space is turned, skewed or scaled unevenly. + axes: Affine, /// Its region (filter effects region): x, y, width, height. region: [Length; 4], /// Is the region in user space with a length measured against the object's box? It is then @@ -1250,11 +1254,92 @@ fn origin_param(p: &Value) -> Vec2 { Vec2::new(at(0), at(1)) } +/// The largest magnitude of an entry of an SVG filter effect's `axes`. +pub const MAX_AXIS: f64 = 1000.0; + +/// The smallest magnitude of the determinant of an SVG filter effect's `axes`. +pub const MIN_AXES_DET: f64 = 0.001; + +/// The `axes` of an SVG filter effect's parameters `p`: `[a, b, c, d]`, the document directions +/// of one unit along the filter's x axis (`[a, b]`) and its y axis (`[c, d]`), before `scale`. +/// The identity when absent; `None` when it isn't four numbers from -[`MAX_AXIS`] to [`MAX_AXIS`] +/// whose determinant is at least [`MIN_AXES_DET`] across ([`valid_axes`]). +fn axes_param(p: &Value) -> Option { + match p.get("axes") { + None | Some(Value::Null) => Some(Affine::IDENTITY), + Some(v) => valid_axes(v), + } +} + +/// `v` as the linear map of an SVG filter effect's `axes` ([`axes_param`]), when it is one. +pub fn valid_axes(v: &Value) -> Option { + let n: Vec = v.as_array()?.iter().map(Value::as_f64).collect::>()?; + axes_of(<[f64; 4]>::try_from(n).ok()?) +} + +/// `[a, b, c, d]` as the linear map of an SVG filter effect's `axes`, when the effect takes it ([`valid_axes`]). +fn axes_of([a, b, c, d]: [f64; 4]) -> Option { + let det = a * d - b * c; + ([a, b, c, d].iter().all(|v| v.is_finite() && v.abs() <= MAX_AXIS) && det.abs() >= MIN_AXES_DET).then(|| Affine::new([a, b, c, d, 0.0, 0.0])) +} + +/// A filter's user space as the SVG filter effect stores it ([`user_space`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct UserSpace { + /// The effect's `scale`. + pub scale: f64, + /// The effect's `origin`. + pub origin: [f64; 2], + /// The effect's `axes`; `None` when the axes are the document's. + pub axes: Option<[f64; 4]>, +} + +/// `ts`, a user space mapped to the document, as the SVG filter effect's `scale` (the square root +/// of its area scale), `origin` and, when it turns, skews or scales unevenly, `axes`. `None` when +/// it can't be stored: not finite, `scale` outside 0.001..1000, or `axes` the effect refuses +/// ([`valid_axes`]). +pub fn user_space(ts: Affine) -> Option { + let [a, b, c, d, e, f] = ts.as_coeffs(); + if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { + return None; + } + let scale = ts.determinant().abs().sqrt(); + if !(0.001..=1000.0).contains(&scale) { + return None; + } + let even = b == 0.0 && c == 0.0 && a > 0.0 && (a - d).abs() <= 1e-9 * a.max(1.0); + if even { + return Some(UserSpace { scale: a, origin: [e, f], axes: None }); + } + let axes = [a, b, c, d].map(|v| v / scale); + axes_of(axes)?; + Some(UserSpace { scale, origin: [e, f], axes: Some(axes) }) +} + +/// The longest a unit vector becomes under the linear part of `a` (its largest singular value). +fn stretch(a: Affine) -> f64 { + let [a, b, c, d, _, _] = a.as_coeffs(); + let sum = a * a + b * b + c * c + d * d; + let det = a * d - b * c; + (0.5 * (sum + (sum * sum - 4.0 * det * det).max(0.0).sqrt())).sqrt() +} + +/// The share of a raster pixel, `space.px` points wide, that a pixel of the Document Raster Effects +/// resolution is (at most 1). +fn raster_resolution(space: &PixelSpace) -> f64 { + if space.line.is_finite() && space.line > 0.0 && space.px.is_finite() && space.px > 0.0 { (space.px / space.line).min(1.0) } else { 1.0 } +} + +/// How many times the raster's pixels a turned filter's canvas may hold ([`SvgFilterFx::turned_canvas`]). +const TURNED_ROOM: f64 = 4.0; + impl SvgFilterFx { - /// The effect's parameters `p` (defaults merged in): `xml`, the `` element, `scale` - /// and `origin`. `None` when `xml` isn't a filter [`check`] takes, however the effect came to - /// hold it (an opened file, a script): the object is then drawn, printed and exported without it. + /// The effect's parameters `p` (defaults merged in): `xml`, the `` element, `scale`, + /// `origin` and `axes`. `None` when `xml` isn't a filter [`check`] takes, or `axes` isn't + /// valid ([`axes_param`]), however the effect came to hold it (an opened file, a script): the + /// object is then drawn, printed and exported without it. pub(crate) fn parse(p: &Value) -> Option { + let axes = axes_param(p)?; let xml = checked(text(p, "xml", ""))?; let (wrapped, _) = wrap(&xml); let doc = roxmltree::Document::parse(&wrapped).ok()?; @@ -1273,7 +1358,17 @@ impl SvgFilterFx { let (window, blur) = (largest(&["radius", "kernelUnitLength"]), largest(&["stdDeviation"])); let box_units = f.attribute("primitiveUnits").is_some_and(|u| u.trim() == "objectBoundingBox"); let scale = num(p, "scale", 1.0).clamp(1e-3, 1e3); - Some(Self { xml, scale, origin: origin_param(p), region, measured, window, blur, box_units }) + Some(Self { xml, scale, origin: origin_param(p), axes, region, measured, window, blur, box_units }) + } + + /// The filter's user space → the document: moved to `origin`, scaled by `scale`, along `axes`. + pub fn to_doc(&self) -> Affine { + Affine::translate(self.origin) * Affine::scale(self.scale) * self.axes + } + + /// The box of `bounds` (document units) in the filter's user space. + fn user_box(&self, bounds: Rect) -> Rect { + self.to_doc().inverse().transform_rect_bbox(bounds) } /// The filter's region in its user space for an object whose box there is `b`: lengths in user @@ -1308,11 +1403,11 @@ impl SvgFilterFx { with_attributes(xml, &[("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())]) } - /// The filter as SVG output writes it (one unit a point, [`Self::in_points`]) for an object - /// whose box in the document is `bounds`: in its SVG 1.1 form ([`svg11`]), `None` when SVG 1.1 - /// can't hold it ([`Self::writes_as_svg11`]). - pub fn written_xml(&self, bounds: Rect) -> Option { - let out = self.with_region(&self.svg11_form()?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))?; + /// The filter as SVG output writes it, in its user space ([`Self::to_doc`]), for an object + /// whose box in that user space is `user_box`: in its SVG 1.1 form ([`svg11`]), `None` when SVG + /// 1.1 can't hold it ([`Self::writes_as_svg11`]). + pub fn written_xml(&self, user_box: Rect) -> Option { + let out = self.with_region(&self.svg11_form()?, user_box)?; (written_len(&out) <= MAX_WRITTEN_XML).then_some(out) } @@ -1345,10 +1440,11 @@ impl SvgFilterFx { if !(space.px.is_finite() && space.px > 0.0) { return 1.0; } - let mut s: f64 = if space.line.is_finite() && space.line > 0.0 { (space.px / space.line).min(1.0) } else { 1.0 }; - // A length spans length × scale / px raster pixels, or a fraction of the box. + let mut s = raster_resolution(space); + // A length spans at most length × scale × the axes' stretch / px raster pixels, or a fraction of the box. + let reach = self.scale * stretch(self.axes); for (length, most) in [(self.window, MAX_WINDOW_PX), (self.blur, MAX_BLUR_PX)] { - let px = if self.box_units { length * box_px.max(0.0) } else { length * self.scale / space.px }; + let px = if self.box_units { length * box_px.max(0.0) } else { length * reach / space.px }; if px * s > most { s = most / px; } @@ -1374,15 +1470,21 @@ impl SvgFilterFx { self.measured } - /// Does one unit of the filter's user space measure one point (its `scale` is 1)? - pub fn in_points(&self) -> bool { - self.scale == 1.0 + /// Is the filter's user space the document's, only moved: one unit a point, its axes the + /// document's (`scale` 1, no `axes`)? + pub fn moves_only(&self) -> bool { + self.scale == 1.0 && self.axes == Affine::IDENTITY + } + + /// Can SVG import store the filter's user space ([`Self::to_doc`]) again ([`user_space`])? Its + /// `scale` and `axes` together can scale it past what one of them holds alone. + pub fn opens_again(&self) -> bool { + user_space(self.to_doc()).is_some() } /// How far beyond `bounds` (document units) the filter can draw: its region's reach past them. pub fn outset(&self, bounds: Rect) -> f64 { - let b = (bounds - self.origin).scale_from_origin(1.0 / self.scale); - let region = self.region_in(b).scale_from_origin(self.scale) + self.origin; + let region = self.to_doc().transform_rect_bbox(self.region_in(self.user_box(bounds))); let reach = [bounds.x0 - region.x0, bounds.y0 - region.y0, region.x1 - bounds.x1, region.y1 - bounds.y1].into_iter().fold(0.0, f64::max); if reach.is_finite() { reach.min(MAX_OUTSET) } else { 0.0 } } @@ -1397,20 +1499,40 @@ impl SvgFilterFx { } // Art that draws no pixels still has a box: the whole raster stands for it. let (x0, y0, x1, y1) = drawn_box(data, w, h).unwrap_or((0, 0, w, h)); - let s = self.resolution(space, (x1 - x0).max(y1 - y0) as f64); - let (sw, sh) = ((w as f64 * s).ceil().max(1.0) as u32, (h as f64 * s).ceil().max(1.0) as u32); - let Some(png) = png_of(data, w, x0, y0, x1, y1).map(Arc::new) else { return }; - let k = self.scale; - // Filter user space → the small canvas, and the image's pixels → filter user space. - let outer = Affine::scale(s) * space.to_doc.inverse() * Affine::translate(self.origin) * Affine::scale(k); - let place = Affine::scale(1.0 / k) * Affine::translate(-self.origin) * space.to_doc * Affine::translate(Vec2::new(x0 as f64, y0 as f64)); - let b = place.transform_rect_bbox(Rect::new(0.0, 0.0, (x1 - x0) as f64, (y1 - y0) as f64)); + let user = self.to_doc(); + // Filter user space → the small canvas, and the small canvas → the raster's pixels. + let (sw, sh, outer, back) = if self.axes == Affine::IDENTITY { + let s = self.resolution(space, (x1 - x0).max(y1 - y0) as f64); + let (sw, sh) = ((w as f64 * s).ceil().max(1.0) as u32, (h as f64 * s).ceil().max(1.0) as u32); + (sw, sh, Affine::scale(s) * space.to_doc.inverse() * user, Affine::scale(1.0 / s)) + } else { + // The canvas measures the costly lengths in its own pixels. + let drawn = Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64); + let Some(canvas) = self.turned_canvas(w, h, raster_resolution(space), drawn, space) else { return }; + canvas + }; + // The source as an image, its size, and the map from its pixels to filter user space. The + // object's box in user space is the image's box there, which resvg measures the region and + // a primitive's lengths in the object's box against. In a turned or skewed user space the + // source is first drawn onto the canvas's pixels, whose rows and columns run along the user + // space's axes, and cut to the pixels it draws there: the image then lies along those axes, + // and its box is the box of the art in the user space, not of the raster's box turned into it. + let source = if self.axes == Affine::IDENTITY { + let place = user.inverse() * space.to_doc * Affine::translate(Vec2::new(x0 as f64, y0 as f64)); + png_of(data, w, x0, y0, x1, y1).map(|png| (png, x1 - x0, y1 - y0, place)) + } else { + on_canvas(data, w, h, sw, sh, outer * user.inverse() * space.to_doc) + .map(|(png, cx, cy, iw, ih)| (png, iw, ih, outer.inverse() * Affine::translate(Vec2::new(cx as f64, cy as f64)))) + }; + let Some((png, iw, ih, place)) = source else { return }; + let png = Arc::new(png); + let b = place.transform_rect_bbox(Rect::new(0.0, 0.0, iw as f64, ih as f64)); let Some(drawn) = self.drawn_xml(b).and_then(|xml| with_id(&xml, DRAWN_ID)) else { return }; let svg = format!( "{drawn}", matrix(outer), - x1 - x0, - y1 - y0, + iw, + ih, matrix(place), base64(&png) ); @@ -1420,19 +1542,65 @@ impl SvgFilterFx { } let Some(mut small) = tiny_skia::Pixmap::new(sw, sh) else { return }; resvg::render(&tree, tiny_skia::Transform::identity(), &mut small.as_mut()); - let out = if s < 1.0 { + let out = if back == Affine::IDENTITY { + small + } else { let Some(mut full) = tiny_skia::Pixmap::new(w as u32, h as u32) else { return }; let paint = tiny_skia::PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() }; - let up = (1.0 / s) as f32; - full.draw_pixmap(0, 0, small.as_ref(), &paint, tiny_skia::Transform::from_scale(up, up), None); + let [a, b, c, d, e, f] = back.as_coeffs().map(|v| v as f32); + full.draw_pixmap(0, 0, small.as_ref(), &paint, tiny_skia::Transform::from_row(a, b, c, d, e, f), None); full - } else { - small }; if out.data().len() == data.len() { data.copy_from_slice(out.data()); } } + + /// The canvas a filter whose user space is turned, skewed or scaled unevenly runs on, for a + /// `w` × `h` raster lying in the document as `space` says, at resolution `s` + /// ([`raster_resolution`]), whose art draws within `drawn` (raster pixels): its axes are the user + /// space's, so the filter's offsets, blurs and other lengths run along them as SVG 1.1 measures + /// them, and it covers what the raster shows. Along each of its axes its pixels are about as + /// large as the raster's at `s`, and coarser when it would hold more than [`TURNED_ROOM`] times + /// as many pixels as the raster does at `s`, and when a morphology radius or kernel unit would + /// span more than 32 of its pixels or a blur's standard deviation more than 512 (a length in the + /// object's box measured against the art's box on the canvas). Returns its size, the map from + /// user space to it, and the map from it to the raster's pixels; `None` when the spaces can't + /// be measured. + fn turned_canvas(&self, w: usize, h: usize, s: f64, drawn: Rect, space: &PixelSpace) -> Option<(u32, u32, Affine, Affine)> { + let to_px = space.to_doc.inverse() * self.to_doc(); + let from_px = to_px.inverse(); + let ub = from_px.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); + let most = ((w as f64 * s).ceil().max(1.0) * (h as f64 * s).ceil().max(1.0) * TURNED_ROOM).max(1.0); + // Canvas pixels a unit along each axis of the user space: as many as the raster's at `s`. + let [a, b, c, d, _, _] = to_px.as_coeffs(); + let (mut qx, mut qy) = (s * a.hypot(b), s * c.hypot(d)); + let pixels = ub.width() * qx * ub.height() * qy; + if pixels > most { + let k = (most / pixels).sqrt(); + (qx, qy) = (qx * k, qy * k); + } + // A length along an axis spans length × q canvas pixels, or length × the art's box on the + // canvas along that axis: both shrink with q. + let art = from_px.transform_rect_bbox(drawn); + for (length, cap) in [(self.window, MAX_WINDOW_PX), (self.blur, MAX_BLUR_PX)] { + let px = if self.box_units { length * (art.width() * qx).max(art.height() * qy) } else { length * qx.max(qy) }; + if px > cap { + let k = cap / px; + (qx, qy) = (qx * k, qy * k); + } + } + if !(qx.is_finite() && qx > 0.0 && qy.is_finite() && qy > 0.0 && ub.is_finite()) { + return None; + } + let (cw, ch) = ((ub.width() * qx).ceil().max(1.0), (ub.height() * qy).ceil().max(1.0)); + // Rounding up adds at most a row and a column. + if cw * ch > most + cw + ch + 1.0 { + return None; + } + let outer = Affine::scale_non_uniform(qx, qy) * Affine::translate(Vec2::new(-ub.x0, -ub.y0)); + Some((cw as u32, ch as u32, outer, to_px * outer.inverse())) + } } /// The farthest a filter's region reaches beyond the object, in points. @@ -1450,6 +1618,22 @@ fn drawn_box(data: &[u8], w: usize, h: usize) -> Option<(usize, usize, usize, us (x1 > x0 && y1 > y0).then_some((x0, y0, x1, y1)) } +/// The pixels of `data` (premultiplied RGBA8, `w` × `h`) drawn onto a `cw` × `ch` canvas through +/// `to_canvas` (the raster's pixels → the canvas's), cut to the box of the canvas pixels that aren't +/// transparent (the whole canvas when all are): a PNG of them, the box's corner on the canvas, and +/// its size. +fn on_canvas(data: &[u8], w: usize, h: usize, cw: u32, ch: u32, to_canvas: Affine) -> Option<(Vec, usize, usize, usize, usize)> { + let size = tiny_skia::IntSize::from_wh(u32::try_from(w).ok()?, u32::try_from(h).ok()?)?; + let raster = tiny_skia::Pixmap::from_vec(data.to_vec(), size)?; + let mut canvas = tiny_skia::Pixmap::new(cw, ch)?; + let paint = tiny_skia::PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() }; + let [a, b, c, d, e, f] = to_canvas.as_coeffs().map(|v| v as f32); + canvas.draw_pixmap(0, 0, raster.as_ref(), &paint, tiny_skia::Transform::from_row(a, b, c, d, e, f), None); + let (cw, ch) = (cw as usize, ch as usize); + let (x0, y0, x1, y1) = drawn_box(canvas.data(), cw, ch).unwrap_or((0, 0, cw, ch)); + Some((png_of(canvas.data(), cw, x0, y0, x1, y1)?, x0, y0, x1 - x0, y1 - y0)) +} + /// The pixels of `data` (premultiplied RGBA8, `w` wide) in columns x0..x1 and rows y0..y1, as a PNG. fn png_of(data: &[u8], w: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> Option> { use image::ImageEncoder; diff --git a/crates/effects/src/svg_filter/tests.rs b/crates/effects/src/svg_filter/tests.rs index f93cb53124..4ab6328359 100644 --- a/crates/effects/src/svg_filter/tests.rs +++ b/crates/effects/src/svg_filter/tests.rs @@ -222,6 +222,154 @@ fn user_space_starts_at_the_origin() { assert!((filter(json!({ "xml": half, "origin": [-10.0, 0.0] })).outset(b) - 10.0).abs() < 1e-9); } +/// The opacity of `data` (`w` wide) after the filter `xml` with `params` runs on a blue square +/// over 10..20 pt of a 40 pt raster one point a pixel, at each of `points`. +fn turned_run(xml: &str, params: serde_json::Value, points: &[(usize, usize)]) -> Vec { + let w = 40; + let mut data = image(w, w, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [0, 0, 255, 255] } else { [0; 4] }); + let mut p = params; + p["xml"] = json!(xml); + filter(p).apply(&mut data, w, w, &space(w, w, 1.0, 1.0)); + points.iter().map(|&(x, y)| at(&data, w, x, y)[3]).collect() +} + +const SHIFT_X: &str = r#""#; + +/// With `axes`, the filter runs in its turned or skewed user space: an offset along its x axis +/// moves the art along that axis in the document (SVG 1.1 measures a primitive's lengths in the +/// user space of the element the filter is on). +#[test] +fn a_turned_user_space_moves_the_art_along_its_own_axes() { + // The square spans 10..20 pt; moved 4 pt along x it covers 14..24 across. + let points = [(22, 15), (15, 22), (12, 11)]; + assert_eq!(turned_run(SHIFT_X, json!({}), &points), [255, 0, 0], "the document's axes: to the right"); + // Turned a quarter turn: the filter's x axis points down the page. + let down = turned_run(SHIFT_X, json!({ "axes": [0.0, 1.0, -1.0, 0.0] }), &points); + assert_eq!(down, [0, 255, 0], "moved down, not to the right"); + // Skewed: one unit along y is one across and one down, so dy 4 moves the art 4 pt each way. + let shift_y = r#""#; + let skewed = turned_run(shift_y, json!({ "axes": [1.0, 0.0, 1.0, 1.0] }), &[(22, 22), (11, 22), (11, 11)]); + assert!(skewed[0] > 200 && skewed[1] < 60 && skewed[2] < 60, "moved across and down: {skewed:?}"); +} + +#[test] +fn axes_the_effect_cannot_measure_are_not_drawn() { + for axes in [json!([0, 0, 0, 0]), json!([1, 2]), json!([1, 0, 0, "1"]), json!([2000, 0, 0, 1]), json!([0.01, 0, 0, 0.01]), json!("turned")] { + let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": SHIFT_X, "axes": axes }), visible: true }; + assert!(raster_effects(&[e]).is_empty(), "{axes}: drawn without it"); + assert!(valid_axes(&axes).is_none(), "{axes}"); + } + assert_eq!(valid_axes(&json!([0, 1, -1, 0])), Some(Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]))); +} + +/// A region measured against the object's box is measured against its box in the turned user +/// space: a diamond turned an eighth of a turn is a square there, and a flood over 0..100% of it +/// covers the diamond alone. +#[test] +fn a_turned_region_is_measured_against_the_box_in_its_user_space() { + let w = 40; + let diamond = |x: usize, y: usize| (x as f64 - 19.5).abs() + (y as f64 - 19.5).abs() <= 10.0; + let mut data = image(w, w, |x, y| if diamond(x, y) { [0, 0, 255, 255] } else { [0; 4] }); + let flood = r##""##; + let h = std::f64::consts::FRAC_1_SQRT_2; + filter(json!({ "xml": flood, "axes": [h, h, -h, h] })).apply(&mut data, w, w, &space(w, w, 1.0, 1.0)); + assert_eq!(at(&data, w, 20, 20), [255, 0, 0, 255], "inside the diamond"); + assert!(at(&data, w, 12, 12)[3] < 40, "outside the diamond, inside its box on the page: {:?}", at(&data, w, 12, 12)); +} + +/// In a turned user space, a region in the object's bounding box (the default `filterUnits`) and a +/// primitive's lengths in it (`primitiveUnits="objectBoundingBox"`) are measured against the +/// object's box there: on a diamond turned an eighth of a turn, a flood over 0..100% of the box +/// covers the diamond alone. +#[test] +fn a_turned_filter_measures_box_units_against_the_box_in_its_user_space() { + let w = 40; + let h = std::f64::consts::FRAC_1_SQRT_2; + let region = r##""##; + let primitive = r##""##; + for xml in [region, primitive] { + let diamond = |x: usize, y: usize| (x as f64 - 19.5).abs() + (y as f64 - 19.5).abs() <= 10.0; + let mut data = image(w, w, |x, y| if diamond(x, y) { [0, 0, 255, 255] } else { [0; 4] }); + filter(json!({ "xml": xml, "axes": [h, h, -h, h] })).apply(&mut data, w, w, &space(w, w, 1.0, 1.0)); + assert_eq!(at(&data, w, 20, 20), [255, 0, 0, 255], "{xml}: inside the diamond"); + assert!(at(&data, w, 12, 12)[3] < 40, "{xml}: outside the diamond, inside its box on the page: {:?}", at(&data, w, 12, 12)); + } +} + +/// SVG import stores a filter's user space again only when `scale` and `axes` together stay within +/// what it holds: each alone can be valid while their product is not. +#[test] +fn a_user_space_opens_again_only_within_what_import_stores() { + let opens = |scale: f64, axes: serde_json::Value| filter(json!({ "xml": SHIFT_X, "scale": scale, "axes": axes })).opens_again(); + assert!(opens(1000.0, json!(null))); + assert!(opens(2.0, json!([0.0, 1.0, -1.0, 0.0]))); + assert!(!opens(1000.0, json!([2.0, 0.0, 0.0, 2.0])), "scale 2000 across"); + assert!(!opens(0.001, json!([0.5, 0.0, 0.0, 0.5])), "scale 0.0005 across"); + let space = user_space(Affine::new([0.0, 2.0, -2.0, 0.0, 5.0, 6.0])).unwrap(); + assert_eq!((space.scale, space.origin, space.axes), (2.0, [5.0, 6.0], Some([0.0, 1.0, -1.0, 0.0]))); +} + +/// The region of a turned filter reaches as far as its turned box does. +#[test] +fn a_turned_region_reaches_along_the_turned_axes() { + let b = Rect::new(0.0, 0.0, 50.0, 50.0); + let user = r#""#; + assert!((filter(json!({ "xml": user })).outset(b) - 45.0).abs() < 1e-9, "45 pt below the box"); + // A quarter turn: user y runs to the left of the page, so -5..95 of it lies 95 pt left of the box. + assert!((filter(json!({ "xml": user, "axes": [0.0, 1.0, -1.0, 0.0] })).outset(b) - 95.0).abs() < 1e-9); +} + +/// A filter skewed as far as `axes` allows runs on a canvas no larger than four times the raster's +/// pixels, however far the skew spreads the raster's box in its user space. +#[test] +fn a_skewed_filter_runs_within_its_pixel_budget() { + let (w, h) = (64, 48); + let f = filter(json!({ "xml": SHIFT_X, "axes": [1.0, 0.0, 999.0, 1.0] })); + let all = Rect::new(0.0, 0.0, w as f64, h as f64); + let (cw, ch, ..) = f.turned_canvas(w, h, 1.0, all, &space(w, h, 1.0, 1.0)).unwrap(); + let (cw, ch) = (f64::from(cw), f64::from(ch)); + assert!(cw * ch <= TURNED_ROOM * (w * h) as f64 + cw + ch + 1.0, "{cw} × {ch}"); + // It still draws. + let mut data = image(w, h, |x, y| if (20..40).contains(&x) && (10..30).contains(&y) { [0, 0, 255, 255] } else { [0; 4] }); + f.apply(&mut data, w, h, &space(w, h, 1.0, 1.0)); + // A plain quarter turn keeps the raster's own pixel size. + let turned = filter(json!({ "xml": SHIFT_X, "axes": [0.0, 1.0, -1.0, 0.0] })); + let (cw, ch, outer, _) = turned.turned_canvas(w, h, 1.0, all, &space(w, h, 1.0, 1.0)).unwrap(); + assert_eq!((cw, ch), (48, 64)); + assert!((outer.determinant() - 1.0).abs() < 1e-9); + // Stretched 100 times across and squeezed 100 times down: each canvas axis keeps the raster's + // detail along it, not one column of it. + let stretched = filter(json!({ "xml": SHIFT_X, "axes": [100.0, 0.0, 0.0, 0.01] })); + let (cw, ch, ..) = stretched.turned_canvas(100, 100, 1.0, Rect::new(0.0, 0.0, 100.0, 100.0), &space(100, 100, 1.0, 1.0)).unwrap(); + assert_eq!((cw, ch), (100, 100)); +} + +/// On a far skewed canvas, a radius or a standard deviation in the object's box spans no more +/// canvas pixels than the caps allow: it is measured against the art's box on the canvas, which the +/// skew spreads far wider than the art's box in the raster. +#[test] +fn a_skewed_filter_keeps_box_lengths_within_the_caps_on_its_canvas() { + let (w, h) = (64, 48); + let sp = space(w, h, 1.0, 1.0); + let drawn = Rect::new(20.0, 10.0, 40.0, 30.0); + for (xml, length, cap) in [ + ("", 1.5, MAX_WINDOW_PX), + ("", 20.0, MAX_BLUR_PX), + ] { + let f = filter(json!({ "xml": xml, "axes": [1.0, 0.0, 999.0, 1.0] })); + let (_, _, outer, _) = f.turned_canvas(w, h, 1.0, drawn, &sp).unwrap(); + // The art's box on the canvas: the drawn pixels taken into user space, then onto the canvas. + let art = (outer * f.to_doc().inverse() * sp.to_doc).transform_rect_bbox(drawn); + let px = length * art.width().max(art.height()); + assert!(px <= cap + 1e-6, "{xml}: {px} canvas pixels"); + } + // A user-space radius spans no more than 32 canvas pixels along either axis. + let f = filter(json!({ "xml": "", "scale": 1000.0, "axes": [1.0, 0.0, 999.0, 1.0] })); + let (_, _, outer, _) = f.turned_canvas(w, h, 1.0, drawn, &sp).unwrap(); + let [qx, _, _, qy, _, _] = outer.as_coeffs(); + assert!(100.0 * qx.max(qy) <= MAX_WINDOW_PX + 1e-6, "{qx} {qy}"); +} + #[test] fn runs_no_finer_than_the_raster_effects_resolution() { let w = 16; @@ -448,7 +596,8 @@ fn a_region_measured_against_the_box_is_written_out() { let far = r#""#; let f = filter(json!({ "xml": far, "origin": [10.0, 0.0] })); assert_eq!( - f.written_xml(Rect::new(10.0, 0.0, 30.0, 10.0)).as_deref(), + // The object over 10..30 pt of the page: 0..20 of the user space that starts at the origin. + f.written_xml(Rect::new(0.0, 0.0, 20.0, 10.0)).as_deref(), Some(r#""#) ); let plain = filter(json!({ "xml": RED_FLOOD })); diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs index bf63df61ab..57566fbb28 100644 --- a/crates/effects/src/tests/clipfx.rs +++ b/crates/effects/src/tests/clipfx.rs @@ -386,9 +386,111 @@ fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() { let baked = bake_document(&d).unwrap(); let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap(); assert!(close(exported), "exported: {exported:?}"); - // A clipping path that paints its fill keeps it, a path with the fill's effect live. + // A clipping path that paints its fill clips alike and paints the fill it clips by. let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap(); - assert!(painted.clip_paint().fill.is_some(), "{painted:?}"); + assert!(close(clip_outline(&painted).unwrap().0.bounding_box()), "painted: {painted:?}"); + let fill = painted.clip_paint().fill.unwrap(); + assert!(close(fill.geometric_bounds().unwrap()), "painted fill: {fill:?}"); +} + +#[test] +fn a_painted_clipping_paths_fill_and_stroke_effects_apply_before_the_effects_around_it() { + // A clipping path with a black fill grown by Offset Path 10 and a 2 pt blue stroke moved 30 pt + // right by a Transform, in a group that its Transform scales to 50 %: the fill paints the grown + // square at half size, 60 pt across, and the stroke the square at half size, 15 pt right of + // it, on the canvas and in an export alike. + let mut clip = clipping_path(3, vec![]); + clip.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0).into(); + for item in &mut clip.appearance.items { + match item { + vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("path.offsetPath", json!({"offset": 10}))), + vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveH": 30}))), + } + } + let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip.clone()))]); + outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))); + fn first_clip(n: &Node) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(|c| first_clip(c)), + } + } + let check = |what: &str, c: &Node| { + let paint = c.clip_paint(); + let fill = paint.fill.as_ref().and_then(Node::geometric_bounds).unwrap(); + assert!((fill.width() - 60.0).abs() < 0.5 && (fill.height() - 60.0).abs() < 0.5, "{what} fill: {fill:?}"); + let stroke = paint.stroke.as_ref().and_then(Node::geometric_bounds).unwrap(); + assert!((stroke.width() - 50.0).abs() < 0.5 && (stroke.x0 - fill.x0 - 20.0).abs() < 0.5, "{what} stroke: {stroke:?} fill: {fill:?}"); + }; + let evaluated = crate::evaluate_container(None, &outer).unwrap(); + let canvas = first_clip(&evaluated).unwrap(); + check("canvas", &canvas); + // The clip group's box takes in the stroke's width, as for a path that paints its stroke. + let stroke = canvas.clip_paint().stroke.as_ref().and_then(Node::geometric_bounds).unwrap(); + let visual = evaluated.children().and_then(|ch| ch.first()).and_then(|g| g.visual_bounds()).unwrap(); + assert!(visual.x1 > stroke.x1 + 0.25, "{visual:?} {stroke:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + check("exported", &baked_clip(&d)); + // Without effects around it, the export paints the fill and the stroke as the canvas does. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(2, clip.clone())).unwrap(); + let paint = baked_clip(&d).clip_paint(); + let fill = paint.fill.as_ref().and_then(Node::geometric_bounds); + let stroke = paint.stroke.as_ref().and_then(Node::geometric_bounds); + assert_eq!(fill.map(|f| f.width()), Some(120.0), "{paint:?}"); + assert_eq!(stroke.map(|s| s.x0), Some(80.0), "{paint:?}"); + // A piece that an effect around it splits into several paths (a compound path), or puts in a + // group with its copies, still paints. + let NodeKind::Group { children, .. } = &canvas.kind else { panic!("not the pieces: {canvas:?}") }; + let mut split = canvas.as_ref().clone(); + let mut compound = children[0].as_ref().clone(); + compound.kind = NodeKind::Compound { children: vec![children[0].clone(), children[0].clone()], rule: vectorcraft_geom::FillRule::NonZero }; + let copies = Node::group(NodeId(9), vec![children[1].clone(), children[1].clone()]); + if let Some(ch) = split.children_mut() { + ch[0] = Arc::new(compound); + ch[1] = Arc::new(copies); + } + let paint = split.clip_paint(); + assert!(paint.fill.is_some() && paint.stroke.is_some(), "{split:?}"); + let stroke = paint.stroke.unwrap(); + assert_eq!(stroke.children().map(|ch| ch.len()), Some(1), "only the stroke's group: {stroke:?}"); +} + +#[test] +fn a_painted_clipping_paths_stroke_keeps_the_width_its_transform_scales_it_to() { + // A compound clipping path with a 2 pt stroke whose Transform scales strokes to 200 % without + // transforming the object: the export paints the stroke at 4 pt. + let square = Node::path(NodeId(4), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + let mut clip = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(square)], rule: vectorcraft_geom::FillRule::NonZero }); + clip.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0).into(); + let scale = json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Stroke(s) = item { + s.effects.push(fx("distort.transform", scale.clone())); + } + } + fn widths(n: &Node, out: &mut Vec) { + for item in &n.appearance.items { + if let vectorcraft_doc::AppearanceItem::Stroke(s) = item { + out.push(s.width); + } + } + n.children().into_iter().flatten().for_each(|c| widths(c, out)); + } + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(2, clip)).unwrap(); + let baked = baked_clip(&d); + let stroke = baked.clip_paint().stroke.unwrap(); + let mut found = vec![]; + widths(&stroke, &mut found); + assert!(!found.is_empty() && found.iter().all(|w| (w - 4.0).abs() < 1e-6), "{found:?} {stroke:?}"); + // The stroke is still drawn where the path lies, as the Transform does not move the object. + let bounds = stroke.geometric_bounds().unwrap(); + assert!((bounds.x0 - 50.0).abs() < 0.5 && (bounds.width() - 100.0).abs() < 0.5, "{bounds:?}"); } #[test] diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 3d3d81c172..586481d9b5 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -118,13 +118,71 @@ fn a_hidden_shadow_follows_the_transform_it_loses() { assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0))); } -/// Rasterize keeps its room around the art: its image keeps its own pixel grid. +/// Rasterize keeps its room around the art: its image keeps its own pixel grid, and the Transform +/// effect moves the image instead ([`crate::rasterize_stage`]). #[test] fn rasterize_before_a_transform_keeps_its_options() { let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6})); let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))]; assert_eq!(crate::raster::followed(&effects)[0], rasterize); assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); + assert_eq!(crate::rasterize_stage(&effects), Some(vec![1])); +} + +/// The Transform effects after the last Rasterize effect move its image when, from the first of +/// them on, every visible effect is a Transform effect that moves the art once. Hidden ones, and +/// ones with Transform Objects off, leave the image where it is. +#[test] +fn rasterize_following_ignores_hidden_and_objects_off() { + let rasterize = || fx(crate::RASTERIZE, json!({})); + let scale = || transform(json!({"scaleH": 200, "scaleV": 200})); + let mut hidden = scale(); + hidden.visible = false; + let off = transform(json!({"scaleH": 200, "objects": false})); + let roughen = fx("distort.roughen", json!({})); + let shadow = shadow(5.0, 5.0, 2.0); + let cases: Vec<(Vec, Option>)> = vec![ + (vec![rasterize(), hidden.clone()], None), + (vec![rasterize(), off.clone()], None), + (vec![rasterize(), hidden, off, scale()], Some(vec![3])), + (vec![rasterize(), transform(json!({"moveH": 10})), scale()], Some(vec![1, 2])), + // Effects before the first Transform effect go into the image; anything after it holds + // the image back. + (vec![rasterize(), roughen.clone(), shadow.clone(), scale()], Some(vec![3])), + (vec![rasterize(), scale(), roughen], None), + (vec![rasterize(), scale(), shadow], None), + (vec![rasterize(), transform(json!({"scaleH": 200, "copies": 2}))], None), + (vec![rasterize(), transform(json!({"scaleH": 200, "random": true}))], None), + // The Transform effect before the Rasterize effect moves the art. + (vec![scale(), rasterize()], None), + (vec![scale(), rasterize(), rasterize(), scale()], Some(vec![3])), + ]; + for (effects, want) in cases { + let ids: Vec<&str> = effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(crate::rasterize_stage(&effects), want, "{ids:?}"); + } + // What the image is made from hides those Transform effects; the rest stay as they are. + let effects = [rasterize(), scale()]; + let staged = crate::staged_effects(&effects, &[1]); + assert_eq!((staged[0].visible, staged[1].visible), (true, false)); +} + +/// The map that moves the image is each Transform effect's map about its reference point on the +/// bounds of the geometry it takes, in order. +#[test] +fn the_image_moves_by_each_transform_about_the_art_it_takes() { + let staged = shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)); + let effects = [fx(crate::RASTERIZE, json!({})), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"moveH": 5}))]; + let map = crate::stage_map(&effects, &[1, 2], &staged).unwrap(); + // Twice the size about the center (20, 20), then 5 pt to the right. + let want = Affine::translate((5.0, 0.0)) * Affine::translate((20.0, 20.0)) * Affine::scale(2.0) * Affine::translate((-20.0, -20.0)); + assert!(map.as_coeffs().iter().zip(want.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9), "{map:?}"); + // The second Transform effect turns about the center of what the first one made. + let effects = [fx(crate::RASTERIZE, json!({})), transform(json!({"moveH": 100})), transform(json!({"rotate": 90}))]; + let map = crate::stage_map(&effects, &[1, 2], &staged).unwrap(); + let c = map * Point::new(20.0, 20.0); + assert!((c.x - 120.0).abs() < 1e-9 && (c.y - 20.0).abs() < 1e-9, "{c:?}"); + assert_eq!(crate::stage_map(&effects, &[1, 2], &PathData::default()), None); } /// An SVG filter keeps its options: it paints in its own user space. diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 313531bb99..c6aed0a644 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -43,6 +43,7 @@ pub(crate) mod graph; mod halftone; pub mod help; pub mod importmatches; +mod info; pub(crate) mod inline; mod interactivity; mod intertwine; @@ -344,6 +345,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(docmenu::specs()); v.extend(variablecmds::specs()); v.extend(docinfo::specs()); + v.extend(info::specs()); v.extend(panelcmds::specs()); v.extend(artboardalign::specs()); v.extend(buildcmds::specs()); @@ -897,6 +899,9 @@ pub use brushsym::reset_transformation_changes; /// Data window reads files by it too). pub use variablecmds::utf16_order_without_mark; +/// The Variables panel's sort (`variable.sort`) and its rows in that order. +pub use variablecmds::{VariableSort, VariableSortKey, variable_order}; + /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; diff --git a/crates/engine/src/cmd/actionset.rs b/crates/engine/src/cmd/actionset.rs index b6a9118380..ad23db1c64 100644 --- a/crates/engine/src/cmd/actionset.rs +++ b/crates/engine/src/cmd/actionset.rs @@ -9,8 +9,9 @@ //! text. Each action is an `/action-N` block with a hexadecimal `/name`; each step is an `/event-N` //! block inside one, with its `/internalName` in parentheses, the `/localizedName` it is shown with //! as hexadecimal text, and `/isOn` 0 when it is left out of playback; each value a step was -//! recorded with is a `/parameter-N` block inside it, which is counted, and whose hexadecimal -//! `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount` +//! recorded with is a `/parameter-N` block inside it, which is counted, whose `/key` (a number), +//! `/type` (text in parentheses) and `/value` (a number, text in parentheses or hexadecimal text) are +//! kept, and whose hexadecimal `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount` //! and a step's `/parameterCount` give how many actions, steps and recorded values follow. //! The layout follows the README of Inventsable's Redesigning-Illustrator-Actions-in-Scripting //! (https://github.com/Inventsable/Redesigning-Illustrator-Actions-in-Scripting), the forum post @@ -40,6 +41,8 @@ const MAX_STEPS: usize = 10_000; /// The most steps read in the set: a small text can hold hundreds of thousands of empty steps, and /// the answer holds an object for each. const MAX_SET_STEPS: usize = 100_000; +/// The most recorded values kept in the set; the others are counted but not kept. +const MAX_VALUES: usize = 100_000; /// The longest name kept (bytes). const MAX_NAME: usize = 1024; /// The most tokens listed as unread. @@ -56,7 +59,7 @@ pub fn specs() -> Vec { "Read Action Set File", [], None, - "{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error", + "{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, values: [{key: the block's /key, or null, type: its /type, or null, value: its /value when it is a number, or text that is UTF-8 (else null), bytes?: the text's length in bytes}] (the first 100,000 recorded values in the set), line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error", always, read )] @@ -73,12 +76,33 @@ pub struct AiaStep { pub on: Option, /// How many `/parameter-N` blocks it has. pub params: usize, + /// Its `/parameter-N` blocks, in order, while the set keeps at most [`MAX_VALUES`]. + pub values: Vec, /// Its `/parameterCount`, if it has one. pub declared: Option, /// The line its block opens on (from 1). pub line: usize, } +/// A value a step was recorded with: a `/parameter-N` block. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AiaValue { + /// Its `/key`, if it has one that fits an `i64`. + pub key: Option, + /// Its `/type`, read as UTF-8 with replacement characters, if it has one. + pub kind: Option, + /// Its `/value`, if it is a number that fits an `i64` or text. + pub value: Option, +} + +/// A recorded `/value` that is kept. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AiaData { + Int(i64), + /// The bytes of text in parentheses or of hexadecimal text. + Text(Vec), +} + /// An action: an `/action-N` block. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct AiaAction { @@ -106,6 +130,8 @@ pub struct AiaSet { pub truncated: bool, /// How many steps were read in all the actions. steps: usize, + /// How many recorded values were kept in all the steps. + values: usize, } /// A token of the text. @@ -252,7 +278,8 @@ fn hex_bytes(digits: &[u8]) -> Option> { enum Kind { Action, Step, - Param, + /// A recorded value, and whether it is kept. + Param(bool), /// A block that isn't read, or one past a cap. Other, } @@ -315,12 +342,18 @@ fn open_block(set: &mut AiaSet, stack: &[Kind], name: &[u8], line: usize) -> Kin } }, Some(Kind::Step) if numbered(name, b"parameter-") => { + let mut kept = false; if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) { s.params += 1; + if set.values < MAX_VALUES { + s.values.push(AiaValue::default()); + set.values += 1; + kept = true; + } } - Kind::Param + Kind::Param(kept) } - Some(Kind::Param | Kind::Other) => Kind::Other, + Some(Kind::Param(_) | Kind::Other) => Kind::Other, _ => { unread(set, line, format!("/{} {{", String::from_utf8_lossy(name))); Kind::Other @@ -355,12 +388,23 @@ fn set_hex(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, bytes: Op } } -/// Mark `set` truncated when the hexadecimal `/value` on `line` isn't as many bytes as its count -/// says. -fn check_value(set: &mut AiaSet, bytes: Option>, line: usize) { - if bytes.is_none() { +/// Set the field `name` of the recorded value open on `line` to `token`: its `/key`, `/type` or +/// `/value`, when the value is `kept`. A hexadecimal `/value` that isn't as many bytes as its count +/// says marks `set` truncated, kept or not. +fn set_param(set: &mut AiaSet, kept: bool, name: Option<&[u8]>, token: Token, line: usize) { + if name == Some(b"value".as_slice()) && matches!(token, Token::Hex(None)) { set.truncated = true; unread(set, line, BAD_HEX); + return; + } + let value = set.actions.last_mut().and_then(|a| a.steps.last_mut()).and_then(|s| s.values.last_mut()); + let Some(v) = value.filter(|_| kept) else { return }; + match (name, token) { + (Some(b"key"), Token::Int(key)) => v.key = key, + (Some(b"type"), Token::Text(t)) => v.kind = Some(String::from_utf8_lossy(&t).into_owned()), + (Some(b"value"), Token::Int(Some(n))) => v.value = Some(AiaData::Int(n)), + (Some(b"value"), Token::Text(t) | Token::Hex(Some(t))) => v.value = Some(AiaData::Text(t)), + _ => {} } } @@ -448,13 +492,13 @@ pub fn read_aia(bytes: &[u8]) -> std::result::Result { unread(&mut set, line, "}"); } } - Token::Hex(bytes) if matches!(stack.last(), Some(Kind::Param)) => { - if field(&mut pending).as_deref() == Some(b"value".as_slice()) { - check_value(&mut set, bytes, line); - } + _ if matches!(stack.last(), Some(Kind::Param(_))) => { + let kept = matches!(stack.last(), Some(Kind::Param(true))); + let name = field(&mut pending); + set_param(&mut set, kept, name.as_deref(), token, line); } - // The other values inside a recorded value, or a block that isn't read. - _ if matches!(stack.last(), Some(Kind::Param | Kind::Other)) => { + // The values inside a block that isn't read. + _ if matches!(stack.last(), Some(Kind::Other)) => { pending = None; } Token::Hex(bytes) => { @@ -521,6 +565,21 @@ pub fn read_aia(bytes: &[u8]) -> std::result::Result { Ok(set) } +/// A recorded value in `actionSet.read`'s answer: `{key, type, value, bytes?}`, the value a number, +/// text that is UTF-8 (else null) with its length in bytes, or null. +fn value_json(v: &AiaValue) -> Value { + let mut out = json!({ "key": v.key, "type": v.kind, "value": null }); + match &v.value { + Some(AiaData::Int(n)) => out["value"] = json!(n), + Some(AiaData::Text(t)) => { + out["value"] = json!(std::str::from_utf8(t).ok()); + out["bytes"] = json!(t.len()); + } + None => {} + } + out +} + fn read(_: &mut Session, p: &Value) -> Result { const C: &str = "actionSet.read"; let (path, data, b64) = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?); @@ -544,7 +603,8 @@ fn read(_: &mut Session, p: &Value) -> Result { .iter() .map(|s| { let label = s.label.clone().filter(|l| !l.trim().is_empty()); - json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "line": s.line }) + let values: Vec = s.values.iter().map(value_json).collect(); + json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "values": values, "line": s.line }) }) .collect(); json!({ "name": a.name, "steps": steps }) diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index cba512cd18..d5f241b4ee 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -9,7 +9,7 @@ mod tests; mod text; use serde_json::{Map, Value, json}; -use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Document, LockedArtboards, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, SubPath}; use vectorcraft_pathops::addons as po; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Simplify Anchors (Keep Shape)…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay, and locked and hidden paths are left out → {ids, before, after} anchor counts", + "{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step when a path changes; open ends stay; from the selection, locked and hidden paths are left out, and named by `ids`, the paths of the objects, groups and layers it names are taken even when locked or hidden, but not those on a locked artboard; a changed path loses its selected anchors and segments → {ids, before, after} anchor counts", has_selection, simplify ), @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Deep Ungroup…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in; locked and hidden groups stay whole, and locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}", + "{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step when a group goes; each object keeps the opacity of the groups it was in; from the selection, locked and hidden groups stay whole; named by `ids`, the groups it names and the groups in the groups and layers it names are ungrouped even when locked or hidden (a named layer stays), but not those on a locked artboard, and their objects become hidden or locked when a group was; locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}", has_selection, deep_ungroup ), @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "Rejoin Path Pieces…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step → {ids, joined (paths made), removed (pieces merged into them)}", + "{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step when a path is made; from the selection, locked and hidden paths are left out, and named by `ids`, they are taken, but not those on a locked artboard; only pieces alike in being hidden (by themselves or a group or layer above them) and in being locked join; locked and hidden paths made are not selected → {ids, joined (paths made), removed (pieces merged into them)}", has_selection, rejoin ), @@ -91,9 +91,28 @@ pub fn specs() -> Vec { v } -/// The paths (not guides) under `roots`, compound path members included; locked and hidden -/// paths, and those in locked or hidden groups, stay out. -fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { +/// Whether a request names its objects with `ids` (present and not null) rather than taking the +/// selection. +fn named(p: &Value) -> bool { + p.get("ids").is_some_and(|v| !v.is_null()) +} + +/// Keep the items of `items` whose object (`id`) a command acts on: when `named`, every one not on +/// a locked artboard, locked and hidden ones (and ones in a locked or hidden group or layer) +/// included; else only those that can be edited. +fn retain_targets(d: &Document, items: &mut Vec, named: bool, id: impl Fn(&T) -> NodeId) { + if named { + let locks = d.locked_artboards(); + items.retain(|t| !locks.holds_id(id(t))); + } else { + let editable = d.editable(); + items.retain(|t| editable(id(t))); + } +} + +/// The paths (not guides) under `roots`, compound path members included, that a command acts on +/// ([`retain_targets`]). +fn paths_under(d: &Document, roots: &[NodeId], named: bool) -> Vec { let mut out = vec![]; for n in roots.iter().filter_map(|id| d.node(*id)) { n.walk(&mut |c| { @@ -102,8 +121,7 @@ fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { } }); } - let editable = d.editable(); - out.retain(|id| editable(*id)); + retain_targets(d, &mut out, named, |id| *id); out } @@ -116,7 +134,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { let protect = switch_or(p, "protectCorners", false, C)?; let roots = checked_ids_or_selection(s, p, C)?; let d = &s.doc()?.doc; - let ids = paths_under(d, &roots); + let ids = paths_under(d, &roots, named(p)); let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect(); let before: usize = paths.iter().map(|(_, p)| p.anchor_count()).sum(); if before > MAX_ANCHORS { @@ -258,10 +276,11 @@ fn ungroupable(n: &Node, clips: bool) -> bool { } /// `n` with every level of grouping Deep Ungroup takes apart removed, into `out`; `count` counts -/// the groups. Members take on the opacity of the groups they were in, and a clipping path -/// becomes a plain path. A locked or hidden group stays whole. -fn flatten(n: Node, clips: bool, out: &mut Vec, count: &mut usize) { - if !ungroupable(&n, clips) || n.locked || !n.visible { +/// the groups. Members take on the opacity of the groups they were in, are hidden when one of them +/// was hidden and locked when one of them was locked, and a clipping path becomes a plain path. A +/// locked or hidden group stays whole unless `named`. +fn flatten(n: Node, clips: bool, named: bool, out: &mut Vec, count: &mut usize) { + if !ungroupable(&n, clips) || (!named && (n.locked || !n.visible)) { out.push(n); return; } @@ -276,26 +295,57 @@ fn flatten(n: Node, clips: bool, out: &mut Vec, count: &mut usize) { *clipping = false; } c.opacity *= n.opacity; - flatten(c, clips, out, count); + c.visible &= n.visible; + c.locked |= n.locked; + flatten(c, clips, named, out, count); } } +/// The outermost groups Deep Ungroup takes apart in layer `n`, its sublayers included, in paint +/// order, leaving out those on a locked artboard. +fn groups_in_layer(d: &Document, n: &Node, clips: bool, out: &mut Vec) { + let locks = d.locked_artboards(); + fn visit(n: &Node, clips: bool, locks: &LockedArtboards, out: &mut Vec) { + for c in n.children().into_iter().flatten() { + if c.is_layer() { + visit(c, clips, locks, out); + } else if ungroupable(c, clips) && !locks.holds_id(c.id) { + out.push(c.id); + } + } + } + visit(n, clips, &locks, out); +} + fn deep_ungroup(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.cleanup.deepUngroup"; let clips = switch_or(p, "includeClipGroups", false, C)?; - let roots = checked_ids_or_selection(s, p, C)?; + let named = named(p); + let mut roots = checked_ids_or_selection(s, p, C)?; let d = &s.doc()?.doc; - // Locked and hidden objects, and those in locked or hidden groups, stay as they are. - let roots: Vec = { - let editable = d.editable(); - roots.into_iter().filter(|id| editable(*id)).collect() - }; + // From the selection, locked and hidden objects, and those in locked or hidden groups, stay as + // they are; named, only those on a locked artboard do. + retain_targets(d, &mut roots, named, |id| *id); + // A named layer stays: the groups in it, and in its sublayers, are taken apart. + let mut groups = vec![]; + for id in roots { + match d.node(id) { + Some(n) if named && n.is_layer() => { + let at = groups.len(); + groups_in_layer(d, n, clips, &mut groups); + if groups.len() == at { + groups.push(id); + } + } + _ => groups.push(id), + } + } let (mut items, mut selected, mut count) = (vec![], vec![], 0); - for id in &roots { + for id in &groups { match d.node(*id).filter(|n| ungroupable(n, clips)) { Some(n) => { let mut out = vec![]; - flatten(n.clone(), clips, &mut out, &mut count); + flatten(n.clone(), clips, named, &mut out, &mut count); selected.extend(out.iter().map(|n| n.id)); items.push((*id, out)); } @@ -304,7 +354,7 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { } // With no group to take apart, the document, the selection and the undo history stay as they // are, and the reply lists the selection. - if items.is_empty() { + if count == 0 { selected = s.doc()?.selection.objects.to_vec(); } else { selected = s.edit("Deep Ungroup", move |d, sel| { @@ -319,26 +369,39 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::>(), "ungrouped": count })) } +/// One path Rejoin Path Pieces takes: its id, its subpath, and whether it and every group and layer +/// above it are visible, and whether they are all unlocked. +type Piece = (NodeId, SubPath, (bool, bool)); + /// The open single-subpath paths under `roots` that Rejoin Path Pieces takes, in paint order -/// (compound path members, clipping paths and guides stay out). -fn pieces(d: &Document, roots: &[NodeId]) -> Vec<(NodeId, SubPath)> { - fn visit(n: &Node, out: &mut Vec<(NodeId, SubPath)>) { +/// (compound path members, clipping paths and guides stay out; [`retain_targets`]). +fn pieces(d: &Document, roots: &[NodeId], named: bool) -> Vec { + fn visit(n: &Node, above: (bool, bool), out: &mut Vec) { + let state = (above.0 && n.visible, above.1 && !n.locked); match &n.kind { NodeKind::Path { path, guide: false, clipping: false, .. } => { if let [sp] = path.subpaths.as_slice() && !sp.closed { - out.push((n.id, sp.clone())); + out.push((n.id, sp.clone(), state)); } } - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, state, out)), _ => {} } } let mut out = vec![]; - roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); - let editable = d.editable(); - out.retain(|(id, _)| editable(*id)); + for (id, n) in roots.iter().filter_map(|id| Some((*id, d.node(*id)?))) { + // The groups and layers above the root, and their state. + let above = d.ancestry(id).unwrap_or_default(); + let above = above.split_last().map_or(&[][..], |(_, up)| up); + if above.last().and_then(|i| d.node(*i)).is_some_and(|parent| matches!(parent.kind, NodeKind::Compound { .. })) { + continue; + } + let state = above.iter().filter_map(|i| d.node(*i)).fold((true, true), |(v, u), a| (v && a.visible, u && !a.locked)); + visit(n, state, &mut out); + } + retain_targets(d, &mut out, named, |piece| piece.0); out } @@ -349,18 +412,26 @@ fn rejoin(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`gap` must be from 0 to 6 pt")); } let roots = checked_ids_or_selection(s, p, C)?; - let found = pieces(&s.doc()?.doc, &roots); + let found = pieces(&s.doc()?.doc, &roots, named(p)); if found.len() > MAX_OBJECTS { return Err(EngineError::Other("Rejoin Path Pieces: too many paths to rejoin at once".into())); } - let subpaths: Vec = found.iter().map(|(_, sp)| sp.clone()).collect(); - // Each chain becomes its topmost piece (the last in paint order); the others go. + // Only pieces alike in being shown and in being unlocked join, so that a joined path neither + // shows hidden art nor hides shown art, nor locks or unlocks a piece. let (mut kept, mut gone) = (vec![], vec![]); - for j in po::rejoin(&subpaths, gap) { - let ids: Vec = j.members.iter().filter_map(|&m| found.get(m).map(|f| f.0)).collect(); - let Some(&top) = j.members.iter().max().and_then(|&m| found.get(m).map(|f| &f.0)) else { continue }; - gone.extend(ids.into_iter().filter(|id| *id != top)); - kept.push((top, j.path)); + for state in [(true, true), (true, false), (false, true), (false, false)] { + let part: Vec<&Piece> = found.iter().filter(|f| f.2 == state).collect(); + if part.is_empty() { + continue; + } + let subpaths: Vec = part.iter().map(|f| f.1.clone()).collect(); + // Each chain becomes its topmost piece (the last in paint order); the others go. + for j in po::rejoin(&subpaths, gap) { + let ids: Vec = j.members.iter().filter_map(|&m| part.get(m).map(|f| f.0)).collect(); + let Some(top) = j.members.iter().max().and_then(|&m| part.get(m).map(|f| f.0)) else { continue }; + gone.extend(ids.into_iter().filter(|id| *id != top)); + kept.push((top, j.path)); + } } if !kept.is_empty() { let ids: Vec = kept.iter().map(|k| k.0).collect(); @@ -369,7 +440,9 @@ fn rejoin(s: &mut Session, p: &Value) -> Result { set_path(d, *id, PathData::single(sp.clone())); } d.remove_many(&gone)?; - sel.set(ids); + // Locked and hidden paths, and those in locked or hidden groups, are not selected. + let editable = d.editable(); + sel.set(ids.into_iter().filter(|id| editable(*id))); Ok(()) })?; } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 4d4ed6461b..e1a50b928b 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, LayerColor, Node, NodeId, NodeKind}; use vectorcraft_geom::{FillRule, Rect}; use crate::Session; @@ -123,18 +123,100 @@ fn simplify_leaves_locked_and_hidden_paths_alone() { assert_eq!(r["before"], 16); assert!(anchors(&s, ids[0]) < 16); assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16)); - // Named by `ids`, or in a locked group, a path stays too, with no undo step. - let steps = undo_steps(&s); - s.execute("addon.cleanup.simplify", &json!({"ids": [ids[1].0, ids[2].0]})).unwrap(); - assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16)); - assert_eq!(undo_steps(&s), steps); + // In a selected locked group, a path stays too, with no undo step. change(&mut s, ids[1], |n| n.locked = false); change(&mut s, g, |n| n.locked = true); let steps = undo_steps(&s); - let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap(); + let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap(); assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(0), Some(0))); assert_eq!(anchors(&s, ids[1]), 16); assert_eq!(undo_steps(&s), steps); + // Named by `ids`, the locked group's paths, its hidden one included, are simplified. + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap(); + assert_eq!(r["ids"], json!([ids[0].0, ids[1].0, ids[2].0])); + assert!(anchors(&s, ids[1]) < 16 && anchors(&s, ids[2]) < 16); + assert_eq!(undo_steps(&s), steps + 1); +} + +/// A path of 16 anchors at (`x`, `y`): an ellipse with anchors added twice. +fn ellipse16(s: &mut Session, x: f64, y: f64) -> NodeId { + let e = id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 50, "height": 50})).unwrap()); + select(s, &[e]); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + e +} + +/// Simplify Anchors takes the locked and hidden paths `ids` names, those in the locked or hidden +/// groups and layers it names (their locked and hidden members too) and a hidden compound path's +/// member, as one undo step, without selecting them; from the selection it leaves them alone, and a +/// path on a locked artboard stays even when named. +#[test] +fn simplify_named_locked_and_hidden_paths() { + let mut s = session(); + let locked = ellipse16(&mut s, 10.0, 10.0); + let hidden = ellipse16(&mut s, 70.0, 10.0); + change(&mut s, locked, |n| n.locked = true); + change(&mut s, hidden, |n| n.visible = false); + // A locked group with a hidden member, and a hidden group with a locked member. + let in_locked = ellipse16(&mut s, 130.0, 10.0); + let locked_group = wrap(&mut s, in_locked, |c| NodeKind::Group { children: c, clip: false }); + change(&mut s, locked_group, |n| n.locked = true); + change(&mut s, in_locked, |n| n.visible = false); + let in_hidden = ellipse16(&mut s, 190.0, 10.0); + let hidden_group = wrap(&mut s, in_hidden, |c| NodeKind::Group { children: c, clip: false }); + change(&mut s, hidden_group, |n| n.visible = false); + change(&mut s, in_hidden, |n| n.locked = true); + // A member of a hidden compound path. + let member = ellipse16(&mut s, 250.0, 10.0); + let compound = wrap(&mut s, member, |c| NodeKind::Compound { children: c, rule: FillRule::NonZero }); + change(&mut s, compound, |n| n.visible = false); + // A path in a locked layer, named itself. + let layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let in_layer = ellipse16(&mut s, 10.0, 100.0); + let on_board = ellipse16(&mut s, 10.0, 200.0); + change(&mut s, layer, |n| n.locked = true); + let all = [locked, hidden, in_locked, in_hidden, member, in_layer]; + select(&mut s, &[locked, hidden, locked_group, hidden_group, compound]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([]), "from the selection, none is taken"); + assert_eq!(undo_steps(&s), steps); + assert!(all.iter().all(|id| anchors(&s, *id) == 16)); + select(&mut s, &[]); + s.edit("Select Anchors", |_, sel| { + sel.anchors.insert(locked, [(0, 1)].into()); + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0, hidden.0, locked_group.0, hidden_group.0, member.0, in_layer.0]})).unwrap(); + assert_eq!(r["ids"], json!(all.iter().map(|i| i.0).collect::>())); + assert_eq!(r["before"], 16 * 6); + assert_eq!(r["after"].as_u64(), Some(all.iter().map(|id| anchors(&s, *id) as u64).sum::())); + assert!(all.iter().all(|id| anchors(&s, *id) < 16), "{r}"); + assert_eq!(undo_steps(&s), steps + 1); + let sel = &s.doc().unwrap().selection; + assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "nothing is selected, and the changed path's anchors are not"); + let n = node(&s, in_hidden).unwrap(); + assert!(n.locked && n.visible && !node(&s, hidden_group).unwrap().visible); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(all.iter().all(|id| anchors(&s, *id) == 16)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(all.iter().all(|id| anchors(&s, *id) < 16)); + // Nothing left to remove: no undo step. + let steps = undo_steps(&s); + s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps); + // On a locked artboard, a named path stays as it is. + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [on_board.0]})).unwrap(); + assert_eq!((r["ids"].clone(), anchors(&s, on_board), undo_steps(&s)), (json!([]), 16, steps)); } /// Each kind: what makes the issue in a fresh document (beside a clean rectangle). @@ -610,14 +692,15 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() { assert!(!d.node(hidden).unwrap().visible && d.node(b).unwrap().visible); assert!(d.node(locked).unwrap().locked && d.node(e).unwrap().locked); assert_eq!(s.doc().unwrap().selection.objects, [a]); - // Named by `ids`, a locked group stays as it is, with no undo step. + // Named by `ids`, a locked group is taken apart as one undo step; its member becomes locked + // and is not selected. let steps = undo_steps(&s); let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0]})).unwrap(); - assert_eq!(r["ungrouped"], 0); - assert!(node(&s, locked).is_some()); - assert_eq!(undo_steps(&s), steps); + assert_eq!(r["ungrouped"], 1); + assert!(node(&s, locked).is_none() && node(&s, c).unwrap().locked); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(r["ids"], json!([])); // With nothing ungrouped, the selection stays and the reply lists it. - assert_eq!(r["ids"], json!([a.0])); let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0); select(&mut s, &[a]); let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [f.0]})).unwrap(); @@ -625,6 +708,108 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() { assert_eq!(s.doc().unwrap().selection.objects, [a]); } +/// Put `ids` inside a new container that `kind` makes of its members. +fn wrap_all(s: &mut Session, ids: &[NodeId], kind: fn(Vec>) -> NodeKind) -> NodeId { + s.edit("t", |d, _| { + let w = d.alloc_id(); + Ok(d.wrap(ids, |c| Node::new(w, kind(c)))?) + }) + .unwrap() +} + +fn plain_group(c: Vec>) -> NodeKind { + NodeKind::Group { children: c, clip: false } +} + +fn sublayer(c: Vec>) -> NodeKind { + NodeKind::Layer { color: LayerColor::Preset(0), template: false, printable: true, children: c, clip: false, preview: true, dim_images: None } +} + +/// Named by `ids`, Deep Ungroup takes apart locked and hidden groups, those nested in a group it +/// names and those in a layer it names (its sublayers included, the layer staying), as one undo +/// step; their members become locked or hidden as the groups were, keep the groups' opacity and are +/// not selected. From the selection those groups stay whole, as do a clipping group and a group on a +/// locked artboard even when named. +#[test] +fn deep_ungroup_named_locked_and_hidden_groups() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let locked = wrap_all(&mut s, &[a], plain_group); + let hidden = wrap_all(&mut s, &[b], plain_group); + change(&mut s, locked, |n| n.locked = true); + change(&mut s, hidden, |n| { + n.visible = false; + n.opacity = 0.5; + }); + select(&mut s, &[locked, hidden]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap(); + assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, they stay whole"); + assert!(node(&s, locked).is_some() && node(&s, hidden).is_some()); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0, hidden.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(2), json!([]))); + assert_eq!(undo_steps(&s), steps + 1); + assert!(node(&s, locked).is_none() && node(&s, hidden).is_none()); + let (na, nb) = (node(&s, a).unwrap(), node(&s, b).unwrap()); + assert!(na.locked && na.visible && !nb.visible && !nb.locked); + assert_eq!(nb.opacity, 0.5); + assert!(s.doc().unwrap().selection.objects.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, locked).is_some() && !node(&s, a).unwrap().locked && node(&s, b).unwrap().visible); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(node(&s, locked).is_none() && node(&s, a).unwrap().locked); + // Locked and hidden groups nested in a named group that can be edited. + let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let e = rect(&mut s, 100.0, 10.0, 20.0, 20.0); + let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0); + let inner_locked = wrap_all(&mut s, &[e], plain_group); + let inner_hidden = wrap_all(&mut s, &[f], plain_group); + change(&mut s, inner_locked, |n| n.locked = true); + change(&mut s, inner_hidden, |n| n.visible = false); + let outer = wrap_all(&mut s, &[c, inner_locked, inner_hidden], plain_group); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [outer.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(3), json!([c.0]))); + assert!(node(&s, e).unwrap().locked && !node(&s, f).unwrap().visible); + assert_eq!(s.doc().unwrap().selection.objects, [c]); + // In a named layer and its sublayer; the layers and a clipping group stay. + let layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let g = rect(&mut s, 10.0, 100.0, 20.0, 20.0); + let h = rect(&mut s, 40.0, 100.0, 20.0, 20.0); + let layer_locked = wrap_all(&mut s, &[g], plain_group); + change(&mut s, layer_locked, |n| n.locked = true); + let layer_hidden = wrap_all(&mut s, &[h], plain_group); + change(&mut s, layer_hidden, |n| n.visible = false); + let sub = wrap_all(&mut s, &[layer_hidden], sublayer); + let clipped = rect(&mut s, 100.0, 100.0, 10.0, 10.0); + let clip = rect(&mut s, 95.0, 95.0, 30.0, 30.0); + select(&mut s, &[clipped, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let clip_group = s.doc().unwrap().selection.objects[0]; + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [layer.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(2), steps + 1)); + assert!(node(&s, layer_locked).is_none() && node(&s, layer_hidden).is_none()); + assert!(node(&s, layer).is_some() && node(&s, clip_group).is_some()); + assert!(node(&s, g).unwrap().locked && !node(&s, h).unwrap().visible); + assert_eq!(s.doc().unwrap().doc.parent_of(h), Some(sub)); + // On a locked artboard, a named group stays whole, with the selection as it was. + let k = rect(&mut s, 10.0, 200.0, 20.0, 20.0); + let on_board = wrap_all(&mut s, &[k], plain_group); + let off_board = rect(&mut s, 500.0, 10.0, 20.0, 20.0); + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + select(&mut s, &[off_board]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [on_board.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), r["ids"].clone(), undo_steps(&s)), (json!(0), json!([off_board.0]), steps)); + assert!(node(&s, on_board).is_some()); + assert_eq!(s.doc().unwrap().selection.objects, [off_board]); +} + #[test] fn rejoin_joins_pieces_within_the_gap_only() { let mut s = session(); @@ -646,3 +831,74 @@ fn rejoin_joins_pieces_within_the_gap_only() { assert!(s.execute("addon.cleanup.rejoin", &bad).is_err(), "{bad}"); } } + +/// Named by `ids`, Rejoin Path Pieces takes locked and hidden pieces (in a locked group or layer +/// too) and joins only pieces alike in being hidden and in being locked, whichever is topmost, as one +/// undo step; a locked or hidden path it makes is not selected. From the selection it takes only +/// pieces that can be edited, and compound path members and pieces on a locked artboard stay out. +#[test] +fn rejoin_named_locked_and_hidden_pieces() { + let mut s = session(); + let a = line(&mut s, &[(10.0, 10.0), (50.0, 10.0)]); + let b = line(&mut s, &[(50.05, 10.0), (90.0, 10.0)]); + let g = wrap_all(&mut s, &[a, b], plain_group); + change(&mut s, g, |n| n.locked = true); + select(&mut s, &[g]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.rejoin", &json!({})).unwrap(); + assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, none is taken"); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [g.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([b.0]))); + assert_eq!(undo_steps(&s), steps + 1); + assert!(node(&s, a).is_none() && anchors(&s, b) == 3); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the locked path is not selected"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, a).is_some() && anchors(&s, b) == 2); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(node(&s, a).is_none() && anchors(&s, b) == 3); + // A shown piece and two hidden ones, the hidden on top: only the hidden pair joins. + let c = line(&mut s, &[(10.0, 40.0), (50.0, 40.0)]); + let e = line(&mut s, &[(50.05, 40.0), (90.0, 40.0)]); + let h = line(&mut s, &[(90.05, 40.0), (130.0, 40.0)]); + change(&mut s, e, |n| n.visible = false); + change(&mut s, h, |n| n.visible = false); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [c.0, e.0, h.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([h.0]))); + assert!(node(&s, e).is_none() && anchors(&s, c) == 2 && anchors(&s, h) == 3); + assert!(!node(&s, h).unwrap().visible && node(&s, c).unwrap().visible); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the hidden path is not selected"); + // An unlocked piece under a locked one, and a hidden piece under a shown one: none joins. + let k = line(&mut s, &[(10.0, 70.0), (50.0, 70.0)]); + let m = line(&mut s, &[(50.05, 70.0), (90.0, 70.0)]); + let n = line(&mut s, &[(10.0, 100.0), (50.0, 100.0)]); + let o = line(&mut s, &[(50.05, 100.0), (90.0, 100.0)]); + change(&mut s, m, |x| x.locked = true); + change(&mut s, n, |x| x.visible = false); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [k.0, m.0, n.0, o.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["removed"].clone(), undo_steps(&s)), (json!(0), json!(0), steps)); + assert!([k, m, n, o].iter().all(|id| anchors(&s, *id) == 2)); + // Named pieces in a locked layer join; members of a compound path stay out. + let layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let p = line(&mut s, &[(10.0, 130.0), (50.0, 130.0)]); + let q = line(&mut s, &[(50.05, 130.0), (90.0, 130.0)]); + let u = line(&mut s, &[(10.0, 160.0), (50.0, 160.0)]); + let v = line(&mut s, &[(50.05, 160.0), (90.0, 160.0)]); + wrap_all(&mut s, &[u, v], |c| NodeKind::Compound { children: c, rule: FillRule::NonZero }); + let w = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)]); + let x = line(&mut s, &[(50.05, 200.0), (90.0, 200.0)]); + change(&mut s, layer, |l| l.locked = true); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [p.0, q.0, u.0, v.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["ids"].clone()), (json!(1), json!([q.0]))); + assert!(node(&s, p).is_none() && anchors(&s, u) == 2 && anchors(&s, v) == 2); + // On a locked artboard, named pieces stay as they are. + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [w.0, x.0]})).unwrap(); + assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps)); + assert!(node(&s, w).is_some() && node(&s, x).is_some()); +} diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index afa232a7ce..7d11eedd1a 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -1225,7 +1225,7 @@ fn expand_dashes_keeps_the_fill_below_and_follows_aligned_corners() { let mut centred = 0; for id in ids(&r) { let sp = path(&s, id).subpaths[0].clone(); - if let [a, b, c] = sp.anchors.as_slice() + if let [a, b, c] = &*sp.anchors.to_vec() && corners.iter().any(|k| b.p.distance(*k) < 1e-6) { assert!((a.p.distance(b.p) - 5.0).abs() < 1e-6 && (c.p.distance(b.p) - 5.0).abs() < 1e-6, "{sp:?}"); diff --git a/crates/engine/src/cmd/addons/select.rs b/crates/engine/src/cmd/addons/select.rs index b2ab59d851..c9ae29a303 100644 --- a/crates/engine/src/cmd/addons/select.rs +++ b/crates/engine/src/cmd/addons/select.rs @@ -14,8 +14,9 @@ use vectorcraft_doc::{LockedArtboards, Node, NodeId, NodeKind}; use vectorcraft_geom::Point; use super::super::links::{Status, statuses}; +use super::super::panelcmds::listed_artboards; use super::super::select::{is_selectable, on_artboard, selectable}; -use super::super::{CommandSpec, bad, checked_ids_param, cmd, has_doc, point_indices, switch_or, text_param}; +use super::super::{CommandSpec, bad, checked_ids_param, cmd, has_doc, switch_or, text_param}; use crate::{DocState, Result, Session}; /// How Select by Artboards relates an object to the chosen artboards (`relation`). @@ -33,7 +34,7 @@ pub fn specs() -> Vec { "Select by Artboards", ["Object", "Plug-ins", "Selection Tools"], None, - "{artboards: [index…] (0-based, at least one), relation?: \"overlap\" (default: on at least one of them, as select.allOnArtboard takes it: the bounds overlap the artboard or the centre is on it) | \"inside\" (the bounds lie wholly within one of them, edges included) | \"outside\" (on none of them)} select those objects among the ones Select All takes (in isolation mode, the isolated container's); hidden and locked objects, objects on hidden, locked or template layers and art held by a locked artboard are left out. Bounds are geometric, a symbol instance's measured by its art → {count}", + "{artboards: [index…] (0-based whole numbers, at least one), relation?: \"overlap\" (default: on at least one of them, as select.allOnArtboard takes it: the bounds overlap the artboard or the centre is on it) | \"inside\" (the bounds lie wholly within one of them, edges included) | \"outside\" (on none of them)} select those objects among the ones Select All takes (in isolation mode, the isolated container's); hidden and locked objects, objects on hidden, locked or template layers and art held by a locked artboard are left out. Bounds are geometric, a symbol instance's measured by its art → {count}", has_doc, by_artboards ), @@ -58,13 +59,12 @@ fn by_artboards(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`relation` must be overlap, inside or outside")); } let listed = p.get("artboards").ok_or_else(|| bad(C, "missing `artboards`"))?; - let indices = point_indices(listed).ok_or_else(|| bad(C, "`artboards` must be a list of artboard indices"))?; - if indices.is_empty() { + if listed.as_array().is_some_and(Vec::is_empty) { return Err(bad(C, "choose at least one artboard")); } let st = s.doc()?; - let rects = - indices.iter().map(|i| st.doc.artboards.get(*i).map(|a| a.rect)).collect::>>().ok_or_else(|| bad(C, "no such artboard"))?; + let rects: Vec<_> = + listed_artboards(&st.doc, listed, "artboards", C)?.into_iter().filter_map(|i| st.doc.artboards.get(i)).map(|a| a.rect).collect(); let symbols = st.doc.symbol_boxes(); let ids: Vec = selectable(&st.doc, st.isolation) .into_iter() diff --git a/crates/engine/src/cmd/addons/select/tests.rs b/crates/engine/src/cmd/addons/select/tests.rs index c97b278258..09106a3652 100644 --- a/crates/engine/src/cmd/addons/select/tests.rs +++ b/crates/engine/src/cmd/addons/select/tests.rs @@ -108,6 +108,35 @@ fn bad_params_are_errors_and_leave_the_selection() { } } +/// A malformed `artboards` entry is refused as one, naming the parameter, before any entry is +/// looked up; a value that is not a list, an empty list, a left-out list and a whole number past +/// the last artboard each have their own error. +#[test] +fn artboard_errors_say_what_is_wrong() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let entries = "invalid parameters for `addon.select.byArtboards`: `artboards` must contain whole numbers"; + let not_a_list = "invalid parameters for `addon.select.byArtboards`: `artboards` must be a list of artboard indices"; + for (p, want) in [ + (json!({"artboards": ["1"]}), entries), + (json!({"artboards": [-1]}), entries), + (json!({"artboards": [1.5]}), entries), + (json!({"artboards": [true]}), entries), + (json!({"artboards": [9, "1"]}), entries), + (json!({"artboards": "0"}), not_a_list), + (json!({"artboards": null}), not_a_list), + (json!({"artboards": []}), "invalid parameters for `addon.select.byArtboards`: choose at least one artboard"), + (json!({}), "invalid parameters for `addon.select.byArtboards`: missing `artboards`"), + (json!({"artboards": [9]}), "no such artboard"), + (json!({"artboards": [0, 9]}), "no such artboard"), + ] { + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.execute(C, &p).unwrap_err().to_string(), want, "{p}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{p}"); + } + assert_eq!(pick(&mut s, json!({"artboards": [0, 0]})), vec![a]); +} + #[test] fn a_symbol_instance_is_measured_by_its_art() { // The symbol's art, (100, 60) to (140, 80), drawn 50 pt up and left: (50, 10) to (90, 30), on diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 1046cada1c..db6e4e28a1 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -19,8 +19,8 @@ use super::edit::{outermost, selected_roots}; use super::pathops::{remove_each, separate}; use super::swatch::{anchor, names_param}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok, - str_param, switch_or, + CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, named_ids, number_or, ok, str_param, + switch_or, }; use crate::EngineError; @@ -180,7 +180,7 @@ pub fn specs() -> Vec { "Paintbrush", [], None, - "{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, or a `style` other than \"pencil\" or \"brush\", is an error and nothing is drawn → {id}", + "{…params as path.freehand, brush?: name} paint a freehand stroke with a brush; without `brush` (or with null) the plain path is drawn; a `brush` that names no brush in the document, a `style` other than \"pencil\" or \"brush\", a `brush` for a path with no stroke (a continued or redrawn path without one, or a new filled \"pencil\" path while the stroke is None) and an unknown key are errors and nothing is drawn; the path and its brush are one undo step → {id}", has_doc, brush_freehand ), diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 7fd8c9aa9a..89f3893b24 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -1,7 +1,6 @@ use super::{ AppearanceItem, Arc, BTreeSet, Bristle, Brush, BrushKind, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, Result, Session, Value, - anchor, bad, brush, check_numbers, find_command, id_param, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown, - str_param, + anchor, bad, brush, check_numbers, find_command, ids_json, json, name_param, names_param, ok, roots, selection_art, set_unknown, str_param, }; // ---------- brushes ---------- @@ -729,9 +728,10 @@ pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&out) })) } -/// Paints a `path.freehand` stroke with the brush `brush` names. A `brush` left out or null draws -/// the plain path; a `brush` that is not a string or names no brush in the document, and a `style` -/// other than "pencil" or "brush", is an error before anything is drawn. +/// Paints a `path.freehand` stroke with the brush `brush` names, in one undo step with the path. +/// A `brush` left out or null draws the plain path; a `brush` that is not a string or names no +/// brush in the document, a `style` other than "pencil" or "brush", and a `brush` for a path with +/// no stroke are errors before anything is drawn. pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { const C: &str = "brush.freehand"; let name = match crate::cmd::text_param(p, "brush", C)? { @@ -745,13 +745,6 @@ pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result { Some(v) => return Err(bad(C, format!("`style` must be \"pencil\" or \"brush\", not {v}"))), } let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?; - let r = crate::cmd::run_checked(s, spec, p)?; - let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) }; - s.edit("Paintbrush", |d, _| { - if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { - st.brush = Some(name.clone()); - } - Ok(()) - })?; - Ok(r) + let checked = crate::schemas::check(spec, p, s.replaying())?; + crate::cmd::draw2::freehand_brushed(s, checked.as_ref().unwrap_or(p), name.as_deref()) } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index f694a2b144..66c9ec8fc4 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces; when every face it reaches already has the paint, nothing changes (no undo step, and the recent colors stay as they are) → {group, face, count (faces reached)}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), scope?: \"one\" (default: the face alone) | \"connected\" (a double-click: also each face reached across edges that are hidden or draw no stroke, as a flood from the face; a stroked edge, a hidden face or a Gap Detection gap stops it) | \"samePaint\" (a triple-click: also every face of the group whose fill is match; \"connected\" and \"samePaint\" are refused with points), match?: paint (as the document stores it, the `fill` of a face's appearance; scope samePaint only; default: the face's fill), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces: each face reached has its fills replaced by one fill with the paint, shown, fully opaque and blending normally, in the place of its topmost fill (none leaves one fill of none), and its strokes and its own opacity stay as they are; when every face it reaches already has only that one fill, nothing changes (no undo step, and the recent colors stay as they are) → {group, face, count (faces reached)}", has_doc, lp_fill ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Live Paint Bucket (Stroke)", [], None, - "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges: each edge reached takes the paint and width in its topmost stroke that is shown, or in a new stroke above the others when it has none; when every edge it reaches already has the paint and the width there, nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}", + "{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), scope?: \"one\" (default: the edge alone) | \"connected\" (a double-click: also each edge joined to it, end to end, through edges that draw with stroke paint match) | \"samePaint\" (a triple-click: also every edge of the group that draws with stroke paint match), match?: paint (as the document stores it; scope connected or samePaint only; default: the stroke paint the edge draws with; an edge draws with the paint of its topmost stroke that is shown, with paint, above 0 pt and not fully transparent, and with none when the edge is fully transparent or has no such stroke), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point, skipping hidden edges: each edge reached has its strokes replaced by one stroke with the paint and width, shown, fully opaque and blending normally, in the place of its topmost stroke and with the dashes, caps and arrowheads of its topmost shown stroke (a new stroke above the fills when it has none; none leaves one stroke of none), and its fills stay as they are; when every edge it reaches already has only that one stroke (at any width for none), nothing changes (no undo step, and the recent colors stay as they are) → {group, edge, count (edges reached)}", has_doc, lp_stroke_edge ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\" (abutting with fills, not mode logo: a \"Seam Fills\" group under the paths holds 1.5 px strokes along the edges two filled paths share, in the color of the lower path, so that no light hairline shows along those edges; the paths are not changed), ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 8dcd453650..6784f36294 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -525,16 +525,17 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { Some(gid) => { let (fid, targets) = targets_in(&s.doc()?.doc, gid)?; let count = targets.len(); - // Faces that already have the paint stay as they are: when every face has it, nothing - // changes and no undo step is recorded. + // Faces that already have only the fill the bucket would leave ([`bucket_keeps`]) stay as + // they are: when every face does, nothing changes and no undo step is recorded. let d = &s.doc()?.doc; - let unpainted: Vec = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect(); + let unpainted: Vec = + targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| !bucket_keeps(&f.appearance, true, &paint, 0.0))).collect(); if unpainted.is_empty() { return Ok(json!({ "group": gid.0, "face": fid.0, "count": count })); } s.edit("Live Paint Bucket", |d, _| { for target in unpainted { - d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + bucket_paint(&mut d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance, true, &paint, 0.0); } Ok((gid, fid, count)) })? @@ -544,7 +545,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { let (fid, targets) = targets_in(d, gid)?; let count = targets.len(); for target in targets { - d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + bucket_paint(&mut d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance, true, &paint, 0.0); } Ok((gid, fid, count)) })?, @@ -580,18 +581,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { .collect(), }; let count = targets.len(); - // An edge whose shown stroke already has the paint and the width (or that has no shown stroke - // and gets none) stays as it is; when every edge does, nothing changes and no undo step is - // recorded. - let changed: Vec = targets - .into_iter() - .filter(|t| { - d.node(*t).is_none_or(|e| match shown_stroke(e) { - Some(st) => !(st.paint == paint && st.width == width), - None => !paint.is_none(), - }) - }) - .collect(); + // An edge that already has only the stroke the bucket would leave ([`bucket_keeps`]) stays + // as it is; when every edge does, nothing changes and no undo step is recorded. + let changed: Vec = + targets.into_iter().filter(|t| d.node(*t).is_none_or(|e| !bucket_keeps(&e.appearance, false, &paint, width))).collect(); (eid, count, changed) }; if changed.is_empty() { @@ -599,14 +592,7 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { } s.edit("Live Paint Bucket", |d, _| { for target in &changed { - let e = d.node_mut(*target).ok_or(EngineError::NoNode(*target))?; - match shown_stroke_mut(e) { - Some(st) => { - st.paint = paint.clone(); - st.width = width; - } - None => e.appearance.items.push(super::AppearanceItem::Stroke(super::StrokeLayer::new(paint.clone(), width))), - } + bucket_paint(&mut d.node_mut(*target).ok_or(EngineError::NoNode(*target))?.appearance, false, &paint, width); } Ok(()) })?; @@ -614,22 +600,76 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { Ok(json!({ "group": g.0, "edge": eid.0, "count": count })) } -/// The stroke `livePaint.strokeEdge` paints on Live Paint edge `e`: its topmost stroke that is -/// shown. None when it has no stroke or every stroke is hidden; the command then adds a stroke -/// above the others. -fn shown_stroke(e: &Node) -> Option<&super::StrokeLayer> { - e.appearance.items.iter().rev().find_map(|i| match i { - super::AppearanceItem::Stroke(s) if s.visible => Some(s), - _ => None, - }) +/// Whether the Live Paint Bucket leaves appearance `a` as it is when it paints its fills (`fill`) +/// or strokes with `paint` (a stroke at `width`): it has exactly one row of that kind, shown, fully +/// opaque, blending normally and with the paint (a stroke also with the width, unless the paint is +/// none), or it has no row of that kind and the paint is none. +fn bucket_keeps(a: &Appearance, fill: bool, paint: &super::Paint, width: f64) -> bool { + let mut rows = a.items.iter().filter(|i| i.is_fill() == fill); + match (rows.next(), rows.next()) { + (None, _) => paint.is_none(), + (Some(row), None) => { + row.visible() + && row.opacity() == 1.0 + && row.blend() == vectorcraft_color::BlendMode::Normal + && row.paint() == paint + && match row { + AppearanceItem::Fill(_) => true, + AppearanceItem::Stroke(st) => paint.is_none() || st.width == width, + } + } + (Some(_), Some(_)) => false, + } } -/// [`shown_stroke`], to change. -fn shown_stroke_mut(e: &mut Node) -> Option<&mut super::StrokeLayer> { - e.appearance.items.iter_mut().rev().find_map(|i| match i { - super::AppearanceItem::Stroke(s) if s.visible => Some(s), - _ => None, - }) +/// Paint the fills (`fill`) or strokes of appearance `a` with `paint` (a stroke at `width`), as the +/// Live Paint Bucket does: every row of that kind is replaced by one row, shown, fully opaque and +/// blending normally, in the place of the topmost row of that kind. The new row keeps the other +/// settings (a stroke's dashes, caps and arrowheads, a row's effects) of the topmost row of that +/// kind that is shown, or of the topmost one when every row is hidden. Rows of the other kind stay +/// as they are. With no row of that kind, a fill is added below the strokes and a stroke above the +/// fills. +fn bucket_paint(a: &mut Appearance, fill: bool, paint: &super::Paint, width: f64) { + let of_kind = |i: &AppearanceItem| i.is_fill() == fill; + let Some(top) = a.items.iter().rposition(of_kind) else { + if fill { + a.set_fill(paint.clone()); + } else { + a.items.push(AppearanceItem::Stroke(super::StrokeLayer::new(paint.clone(), width))); + } + return; + }; + let base = a.items.iter().rposition(|i| of_kind(i) && i.visible()).unwrap_or(top); + let Some(mut row) = a.items.get(base).cloned() else { return }; + match &mut row { + AppearanceItem::Fill(f) => { + f.paint = paint.clone(); + f.opacity = 1.0; + f.blend = vectorcraft_color::BlendMode::Normal; + f.visible = true; + } + AppearanceItem::Stroke(st) => { + st.paint = paint.clone(); + st.width = width; + st.opacity = 1.0; + st.blend = vectorcraft_color::BlendMode::Normal; + st.visible = true; + } + } + let mut row = Some(row); + a.items = std::mem::take(&mut a.items) + .into_iter() + .enumerate() + .filter_map(|(i, item)| { + if i == top { + row.take() + } else if of_kind(&item) { + None + } else { + Some(item) + } + }) + .collect(); } /// Every fill and stroke row of face or edge `n`, bottom first, as `livePaint.info` lists them. A diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index a117bd453e..2eb81eaef5 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -12,6 +12,7 @@ use super::{ thread_local! { static BOX_TESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; static CANDIDATE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static TRACES_ON_THIS_THREAD: std::cell::Cell = const { std::cell::Cell::new(0) }; } // ---------- Image Trace ---------- @@ -22,6 +23,22 @@ fn is_trace_group(n: &Node) -> bool { && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) } +/// The name of the group that holds an Abutting trace's seam fills (see [`tr::TraceResult::seams`]). +const SEAM_FILLS_NAME: &str = "Seam Fills"; + +/// Whether `n` can be a trace's seam fills group: a group named as one, or a group of stroked paths +/// without fills, so that it is still found after it is renamed or its strokes are edited. (Auto +/// Grouping groups hold filled paths.) +fn is_seam_fills(n: &Node) -> bool { + let stroke_only = |m: &Arc| { + matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && m.appearance.fill_paint() == Paint::None + && m.appearance.stroke_paint() != Paint::None + }; + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && (n.name.as_deref() == Some(SEAM_FILLS_NAME) || n.children().is_some_and(|c| !c.is_empty() && c.iter().all(stroke_only))) +} + const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor"; /// The `view` param ([`TraceView`] id), if given. @@ -393,11 +410,15 @@ fn by_color_param(cmd: &str, p: &Value) -> Result { /// (by the boxes of everything they paint, effects included, within one point); otherwise it /// starts another group of that paint, which keeps the stacking order of those items. Items /// farther apart can still share antialiased pixels at small view or raster-export scales, so -/// their edge pixels may change. A group among `items` (Auto Grouping) is refused. +/// their edge pixels may change. The seam fills' group, when it is the lowest item, stays as it is, +/// under every other group. Any other group among `items` (Auto Grouping) is refused. fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result>> { use std::collections::{BTreeMap, HashMap}; use std::ops::Bound::{Excluded, Unbounded}; use vectorcraft_color::tint_percent; + // The seam fills come first, under every path: kept there, they move under nothing. + let mut items = items; + let seams: Vec> = if items.first().is_some_and(|n| is_seam_fills(n)) { items.drain(..1).collect() } else { vec![] }; // Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a // gradient, or another paint (its label). The name is apart from it: a swatch may be named as // a color's `#rrggbb` or as "Gradients". @@ -517,25 +538,31 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result> = groups .into_iter() .map(|run| { let mut g = Node::group(d.alloc_id(), run.members); g.name = Some(run.name); Arc::new(g) }) - .collect()) + .collect(); + Ok(seams.into_iter().chain(groups).collect()) } -/// What a trace depends on: the document it is made in, the object traced (an image, or an Image -/// Trace object traced again), its image, the image's encoded bytes, and the settings it is traced -/// with. The tracer makes the same paths of the same inputs, so a trace made away from the command -/// ([`TraceRun`]) stands in for the command's own in the same document. +/// What a trace depends on: the document it is made in and the content it was reverted to, the +/// object traced (an image, or an Image Trace object traced again), its image, the image's encoded +/// bytes, and the settings it is traced with. The tracer makes the same paths of the same inputs, +/// so a trace made away from the command ([`TraceRun`]) stands in for the command's own in the same +/// document until File › Revert replaces its content. #[derive(Clone, Debug, PartialEq)] struct TraceInputs { /// The [`DocState::uid`](crate::DocState::uid) of the document traced in. A trace made in one /// document never stands in for one in another, however alike their images and settings. doc: u64, + /// The document's [`DocState::reverts`](crate::DocState::reverts) when the trace was prepared. + /// A trace prepared before File › Revert never stands in for one after it, even when the + /// restored image, pixels, object and settings are the same. + reverts: u64, target: NodeId, image: Box, /// The settings traced with, as JSON. @@ -604,21 +631,25 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { let bytes = Arc::clone(&blob.bytes); let library = trace_library(c, s, &mut params)?; let traced_with = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; - let inputs = TraceInputs { doc: s.doc()?.uid, target, image, params: traced_with, swatches: params.swatches.clone(), bytes }; + let st = s.doc()?; + let inputs = TraceInputs { doc: st.uid, reverts: st.reverts, target, image, params: traced_with, swatches: params.swatches.clone(), bytes }; Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library }) } -/// What a trace made of an image: the size in pixels it was decoded at, and the paths. +/// What a trace made of an image: the size in pixels it was decoded at, the paths, and their seam +/// fills ([`seams_within`]). pub struct Traced { inputs: TraceInputs, raster: (u32, u32), res: tr::TraceResult, + seams: Vec, } impl Traced { /// Hand it to the next run of its command with the same inputs ([`TraceRun::params`]) in the /// document it was made in, which applies it as one undo step instead of tracing again. Any - /// other trace drops it, a trace in another document included. + /// other trace drops it and traces again, a trace in another document included, and so does + /// the command after File › Revert replaced the document's content. pub fn hand_in(self, s: &mut Session) { s.trace_hand_off = Some(self); } @@ -627,6 +658,8 @@ impl Traced { /// Decode the image bytes of `inputs` and trace them with `params`, telling `steps` how far it has /// got ([`tr::trace_watched`]); [`EngineError::Cancelled`] once `steps` answers `false`. fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { + #[cfg(test)] + TRACES_ON_THIS_THREAD.with(|n| n.set(n.get() + 1)); // An image over the tracer's size is traced scaled down (the paths are scaled back). let raster = tr::Raster::decode_to_fit(&inputs.bytes).map_err(|e| EngineError::Other(e.to_string()))?; if !steps(0, 0) { @@ -636,7 +669,18 @@ fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, steps: &(dyn Fn(u64 tr::TraceError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), })?; - Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res }) + let seams = seams_within(&res, params, MAX_TRACE_ANCHORS); + Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res, seams }) +} + +/// The seam fills of `res`, traced with `params` ([`tr::seam_fills`]), while the paths and the seam +/// fills together stay within `max` anchors: the seam fills have at most as many anchors as the +/// paths, so a trace whose paths have more than half of `max` gets none. +fn seams_within(res: &tr::TraceResult, params: &tr::TraceParams, max: usize) -> Vec { + if res.anchor_count() > max / 2 { + return vec![]; + } + tr::seam_fills(&res.paths, params) } /// An Image Trace made away from its command, off the UI thread (#3098). [`TraceRun::prepare`] @@ -691,17 +735,30 @@ impl TraceRun { } /// Does it still apply: would the command trace the same object, image, pixels and settings - /// now, in the document it was prepared in? + /// now, in the document it was prepared in, with no File › Revert since? pub fn current(&self, s: &Session) -> bool { prepare(s, self.command, &self.params).is_ok_and(|now| now.inputs == self.prepared.inputs) } + /// Has File › Revert replaced the content of the document it was prepared in since then? False + /// when that document is no longer open. + pub fn reverted(&self, s: &Session) -> bool { + let TraceInputs { doc, reverts, .. } = self.prepared.inputs; + s.documents().iter().any(|st| st.uid == doc && st.reverts != reverts) + } + /// Decode and trace the image, telling `steps` how far it has got → what it traced, or why it /// failed or stopped ([`EngineError::Cancelled`] once `steps` answers `false`). It touches no /// document, so it can run on any thread. pub fn run(&self, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { trace_now(&self.prepared.inputs, &self.prepared.trace, steps) } + + /// How many images this thread has decoded and traced so far. + #[cfg(test)] + pub(crate) fn traces_on_this_thread() -> usize { + TRACES_ON_THIS_THREAD.with(std::cell::Cell::get) + } } pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { @@ -716,7 +773,7 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result traced, _ => trace_now(&inputs, &trace, &|_, _| true)?, }; @@ -729,6 +786,13 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Result> = seam_fills + .iter() + .filter_map(|tp| { + let width = tp.stroke?; + let mut n = shape_node(d, tp.path.transformed(xf), None); + n.appearance = Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { + cap: LineCap::Butt, + join: LineJoin::Round, + ..StrokeLayer::new(paint_of(tp), width * seam_unit) + })], + ..Default::default() + } + .into(); + Some(Arc::new(n)) + }) + .collect(); + if !seams.is_empty() { + let mut g = Node::group(d.alloc_id(), seams); + g.name = Some(SEAM_FILLS_NAME.into()); + children.push(Arc::new(g)); + } let mut traced = Vec::with_capacity(res.paths.len()); for tp in res.paths { + let paint = paint_of(&tp); let path = tp.path.transformed(xf); let mut n = shape_node(d, path, None); // Shapes: a path the tracer found to be a shape is that live shape. @@ -764,10 +858,6 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Appearance { @@ -1103,9 +1193,28 @@ mod color_group_tests { #[cfg(test)] mod trace_limit_tests { - use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings}; + use super::{MAX_TRACE_ANCHORS, keep_warnings, seams_within, trace_warnings}; use vectorcraft_trace as tr; + #[test] + fn seam_fills_count_against_the_anchor_limit() { + // Two colors side by side, the edge between them anti-aliased: one seam fill along it. + let img = tr::Raster::from_fn(24, 16, |x, _| { + let share = (10.4 - f64::from(x)).clamp(0.0, 1.0); + let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8; + [mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255] + }); + let params = tr::TraceParams { mode: tr::Mode::Color, colors: 2, method: tr::Method::Abutting, ..tr::TraceParams::default() }; + let res = tr::trace(&img, ¶ms); + let all = tr::seam_fills(&res.paths, ¶ms); + assert!(!all.is_empty(), "{res:?}"); + let anchors = res.anchor_count(); + // Twice the paths' anchors leaves room for any seam fills. + assert_eq!(seams_within(&res, ¶ms, 2 * anchors), all); + // One anchor short of that: none. + assert_eq!(seams_within(&res, ¶ms, 2 * anchors - 1), []); + } + #[test] fn keep_warnings_name_matched_colors_that_produce_no_paths() { let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() }; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 5922787723..d863edb96d 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget}; +use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, Policy, ProofTarget}; use vectorcraft_color::recolor::Neutral; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::ColorMode; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Color Settings…", ["Edit"], Some("Cmd+Shift+K"), - "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", + "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool, rgbPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\", cmykPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\"|\"preserveNumbers\"} set the working spaces and the color management policies → {rgb, cmyk, intent, bpc, rgbPolicy, cmykPolicy, profiles: [{name, kind, builtin}]}. A policy decides what opening a document (document.open, file.newFromTemplate, file.revert) does with an RGB or CMYK profile it is tagged with that is not the working space: preserveEmbedded (default) keeps it; off and preserveNumbers take it off and keep the color numbers; convert converts the colors of that model into the working space with the intent and bpc and takes it off. The open reply's warnings name each profile taken off or converted, and a document a policy changed opens with unsaved changes. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", always, color_settings ), @@ -163,12 +163,26 @@ fn on_param(p: &Value, key: &str, cmd: &str) -> Result> { switch_or(p, key, false, cmd).map(Some) } +/// The policy `p` gives for `key`, one of `allowed`, or `None` when `p` leaves it out. +fn policy_param(p: &Value, key: &str, allowed: &[Policy], cmd: &str) -> Result> { + let Some(v) = text_param(p, key, cmd)? else { return Ok(None) }; + match Policy::parse(v).filter(|x| allowed.contains(x)) { + Some(x) => Ok(Some(x)), + None => { + let ids: Vec<&str> = allowed.iter().map(Policy::id).collect(); + Err(bad(cmd, format!("`{key}` must be one of {}", ids.join(", ")))) + } + } +} + fn settings_json(st: &ColorSettings) -> Value { json!({ "rgb": st.rgb, "cmyk": st.cmyk, "intent": st.intent.id(), "bpc": st.bpc, + "rgbPolicy": st.rgb_policy.id(), + "cmykPolicy": st.cmyk_policy.id(), "profiles": cms::profiles().iter().map(|p| json!({"name": p.name, "kind": p.kind, "builtin": p.builtin})).collect::>(), }) } @@ -205,6 +219,12 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { st.intent = i; } st.bpc = switch_or(p, "bpc", st.bpc, C)?; + if let Some(v) = policy_param(p, "rgbPolicy", &Policy::RGB, C)? { + st.rgb_policy = v; + } + if let Some(v) = policy_param(p, "cmykPolicy", &Policy::CMYK, C)? { + st.cmyk_policy = v; + } if st != before { cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; // Saved for the next start when the app attached a settings file (#6335). @@ -530,7 +550,12 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, if !convert { return 0; } - let conv = mode_converter(mode, intent, grays); + convert_colors(d, &mode_converter(mode, intent, grays)) +} + +/// Every color of `d` that [`set_color_mode`] converts, converted with `conv` → how many colors of +/// the art changed, as [`set_color_mode`] counts them. +fn convert_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) -> usize { for sw in d.swatches_iter_mut() { match &mut sw.paint { Paint::Solid { color, .. } => *color = conv(color), @@ -538,6 +563,11 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, for st in &mut g.gradient.stops { st.color = conv(&st.color); } + if let Some(freeform) = &mut g.freeform { + for point in &mut freeform.points { + point.color = conv(&point.color); + } + } } _ => {} } @@ -621,6 +651,102 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, in_art } +/// Apply the color management policies of Edit → Color Settings to `d`, a document being opened: +/// each RGB or CMYK profile it is tagged with that is not the working space of its model is kept +/// (Preserve Embedded Profiles), taken off with the color numbers kept (Off, Preserve Numbers), or +/// taken off after the colors of that model were converted from it into the working space with the +/// working intent (Convert to Working Space). A profile that is not available here is taken off +/// by Off and Preserve Numbers and kept by Convert to Working Space, which has nothing to convert +/// from. → a note for each profile, and whether `d` changed. +pub(crate) fn apply_open_policies(d: &mut vectorcraft_doc::Document) -> (Vec, bool) { + let working = cms::working_settings(); + let (rgb, cmyk) = doc_profiles(d); + let mut notes = Vec::new(); + // What each model's policy does with its profile: `None` keeps it; `Some` takes it off, after + // converting the colors from the profile it holds. + let mut decide = |label: &str, tag: Option, working: &str, policy: Policy, kind: cms::ProfileKind| -> Option> { + let tag = tag.filter(|t| t != working)?; + let available = cms::profile(&tag).is_some_and(|p| p.kind == kind); + match policy { + Policy::PreserveEmbedded => None, + Policy::Off | Policy::PreserveNumbers => { + notes.push(format!( + "the {label} profile “{tag}” the file is tagged with was not used: Color Settings' {label} policy is {}", + policy.label() + )); + Some(None) + } + Policy::Convert if available => { + notes.push(format!( + "the {label} colors were converted from the profile “{tag}” the file is tagged with into the working space “{working}”" + )); + Some(Some(tag)) + } + Policy::Convert => { + notes.push(format!("the {label} profile “{tag}” the file is tagged with is not available, so its colors were not converted")); + None + } + } + }; + let rgb_do = decide("RGB", rgb, &working.rgb, working.rgb_policy, cms::ProfileKind::Rgb); + let cmyk_do = decide("CMYK", cmyk, &working.cmyk, working.cmyk_policy, cms::ProfileKind::Cmyk); + if rgb_do.is_none() && cmyk_do.is_none() { + return (notes, false); + } + let (from_rgb, from_cmyk) = (rgb_do.clone().flatten(), cmyk_do.clone().flatten()); + if from_rgb.is_some() || from_cmyk.is_some() { + let source = cms::for_document(from_rgb.as_deref(), from_cmyk.as_deref()); + let dest = cms::working(); + let intent = working.intent; + let (rgb_on, cmyk_on) = (from_rgb.is_some(), from_cmyk.is_some()); + let conv = |c: &Color| match c { + Color::Rgb { .. } if rgb_on => source.convert_into(&dest, c, intent), + Color::Cmyk { .. } if cmyk_on => source.convert_into(&dest, c, intent), + _ => *c, + }; + convert_colors(d, &conv); + convert_setting_colors(d, &conv); + } + if rgb_do.is_some() { + d.color_profiles.rgb = None; + } + if cmyk_do.is_some() { + d.color_profiles.cmyk = None; + } + (notes, true) +} + +/// The colors of `d` outside its art that [`convert_colors`] leaves, converted with `conv`: each +/// artboard's background, the transparency grid and simulated paper colors of Document Setup, and +/// the fill and stroke of the character and paragraph styles. +fn convert_setting_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) { + for c in d.artboards.iter_mut().filter_map(|a| a.color.as_mut()) { + *c = conv(c); + } + for c in &mut d.setup.grid_colors { + *c = conv(c); + } + // A tint of a global swatch is the converted swatch's color at its tint, which + // [`convert_colors`] already gave it. + let bases = global_bases(d); + let mut f = |c: &Color, link: proof::Link| mode_color(c, link, &bases, conv); + for styles in [&mut d.char_styles, &mut d.para_styles] { + if !styles.iter().any(|s| s.attrs.contains_key("fill") || s.attrs.contains_key("stroke")) { + continue; + } + for def in styles.iter_mut() { + for key in ["fill", "stroke"] { + let Some(value) = def.attrs.get_mut(key) else { continue }; + let Ok(mut paint) = serde_json::from_value::(value.clone()) else { continue }; + map_paint_colors(&mut paint, &mut f); + if let Ok(v) = serde_json::to_value(&paint) { + *value = v; + } + } + } + } +} + /// [`proof::map_node_colors`] over `n`'s subtree, then [`map_more_colors`]. fn map_art_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { proof::map_node_colors(n, f); diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 3925612a14..131695e33e 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -1,15 +1,18 @@ //! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]), //! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a -//! font of its own. Type names a composite font as its family, and the font menus list them first. -//! The font database holds the active document's composite fonts ([`sync`]); a file exported here -//! carries them to another document. +//! font of its own, and the characters of each custom character set in the set's font. Type names +//! a composite font as its family, and the font menus list them first. The font database holds the +//! active document's composite fonts ([`sync`]); a file exported here carries them to another +//! document. //! //! Behavior reference (public documentation): -//! . +//! , +//! custom character sets: +//! . use serde_json::{Map, Value, json}; -use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX}; -use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts}; +use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet}; use vectorcraft_text::FontDb; use super::fileio::{read_file_head, write_file}; @@ -26,6 +29,10 @@ const MAX_FILE_BYTES: usize = 4 << 20; /// The keys a part may give. const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"]; +/// The keys a custom character set may give: its name and characters, then its part's values +/// (a set is never scaled at the center). +const SET_KEYS: [&str; 8] = ["name", "characters", "font", "style", "size", "baseline", "hScale", "vScale"]; + pub fn specs() -> Vec { vec![ cmd!( @@ -33,7 +40,7 @@ pub fn specs() -> Vec { "Composite Fonts", [], None, - "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}", + "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}, custom?: [{name, characters, font, style, size, baseline, hScale, vScale}]}]}", has_doc, list ), @@ -42,7 +49,7 @@ pub fn specs() -> Vec { "Composite Fonts…", ["Type"], None, - "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", + "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}, custom?: [{name, characters?: string = \"\", font?, style?, size?, baseline?, hScale?, vScale?}]}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. custom lists custom character sets: each sets the characters it lists in its own font, style, size, baseline and scales (values left out are the kanji part's), ahead of their class; a character two sets list is set by the later one. A set's name is trimmed and unique in the font in any case; its characters are each listed once, at most 8192, none a control character or past U+FFFF; at most 64 sets. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", has_doc, set ), @@ -51,7 +58,7 @@ pub fn specs() -> Vec { "Export Composite Fonts", [], None, - "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}", + "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads (a file over 4 MB is refused); without path the file's text comes back as data → {path, count} | {data, count}", has_doc, export ), @@ -121,8 +128,8 @@ fn list(s: &mut Session, _: &Value) -> Result { /// copy of the Kanji part, and keys or values a part can't take refused. fn parse_font(v: &Value, c: &str) -> Result { let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?; - if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) { - return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts"))); + if let Some(k) = o.keys().find(|k| !["name", "parts", "custom"].contains(&k.as_str())) { + return Err(bad(c, format!("unknown key `{k}`; a font takes name, parts and custom"))); } let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?; let name = CompositeFont::clean_name(raw) @@ -140,7 +147,17 @@ fn parse_font(v: &Value, c: &str) -> Result { serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key()))) }; let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?; - let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) }; + let custom = match o.get("custom") { + None => Vec::new(), + Some(Value::Array(sets)) => { + if sets.len() > CUSTOM_SETS_MAX { + return Err(bad(c, format!("`{name}`: a composite font keeps at most {CUSTOM_SETS_MAX} character sets"))); + } + sets.iter().map(|s| parse_set(s, &name, &kanji, c)).collect::>>()? + } + Some(_) => return Err(bad(c, format!("`{name}`: custom must be a list of character sets"))), + }; + let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji), custom }; for class in CharClass::ALL.into_iter().skip(1) { if let Some(p) = part(class)? { *f.parts.get_mut(class) = p; @@ -150,6 +167,38 @@ fn parse_font(v: &Value, c: &str) -> Result { Ok(f) } +/// Custom character set `v` of composite font `font` from command parameters or a file: its name +/// trimmed, the part values it leaves out those of `kanji`, and keys or values it can't take +/// refused. [`CompositeFont::check`] checks its characters and values. +fn parse_set(v: &Value, font: &str, kanji: &CompositePart, c: &str) -> Result { + let o = v.as_object().ok_or_else(|| bad(c, format!("`{font}`: each character set must be {{name, characters, font…}}")))?; + if let Some(k) = o.keys().find(|k| !SET_KEYS.contains(&k.as_str())) { + return Err(bad(c, format!("`{font}`: unknown key `{k}` in a character set"))); + } + let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, format!("`{font}`: each character set needs a name")))?; + let name = CompositeFont::clean_name(raw) + .ok_or_else(|| bad(c, format!("`{raw}` can't name a character set (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?; + let characters = match o.get("characters") { + None => String::new(), + Some(Value::String(s)) => s.clone(), + Some(_) => return Err(bad(c, format!("`{font}`: `{name}`: characters must be a string"))), + }; + // Checked here as well as by `check`, so a huge list is refused before it is copied again. + if characters.chars().nth(CUSTOM_CHARS_MAX).is_some() { + return Err(bad(c, format!("`{font}`: `{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters"))); + } + let mut part = serde_json::to_value(kanji).map_err(|e| bad(c, e.to_string()))?; + if let Some(p) = part.as_object_mut() { + for k in &SET_KEYS[2..] { + if let Some(given) = o.get(*k) { + p.insert(k.to_string(), given.clone()); + } + } + } + let part: CompositePart = serde_json::from_value(part).map_err(|e| bad(c, format!("`{font}`: `{name}`: {e}")))?; + Ok(CustomCharSet { name, characters, part }) +} + /// Error unless `fonts` can stand together in a document: names unique in any case, none naming /// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`]. fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { @@ -167,10 +216,10 @@ fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { } } for f in fonts { - for class in CharClass::ALL { - let font = &f.parts.get(class).font; + let classes = CharClass::ALL.into_iter().map(|class| (class.key(), &f.parts.get(class).font)); + for (part, font) in classes.chain(f.custom.iter().map(|s| (s.name.as_str(), &s.part.font))) { if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() { - return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key()))); + return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, part))); } } } @@ -260,6 +309,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let mut file = Map::new(); file.insert("compositeFonts".into(), json!(fonts)); let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?; + if text.len() > MAX_FILE_BYTES { + return Err(bad(C, "the composite fonts make a file larger than 4 MB, which Import can't read; export fewer of them")); + } match path { Some(path) => { write_file(path, text.as_bytes())?; diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index da310a89b9..751cb49ef0 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -856,3 +856,127 @@ fn composite_fonts_export_and_import() { assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +/// `font(name)` with two custom character sets: "Marks" (x and A) in Inter at 150%, and "A only" +/// (A) in Source Sans 3, which sets A since it comes later. +fn font_with_sets(name: &str) -> Value { + let mut v = font(name); + v["custom"] = json!([ + {"name": " Marks ", "characters": "xA", "font": "Inter", "size": 150, "hScale": 90}, + {"name": "A only", "characters": "A"}, + ]); + v +} + +/// Custom character sets are set with the font as one undo step, take values they leave out from +/// the Kanji part, set the characters they list ahead of their class (the later set winning), are +/// saved with the document, and measure type again when they change. +#[test] +fn custom_character_sets_set_their_characters() { + let mut s = session(); + let before = steps(&s); + let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap(); + assert_eq!(r["fonts"][0]["custom"][0]["name"], "Marks"); + assert_eq!(steps(&s), before + 1); + let f = s.doc().unwrap().doc.composite_fonts[0].clone(); + assert_eq!(f.custom.len(), 2); + assert_eq!((f.custom[0].part.font.as_str(), f.custom[0].part.size, f.custom[0].part.h_scale), ("Inter", 150.0, 90.0)); + assert_eq!((f.custom[1].part.font.as_str(), f.custom[1].part.size), ("Source Sans 3", 100.0)); + // The same sets again change nothing. + s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap(); + assert_eq!(steps(&s), before + 1); + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "xAB", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Sets"})).unwrap(); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str| db.face(family, "Regular").unwrap().id(); + let lay = vectorcraft_text::layout(db, &text(&s, id)); + let fonts: Vec = lay.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face("Inter"), face("Source Sans 3"), face("JetBrains Mono")]); + // Taking x out of Marks sets it in its class's font again, measured in the same step. + let wide = text(&s, id).cached_bounds.unwrap().width(); + let mut fewer = font_with_sets("CF Sets"); + fewer["custom"][0]["characters"] = json!("A"); + s.execute("type.compositeFonts.set", &json!({"fonts": [fewer]})).unwrap(); + assert!(text(&s, id).cached_bounds.unwrap().width() < wide - 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6); +} + +/// Bad custom character sets are refused with an error and no undo step. +#[test] +fn bad_custom_character_sets_are_refused() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets Base")]})).unwrap(); + let before = steps(&s); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = font_with_sets("CF Sets Refused"); + f(&mut v); + json!({"fonts": [v]}) + }; + let many: String = (0x4E00u32..).filter_map(char::from_u32).take(8193).collect(); + let sets: Vec = (0..65).map(|i| json!({"name": format!("S{i}")})).collect(); + for bad in [ + with(&|v| v["custom"] = json!("Marks")), + with(&|v| v["custom"] = Value::Null), + with(&|v| v["custom"][0]["characters"] = Value::Null), + with(&|v| v["custom"][0] = json!("Marks")), + with(&|v| v["custom"][0]["name"] = json!(" ")), + with(&|v| { + v["custom"][0].as_object_mut().unwrap().remove("name"); + }), + with(&|v| v["custom"][1]["name"] = json!("marks")), + with(&|v| v["custom"][0]["characters"] = json!(["※"])), + with(&|v| v["custom"][0]["characters"] = json!("※※")), + with(&|v| v["custom"][0]["characters"] = json!("A\n")), + with(&|v| v["custom"][0]["characters"] = json!("😀")), + with(&|v| v["custom"][0]["characters"] = json!(many.clone())), + with(&|v| v["custom"] = json!(sets.clone())), + with(&|v| v["custom"][0]["size"] = json!(5)), + with(&|v| v["custom"][0]["size"] = json!("50")), + with(&|v| v["custom"][0]["scaleAtCenter"] = json!(true)), + with(&|v| v["custom"][0]["scaleAtCenter"] = json!(false)), + with(&|v| v["custom"][0]["font"] = json!("")), + with(&|v| v["custom"][0]["font"] = json!("CF Sets Base")), + with(&|v| v["custom"][0]["width"] = json!(3)), + ] { + let e = s.execute("type.compositeFonts.set", &bad); + assert!(e.is_err(), "accepted {bad}"); + assert_eq!(names(&s), ["CF Sets Base"]); + assert_eq!(steps(&s), before); + } + // A set without characters is kept: it sets nothing. + let ok = with(&|v| v["custom"][0]["characters"] = json!("")); + s.execute("type.compositeFonts.set", &ok).unwrap(); + assert_eq!(s.doc().unwrap().doc.composite_fonts[0].custom[0].characters, ""); +} + +/// Export writes the custom character sets and Import reads them back; a file whose set is bad is +/// refused. +#[test] +fn custom_character_sets_export_and_import() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets IO")]})).unwrap(); + let data = s.execute("type.compositeFonts.export", &json!({})).unwrap(); + let mut t = session(); + t.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap(); + assert_eq!(*t.doc().unwrap().doc.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + let text = data["data"].as_str().unwrap().replace("\"A only\"", "\"marks\""); + let mut u = session(); + assert!(u.execute("type.compositeFonts.import", &json!({"data": text})).is_err()); + assert!(names(&u).is_empty()); + // Fonts whose file would be larger than Import reads are refused. + let full: String = (0x4E00u32..).filter_map(char::from_u32).take(8192).collect(); + let sets: Vec = (0..64).map(|i| json!({"name": format!("S{i}"), "characters": full})).collect(); + let big: Vec = (0..3) + .map(|i| { + let mut f = font(&format!("CF Sets Big {i}")); + f["custom"] = json!(sets); + f + }) + .collect(); + let e = s.execute("type.compositeFonts.export", &json!({"fonts": big})).unwrap_err(); + assert!(e.to_string().contains("4 MB"), "{e}"); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index ebef729fd3..08944940a2 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Flare", [], None, - "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?} → {id}", + "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?, ringSeed? (whole number below 2^53: places the rings along the path anew, keeping their count and sizes)} → {id}", has_doc, flare ), @@ -429,6 +429,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { "direction", "pathLength", "seed", + "ringSeed", ]; check_numbers(p, &numbers, "shape.flare")?; let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0); @@ -450,14 +451,28 @@ fn flare(s: &mut Session, p: &Value) -> Result { c + vectorcraft_geom::Vec2::new(a.cos(), -a.sin()) * f64_or(p, "pathLength", 300.0) } }; - // Deterministic pseudo-random sizes (same parameters → same flare). - let mut seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0x2545_f491_4f6c_dd1d) | 1; - let mut rnd = move || { - seed ^= seed << 13; - seed ^= seed >> 7; - seed ^= seed << 17; - (seed >> 11) as f64 / (1u64 << 53) as f64 + // Deterministic pseudo-random sizes (same parameters → same flare). `ringSeed` places the rings + // along the path from a sequence of its own; their count and sizes and the other parts stay. + let seed_of = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| bad("shape.flare", format!("`{key}` must be a whole number of at least 0, not {v}"))), }; + // The generator's state is odd (never 0). + let random = |mut state: u64| { + move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 11) as f64 / (1u64 << 53) as f64 + } + }; + let mut rnd = random(seed_of("seed")?.unwrap_or(0x2545_f491_4f6c_dd1d) | 1); + // Each `ringSeed` below 2^53 (what a JSON number holds exactly) gets a state of its own. + let ring_seed = match seed_of("ringSeed")? { + Some(r) if r >= 1 << 53 => return Err(bad("shape.flare", format!("`ringSeed` must be below 2^53, not {r}"))), + r => r, + }; + let mut ring_at = ring_seed.map(|r| random((r << 1) | 1)); let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) }; let radial = |centre: Point, radius: f64, stops: Vec| { let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops)); @@ -495,6 +510,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { // Rings along the path to the end point (Largest = % of the average ring size). for _ in 0..rings { let t = rnd(); + let t = ring_at.as_mut().map_or(t, |next| next()); let at = c + (end - c) * t; let rr = (r * largest * (0.25 + rnd() * 0.75)).max(0.5); let solid = rnd() < 0.5; diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index 48f2c31e62..43f3aec881 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -356,7 +356,8 @@ fn mirror_open(sp: &SubPath, axis: &Axis, m: Affine) -> Vec { if !end_on { run.reverse(); } - let Some((last, body)) = run.anchors.split_last() else { return vec![] }; + let run = run.anchors.into_vec(); + let Some((last, body)) = run.split_last() else { return vec![] }; let q = axis.project(last.p); let mut anchors = body.to_vec(); anchors.push(Anchor::with_handles(q, last.h_in, m * last.h_in)); diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index ae48ba02e6..3b1e8a7666 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -10,6 +10,7 @@ use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection, use vectorcraft_geom::Vec2; use super::edit::selected_roots; +use super::panelcmds::artboard_at; use super::pathops::Beside; use super::select::{first_selectable, is_selectable, selectable_ids, walk_selectable}; use super::{ @@ -201,7 +202,7 @@ pub fn specs() -> Vec { "Add Guide", [], None, - "{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}", + "{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (0-based whole number; an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default, or null, a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}", has_doc, guide_add ), @@ -767,15 +768,8 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result { fn guide_add(s: &mut Session, p: &Value) -> Result { let pos = f64_req(p, "pos", "guide.add")?; let vertical = switch_or(p, "vertical", false, "guide.add")?; - let artboard = match p.get("artboard").filter(|v| !v.is_null()) { - Some(v) => { - let boards = &s.doc()?.doc.artboards; - let ab = v.as_u64().and_then(|i| boards.get(usize::try_from(i).ok()?)).ok_or_else(|| EngineError::Other("no such artboard".into()))?; - Some(ab.id) - } - None => None, - }; let st = s.doc()?; + let artboard = artboard_at(&st.doc, p, "artboard", "guide.add")?.and_then(|i| st.doc.artboards.get(i)).map(|a| a.id); let layer = match id_param(p, "layer") { Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l), Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")), diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index d469c7a613..9055e83844 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -33,3 +33,4 @@ mod smooth; pub use commands::specs; pub(super) use common::{ids_json, replace_with_pieces, usize_opt}; pub(super) use cut::{components, erase_outline}; +pub(super) use freehand::freehand_brushed; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 401bdd2977..977f3f921a 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -46,7 +46,7 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { let sp = vectorcraft_tools::draw2::catmull_rom(&marked, false); if sp.anchors.len() < 3 { // Two anchors closed would run back over the line itself. - return SubPath::new(sp.anchors, closed && n > 2); + return SubPath::of(sp.anchors, closed && n > 2); } let fitted = po::simplify_with( &PathData::single(sp.clone()), @@ -62,7 +62,7 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath { f.h_in = f.p; } } - SubPath::new(anchors, closed) + SubPath::of(anchors, closed) } /// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points @@ -133,13 +133,13 @@ fn redraw_section(pd: &PathData, pts: &[Point], tol: Option, within: f64) - *s0 = s1.p; let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None }; *e0 = e1.p; - let fit = stroke_subpath(&stroke, tol, false).anchors; + let fit = stroke_subpath(&stroke, tol, false).anchors.into_vec(); let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None }; let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default(); let (before, after): (Vec, Vec) = if sp.closed { ((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![]) } else { - (sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec()) + (sp.anchors.get_range(..=start)?, sp.anchors.get_range(end..)?) }; let mut anchors = before; if let Some(a) = anchors.last_mut() { @@ -157,6 +157,34 @@ fn redraw_section(pd: &PathData, pts: &[Point], tol: Option, within: f64) - } pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { + freehand_brushed(s, p, None) +} + +/// The object a brush on path `id` paints: the compound path a member path is in, else the path. +fn brush_target(d: &Document, id: NodeId) -> NodeId { + d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id) +} + +/// Whether `ap` has a stroke a brush can paint: a stroke whose paint is not None. +fn can_brush(ap: &Appearance) -> bool { + ap.stroke().is_some_and(|st| !st.paint.is_none()) +} + +fn no_stroke(name: &str) -> EngineError { + bad("brush.freehand", format!("cannot apply brush `{name}`: path has no stroke")) +} + +/// Sets brush `name` on the stroke of object `id` of `d`. +fn set_brush(d: &mut Document, id: NodeId, name: &str) { + if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) { + st.brush = Some(name.to_string()); + } +} + +/// `path.freehand`, with the stroke painted with brush `brush_name` (one `brush.freehand` has +/// checked) in the same undo step as the path. A path without a stroke is an error before +/// anything changes. +pub(crate) fn freehand_brushed(s: &mut Session, p: &Value, brush_name: Option<&str>) -> Result { const C: &str = "path.freehand"; let samples = samples_param(p, "points", C)?; if samples.iter().any(|q| !within_input_bound(q.pt)) { @@ -173,6 +201,8 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol); let closed = switch_or(p, "closed", false, C)?; let brush = str_param(p, "style") == Some("brush"); + // The undo step's name: a stroke painted with a brush is a Paintbrush stroke, whatever its style. + let label = if brush || brush_name.is_some() { "Paintbrush" } else { "Pencil" }; let keep_selected = switch_or(p, "keepSelected", true, C)?; let filled = switch_or(p, "fill", false, C)?; if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { @@ -180,6 +210,12 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let at_start = str_param(ext, "end") == Some("start"); let (pd, _) = path_of(&s.doc()?.doc, id)?; let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; + let target = brush_target(&s.doc()?.doc, id); + if let Some(name) = brush_name + && !s.doc()?.doc.node(target).is_some_and(|n| can_brush(&n.appearance)) + { + return Err(no_stroke(name)); + } let mut sp = pd.subpaths[si].clone(); let old_len = subpath_len(&sp); if at_start { @@ -191,6 +227,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) }; *p0 = end; if pts.len() < 2 { + // No stroke to add: the brush alone changes, when it is not the path's already. + if let Some(name) = brush_name + && s.doc()?.doc.node(target).and_then(|n| n.appearance.stroke()).is_some_and(|st| st.brush.as_deref() != Some(name)) + { + s.edit("Paintbrush", |d, _| { + set_brush(d, target, name); + Ok(()) + })?; + } return Ok(json!({ "id": id.0 })); } // A stroke that ends on an open end of another path connects the two: (that path, its last @@ -271,12 +316,12 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { // outgoing handle. let mut other_len = 0.0; if let Some((_, _, y, _)) = &connect - && let Some((y0, rest)) = y.anchors.split_first() + && let Some(y0) = y.anchors.first() { if let Some(last) = sp.anchors.last_mut() { *last = Anchor::with_handles(y0.p, last.h_in + (y0.p - last.p), y0.h_out); } - sp.anchors.extend_from_slice(rest); + sp.anchors.extend(y.anchors.iter().skip(1)); other_len = subpath_len(y); } // What the pen recorded along the path (in the direction it is continued in), followed by @@ -290,7 +335,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let drawn = old.extended(old_len, &pen, drawn_len - old_len); if connect.is_some() { drawn.extended(drawn_len, &other, other_len) } else { drawn } }); - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + s.edit(label, |d, sel| { let reverse = at_start && !sp.closed; if reverse { sp.reverse(); @@ -326,6 +371,9 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { { if reverse { joined.reversed() } else { joined }.write(st); } + if let Some(name) = brush_name { + set_brush(d, target, name); + } if keep_selected { sel.set([id]); } else { @@ -346,11 +394,19 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { // were, measured along the old path; fit them to the new length if strokes drawn with a // pen get redrawn. if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) { - s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| { + // A member path of a compound path is redrawn in it: the compound path is what is + // selected, and what a brush paints. + let whole = brush_target(&s.doc()?.doc, id); + if let Some(name) = brush_name + && !s.doc()?.doc.node(whole).is_some_and(|n| can_brush(&n.appearance)) + { + return Err(no_stroke(name)); + } + s.edit(label, |d, sel| { *path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp; - // A member path of a compound path is redrawn in it: the compound path is what is - // selected. - let whole = d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id); + if let Some(name) = brush_name { + set_brush(d, whole, name); + } if keep_selected { sel.set([whole]); } else { @@ -373,7 +429,15 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { if let Some(st) = look.appearance.stroke_mut() { pen.write(st); } - let out = insert_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None, keep_selected)?; + if let Some(name) = brush_name { + if !can_brush(&look.appearance) { + return Err(no_stroke(name)); + } + if let Some(st) = look.appearance.stroke_mut() { + st.brush = Some(name.to_string()); + } + } + let out = insert_look(s, label, path_kind(PathData::single(sp)), look, None, keep_selected)?; if !keep_selected { // A new stroke with Keep selected off leaves nothing selected: Illustrator starts the stroke // by dropping the earlier selection, it does not keep it (#5752). diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index be7db9a1ae..883c9ba5ed 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -274,7 +274,7 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec Result { return Err(bad(C, format!("{k} is too long (at most {MAX_TEXT} characters)"))); } if s.chars().any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\r' | '\t'))) { - return Err(bad(C, format!("{k} has control characters{}", if multiline { "" } else { " or line breaks" }))); + return Err(if multiline { + bad(C, format!("{k} has control characters")) + } else { + bad(C, format!("{k} has control characters or line breaks")) + }); } Ok(s.to_string()) } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 28cd71804a..fc5be365d5 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -54,6 +54,7 @@ pub mod ppi; pub mod progress; mod psdlayers; pub mod psdread; +mod psdslices; mod pxrread; mod save; mod screens; @@ -93,6 +94,9 @@ pub(crate) use native::preview_png; pub use native::with_compression_pref; pub(crate) use pdfimport::opened_page_document; pub use pdfimport::{LoadOptions, page_document}; +pub(crate) use psdlayers::import_active as psd_layers; +pub use psdlayers::{PsdInfo, info as psd_info}; +pub(crate) use psdslices::over as psd_slices; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ @@ -240,6 +244,8 @@ mod tests_psd; mod tests_psdlayers; #[cfg(test)] mod tests_psdread; +#[cfg(test)] +mod tests_psdslices; #[cfg(test)] mod tests_heifread; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 27024ef8eb..4a3ca6265a 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -1,6 +1,6 @@ use super::access::file_list; use super::formats::formats; -use super::{CommandSpec, action_batch, always, batch, cmd, export, has_doc, has_selection, load, pdfimport, save, screens}; +use super::{CommandSpec, action_batch, always, batch, cmd, export, has_doc, has_selection, load, pdfimport, psdlayers, save, screens}; pub fn specs() -> Vec { vec![ @@ -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), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a 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", + "{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), importSlices?: true (a PSD file's slices become user slices over its canvas, with its layers or its merged image; slices the file generated automatically are left out, and slices that can't be read are left out with a warning), 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, through the profiles the document keeps after the color management policies of edit.colorSettings, 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), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a 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}]}; the color management policies of edit.colorSettings keep, take off or convert from the profiles the file is tagged with, their notes go to warnings, and a document they changed opens with unsaved changes; 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 ), @@ -139,6 +139,15 @@ pub fn specs() -> Vec { always, pdfimport::pdf_info ), + cmd!( + query "document.psdInfo", + "PSD Info", + [], + None, + "{path} or {name?, dataBase64} what a PSD or PSB file holds, read from its header, slices and layer records without decoding pixels → {format: psd|psb, width, height (the canvas, px), layers (group markers not counted), hiddenLayers (the hidden ones among them), groups, slices (those importSlices brings in; 0 when the file has none or they can't be read)}; a file without layer records, or whose layer records can't be read, has layers: 0 and opens and places as its merged image; a file that is not a PSD or PSB file is an error", + always, + psdlayers::psd_info + ), cmd!( "document.exportForOffice", "Save for Office Documents…", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 60aace2351..52ed7f0e7f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -393,8 +393,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result return Err(err(format!("can't open `{}`: {}", file_name(name), crate::cmd::place::dcs::LINK_ONLY))); } let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); + // A `.ai` keeps the artboard its header gives; an EPS comes in on its page. + let import = if matches!(format.id, "ai" | "ait") { vectorcraft_eps::import_ai } else { vectorcraft_eps::import_names }; restore_or_import(editing, || { - let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) + let r = import(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) .map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; Ok((r.document, r.warnings)) })? @@ -526,17 +528,26 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } // A PSD or PSB file's layers become layers (`layers`, default on); without layers, or with // `layers: false`, its merged image opens. Layers that can't be read leave the merged image. - "psd" | "psb" if opts.layers => { - let cms = vectorcraft_color::cms::active(); - match super::psdlayers::import(bytes, opts.hidden_layers, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)) { - Ok(Some((doc, warnings))) => (doc, warnings, false), - Ok(None) => (raster_doc(&title, bytes)?, vec![], false), - Err(e) => (raster_doc(&title, bytes)?, vec![format!("the layers can't be read ({e}): the file opened as its merged image")], false), - } - } + "psd" | "psb" if opts.layers => match super::psdlayers::import_active(bytes, opts.hidden_layers) { + Ok(Some((doc, warnings))) => (doc, warnings, false), + Ok(None) => (raster_doc(&title, bytes)?, vec![], false), + Err(e) => (raster_doc(&title, bytes)?, vec![format!("the layers can't be read ({e}): the file opened as its merged image")], false), + }, _ if format.raster => (raster_doc(&title, bytes)?, vec![], false), _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; + // A PSD or PSB file's slices become user slices over its canvas (`importSlices`, default on), + // whether its layers or its merged image came in. + if matches!(format.id, "psd" | "psb") + && opts.import_slices + && let Some(canvas) = doc.artboards.first().map(|a| a.rect) + { + let (slices, notes) = super::psd_slices(bytes, canvas); + for (rect, options) in slices { + crate::cmd::slices::add_slice(&mut doc, rect, options); + } + warnings.extend(notes); + } // A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document // it carries, opens with the File Info it gives (a PostScript `.ai` gives none). if matches!(format.id, "pdf" | "ai" | "ait") @@ -658,6 +669,17 @@ pub struct ReadFile { path: Option, /// What became of its linked files. links: Resolved, + /// Whether the color management policies changed the document ([`apply_policies`]). + policy_changed: bool, +} + +/// Edit › Color Settings: the color management policies keep, take off or convert from the +/// profiles the document `loaded` holds is tagged with, and their notes go to its warnings → +/// whether they changed the document. +fn apply_policies(loaded: &mut Loaded) -> bool { + let (notes, changed) = crate::cmd::colormgmt::apply_open_policies(&mut loaded.doc); + loaded.warnings.extend(notes); + changed } #[cfg(test)] @@ -678,17 +700,35 @@ impl ReadFile { /// ([`super::importer`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let mut loaded = if opts.link_pages { - linked_pages(name, bytes, path.as_deref(), &opts)? + let (mut loaded, policy_changed) = if opts.link_pages { + let mut loaded = linked_pages(name, bytes, path.as_deref(), &opts)?; + let changed = apply_policies(&mut loaded); + (loaded, changed) } else { match super::importer::pick(name, bytes, p)? { - Some(plugin) => super::importer::load(name, bytes, &plugin, p)?, - None => load_with(name, bytes, &opts)?, + Some(plugin) => { + let mut loaded = super::importer::load(name, bytes, &plugin, p)?; + let changed = apply_policies(&mut loaded); + (loaded, changed) + } + None => { + // The color mode `p` asks for converts the colors after the policies did, through + // the profiles the document keeps. + let mut loaded = load_with(name, bytes, &LoadOptions { color_mode: None, ..opts.clone() })?; + let changed = apply_policies(&mut loaded); + if let Some(mode) = opts.color_mode.filter(|m| *m != loaded.doc.color_mode) { + let doc = &mut loaded.doc; + vectorcraft_color::cms::with_active(super::super::colormgmt::doc_cms(doc), || { + super::super::colormgmt::set_color_mode(doc, mode, true, None, opts.grays); + }); + } + (loaded, changed) + } } }; let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); - Ok(ReadFile { loaded, opts, path, links }) + Ok(ReadFile { loaded, opts, path, links, policy_changed }) } /// The PDF `name` (`bytes`, read from `path`) opened with `linkPages`: its pages as placed documents @@ -703,7 +743,7 @@ fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, opts: &LoadOptions /// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active /// document → `document.open`'s result. pub fn open_read(s: &mut Session, file: ReadFile) -> Result { - open_loaded(s, file.loaded, file.links, file.path, &file.opts, false) + open_loaded(s, file.loaded, file.links, file.path, &file.opts, false, file.policy_changed) } /// File → New from Template: any readable file (at `path`, if it is one: its links are looked for @@ -715,14 +755,24 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st "only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first", )); } + let policy_changed = apply_policies(&mut loaded); let base = crate::cmd::links::LinkBase::file(path); let links = crate::cmd::links::resolve(&mut loaded.doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); - open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true) + open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true, policy_changed) } -/// Make a loaded file the new active document; `links` is what became of its linked files, and -/// `opts` are the options it was read with. -fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { +/// Make a loaded file the new active document; `links` is what became of its linked files, `opts` +/// are the options it was read with, and `policy_changed` says whether the color management +/// policies changed it (it then opens with unsaved changes). +fn open_loaded( + s: &mut Session, + loaded: Loaded, + links: Resolved, + path: Option, + opts: &LoadOptions, + as_template: bool, + policy_changed: bool, +) -> Result { let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing @@ -763,6 +813,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option Document { - let mut d = if version < vectorcraft_format::VERSION && doc.has_placed() { - let mut d = doc.with_placed_art().into_owned(); - d.placed_as_groups(); - d - } else { - doc.clone() - }; + let mut d = doc.clone(); + if version < vectorcraft_format::VERSION { + vectorcraft_format::placed_as_groups(&mut d); + } d.round_geometry(decimals.min(vectorcraft_format::MAX_PRECISION)); d } diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 00eef89a11..189274a31f 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -36,6 +36,8 @@ pub struct LoadOptions { pub layers: bool, /// A PSD or PSB file's hidden layers come in, hidden (`hiddenLayers`; else they are left out). pub hidden_layers: bool, + /// A PSD or PSB file's slices become user slices (`importSlices`; else they are left out). + pub import_slices: bool, /// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]). pub dxf: vectorcraft_cad::ImportOptions, /// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its @@ -67,6 +69,7 @@ impl Default for LoadOptions { text_as: TextAs::default(), layers: true, hidden_layers: false, + import_slices: true, dxf: Default::default(), editing_data: true, trust_editing_data: false, @@ -81,7 +84,7 @@ impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`, /// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false), - /// `editingData` (true | false | `always`), + /// `importSlices` (true | false), `editingData` (true | false | `always`), /// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]), `linkPages` (true | /// false) and `dxf` (the DXF import options). pub fn from_params(cmd: &str, p: &Value) -> Result { @@ -111,6 +114,10 @@ impl LoadOptions { None => false, Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "hiddenLayers must be true or false"))?, }; + let import_slices = match p.get("importSlices").filter(|v| !v.is_null()) { + None => true, + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "importSlices must be true or false"))?, + }; let (editing_data, trust_editing_data) = match p.get("editingData").filter(|v| !v.is_null()) { None => (true, false), Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true), @@ -137,6 +144,7 @@ impl LoadOptions { text_as, layers, hidden_layers, + import_slices, dxf, editing_data, trust_editing_data, diff --git a/crates/engine/src/cmd/fileio/psdlayers.rs b/crates/engine/src/cmd/fileio/psdlayers.rs index c3c45029a0..ea5cf63aa2 100644 --- a/crates/engine/src/cmd/fileio/psdlayers.rs +++ b/crates/engine/src/cmd/fileio/psdlayers.rs @@ -43,6 +43,7 @@ use std::cell::Cell; use std::sync::Arc; use image::RgbaImage; +use serde_json::{Value, json}; use vectorcraft_color::BlendMode; use vectorcraft_color::blend::{blend_rgb, composite}; use vectorcraft_color::cms::{Lab, lab::lab_to_srgb}; @@ -51,6 +52,7 @@ use vectorcraft_geom::Affine; use super::load::{RasterImage, as_png}; use super::psdread::{Header, Reader, byte, header, layer_channel, layer_info, long_key, row_bytes, sample, to_rgb}; +use crate::cmd::Session; use crate::cmd::place::text::mac_roman; /// Most layer groups inside one another. @@ -1527,3 +1529,65 @@ pub(super) fn import( doc.limit_depth_noting(&mut warnings); Ok(Some((doc, warnings))) } + +/// [`import`] within the memory allowed for one image, CMYK converted with the active color +/// settings: what `document.open` and `file.place` read a PSD or PSB file's layers with. +pub(crate) fn import_active(bytes: &[u8], hidden: bool) -> Result)>, String> { + let cms = vectorcraft_color::cms::active(); + import(bytes, hidden, super::MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)) +} + +/// What a PSD or PSB file holds (`document.psdInfo`, the PSD Import Options dialog). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PsdInfo { + /// The large document format. + pub psb: bool, + /// The canvas, in pixels. + pub width: u32, + pub height: u32, + /// The layers, group markers left out. + pub layers: usize, + /// The layers among them that are hidden. + pub hidden: usize, + /// The layer groups. + pub groups: usize, + /// The slices that come in with `importSlices` (those generated automatically, and those + /// outside the canvas, left out). + pub slices: usize, +} + +/// What PSD or PSB file `bytes` holds, read from its header, its slices and its layer records +/// without decoding any pixels. A file without layer records, or whose layer records can't be read, has no layers: it +/// opens and places as its merged image. +pub fn info(bytes: &[u8]) -> Result { + let mut r = Reader::new(bytes); + let h = header(&mut r)?; + let records = layer_info(h.layers, h.psb).filter(|info| info.len() > 2).and_then(|info| records(info, h.psb).ok()).unwrap_or_default(); + let layers = records.iter().filter(|r| r.divider == 0); + Ok(PsdInfo { + psb: h.psb, + width: h.width, + height: h.height, + layers: layers.clone().count(), + hidden: layers.filter(|r| r.hidden).count(), + groups: records.iter().filter(|r| matches!(r.divider, 1 | 2)).count(), + slices: super::psdslices::count(bytes), + }) +} + +const INFO: &str = "document.psdInfo"; + +/// `document.psdInfo`. +pub(super) fn psd_info(_: &mut Session, p: &Value) -> crate::cmd::Result { + let bytes = super::source(p, INFO)?.bytes; + let i = info(&bytes).map_err(super::load::err)?; + Ok(json!({ + "format": if i.psb { "psb" } else { "psd" }, + "width": i.width, + "height": i.height, + "layers": i.layers, + "hiddenLayers": i.hidden, + "groups": i.groups, + "slices": i.slices, + })) +} diff --git a/crates/engine/src/cmd/fileio/psdread.rs b/crates/engine/src/cmd/fileio/psdread.rs index 28b44d6197..ed9f08a731 100644 --- a/crates/engine/src/cmd/fileio/psdread.rs +++ b/crates/engine/src/cmd/fileio/psdread.rs @@ -105,7 +105,7 @@ pub(super) struct Header<'a> { /// Colour mode data: an indexed image's palette (256 reds, then greens, then blues). palette: &'a [u8], /// The image resources. - resources: &'a [u8], + pub(super) resources: &'a [u8], /// The layer and mask information. pub(super) layers: &'a [u8], } @@ -172,7 +172,7 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { /// The image resource blocks of `resources`, in file order: each one's id, name (the bytes of its /// Pascal string) and data. Stops at the first block that is cut short. With `signed`, only the /// blocks whose signature is `8BIM` are given. -fn resources(resources: &[u8], signed: bool) -> impl Iterator { +pub(super) fn resources(resources: &[u8], signed: bool) -> impl Iterator { let mut r = Reader::new(resources); std::iter::from_fn(move || { if r.rest().is_empty() { diff --git a/crates/engine/src/cmd/fileio/psdslices.rs b/crates/engine/src/cmd/fileio/psdslices.rs new file mode 100644 index 0000000000..ea87f70797 --- /dev/null +++ b/crates/engine/src/cmd/fileio/psdslices.rs @@ -0,0 +1,408 @@ +//! The slices a Photoshop document (PSD or PSB) stores, for `document.open` and `file.place` +//! with `importSlices`: image resource 1050, read from the "Slices resource format" of Adobe's +//! public Photoshop File Formats Specification +//! (). Version 6 stores the slices +//! as records; versions 7 and 8 store them in a descriptor. The descriptor's keys, and the meaning +//! of the origin, type and background values, are read as the MIT-licensed ag-psd reads them +//! (, `src/imageResources.ts` and `src/descriptor.ts`), +//! which agrees with psd.rb (, +//! `lib/psd/resources/slices.rb`) on the keys both read. +//! +//! A slice the file marks as generated automatically is left out: the document lays out its own +//! auto slices around the others. A slice that follows a layer comes in as a user slice over the +//! rectangle the file stores for it. Each slice keeps its name, its type (Image or No Image), its +//! link, target, message, alternative text, cell text and background color; text alignment isn't +//! read. The file is untrusted: every read is checked, and the slice count, the strings and the +//! descriptor's nesting and item count are capped. + +use vectorcraft_doc::{SliceKind, SliceOptions}; +use vectorcraft_geom::Rect; + +use super::psdread::{Reader, TRUNCATED, header, resources}; + +/// The image resource that holds the slices. +const SLICES: u16 = 1050; + +/// The most slices one file brings in. +const MAX_SLICES: usize = 10_000; + +/// The deepest descriptor objects and lists nest. +const MAX_DEPTH: usize = 16; + +/// The most values the descriptors of one slices resource hold together, nested ones included. +const MAX_VALUES: usize = 200_000; + +/// The error of descriptors that hold more than [`MAX_VALUES`]. +const TOO_MANY_VALUES: &str = "the slices' descriptor holds too many values"; + +/// One slice as the file stores it. +#[derive(Clone, Debug, PartialEq)] +struct PsdSlice { + /// Left, top, right and bottom, in the canvas's pixels. + rect: [i64; 4], + options: SliceOptions, +} + +/// The slices a file stores, those generated automatically left out, and its canvas. +struct Stored { + slices: Vec, + /// The canvas's width and height, in pixels. + size: (u32, u32), +} + +/// The slices PSD or PSB file `bytes` stores. `None` when it stores none (no `8BIM` resource 1050 +/// before the image resources end or are cut short). An error when the file isn't a PSD or PSB +/// file, or its slices can't be read. +fn read(bytes: &[u8]) -> Result, String> { + let mut r = Reader::new(bytes); + let h = header(&mut r)?; + let Some((_, _, data)) = resources(h.resources, true).find(|(id, _, _)| *id == SLICES) else { return Ok(None) }; + Ok(Some(Stored { slices: decode(data)?, size: (h.width, h.height) })) +} + +/// The slices of resource 1050's `data`. +fn decode(data: &[u8]) -> Result, String> { + let mut r = Reader::new(data); + // The values every descriptor of the resource may hold together. + let mut budget = MAX_VALUES; + match r.u32()? { + 6 => version_6(&mut r, &mut budget), + 7 | 8 => { + if r.u32()? != 16 { + return Err("the slices' descriptor has a version other than 16".into()); + } + let d = descriptor(&mut r, 0, &mut budget)?; + from_descriptor(&d) + } + v => Err(format!("the slices are stored in version {v}, which isn't read")), + } +} + +/// Version 6: the bounds of all the slices, the group's name, the count, then each slice's record. +/// The descriptors that follow the records spend one `budget` of values between them. +fn version_6(r: &mut Reader<'_>, budget: &mut usize) -> Result, String> { + r.take(16)?; + unicode(r)?; + let count = r.u32()? as usize; + if count > MAX_SLICES { + return Err(format!("the file has {count} slices, more than the {MAX_SLICES} read")); + } + let mut out = vec![]; + for _ in 0..count { + // The ID and group ID. + r.take(8)?; + let origin = r.u32()?; + // A slice that follows a layer gives the layer's ID. + if origin == 1 { + r.take(4)?; + } + let name = unicode(r)?; + let kind = if r.u32()? == 0 { SliceKind::NoImage } else { SliceKind::Image }; + let mut rect = [0i64; 4]; + for v in &mut rect { + *v = i64::from(r.u32()? as i32); + } + let (url, target, message, alt) = (unicode(r)?, unicode(r)?, unicode(r)?, unicode(r)?); + let html = r.u8()? != 0; + let cell = unicode(r)?; + // The horizontal and vertical alignment. + r.take(8)?; + let [a, red, green, blue] = r.array::<4>()?; + let background = if a == 0 && red == 0 && green == 0 && blue == 0 { + String::new() + } else if a == 0 { + "matte".into() + } else { + format!("#{red:02x}{green:02x}{blue:02x}") + }; + // Photoshop 7 and later may follow a record with a descriptor (version 16); a record that + // doesn't start one is the next slice. A descriptor read that uses up the budget is an + // error. + if r.rest().starts_with(&16u32.to_be_bytes()) { + let mut ahead = Reader::new(r.rest()); + ahead.u32()?; + match descriptor(&mut ahead, 0, budget) { + Ok(_) => { + let used = r.rest().len() - ahead.rest().len(); + r.take(used)?; + } + Err(e) if e == TOO_MANY_VALUES => return Err(e), + Err(_) => {} + } + } + // Origin 0 is a slice generated automatically. + if origin != 0 { + let options = SliceOptions { kind, name, url, target, message, alt, text: cell_text(cell, html), background, ..Default::default() }; + out.push(PsdSlice { rect, options: for_kind(options) }); + } + } + Ok(out) +} + +/// Versions 7 and 8: the descriptor's `slices` list, each slice an object. +fn from_descriptor(d: &[(String, Value)]) -> Result, String> { + let Some(Value::List(list)) = field(d, "slices") else { return Err("the slices' descriptor has no list of slices".into()) }; + if list.len() > MAX_SLICES { + return Err(format!("the file has {} slices, more than the {MAX_SLICES} read", list.len())); + } + let mut out = vec![]; + for s in list { + let Value::Object(s) = s else { return Err("the slices' descriptor lists something other than a slice".into()) }; + if matches!(field(s, "origin"), Some(Value::Enum(_, v)) if v == "autoGenerated") { + continue; + } + let Some(Value::Object(b)) = field(s, "bounds") else { return Err("a slice in the slices' descriptor has no bounds".into()) }; + let edge = |k: &str| number(field(b, k)).ok_or_else(|| "a slice's bounds in the slices' descriptor aren't numbers".to_string()); + let rect = [edge("Left")?, edge("Top ")?, edge("Rght")?, edge("Btom")?]; + let text = |k: &str| match field(s, k) { + Some(Value::Text(t)) => t.clone(), + _ => String::new(), + }; + let kind = if matches!(field(s, "Type"), Some(Value::Enum(_, v)) if v == "noImage") { SliceKind::NoImage } else { SliceKind::Image }; + let html = matches!(field(s, "cellTextIsHTML"), Some(Value::Bool(true))); + let background = match field(s, "bgColorType") { + Some(Value::Enum(_, v)) if v == "matte" => "matte".into(), + Some(Value::Enum(_, v)) if v == "Clr " => match field(s, "bgColor") { + Some(Value::Object(c)) => { + let channel = |k: &str| number(field(c, k)).map(|v| v.clamp(0, 255)); + match (channel("Rd "), channel("Grn "), channel("Bl ")) { + (Some(r), Some(g), Some(b)) => format!("#{r:02x}{g:02x}{b:02x}"), + _ => String::new(), + } + } + _ => String::new(), + }, + _ => String::new(), + }; + let options = SliceOptions { + kind, + name: text("Nm "), + url: text("url"), + target: text("null"), + message: text("Msge"), + alt: text("altTag"), + text: cell_text(text("cellText"), html), + background, + ..Default::default() + }; + out.push(PsdSlice { rect, options: for_kind(options) }); + } + Ok(out) +} + +/// `o` with only the options of its slice type: an Image slice's cell text isn't shown, and a No +/// Image slice has no link, target, message or alternative text. +fn for_kind(mut o: SliceOptions) -> SliceOptions { + if o.kind == SliceKind::Image { + o.text.clear(); + } else { + for s in [&mut o.url, &mut o.target, &mut o.message, &mut o.alt] { + s.clear(); + } + } + o +} + +/// A cell's text as the HTML a No Image slice shows: plain text has `&`, `<` and `>` escaped. +fn cell_text(text: String, html: bool) -> String { + if html { text } else { text.replace('&', "&").replace('<', "<").replace('>', ">") } +} + +/// The longest text one string, key or class ID holds (UTF-16 units or bytes). +const MAX_TEXT: usize = 65_536; + +/// An error unless `n`, the length a string or ID declares, is at most [`MAX_TEXT`]. +fn text_len(n: usize) -> Result { + if n > MAX_TEXT { + return Err(format!("a text in the slices has more than the {MAX_TEXT} characters read")); + } + Ok(n) +} + +/// A Unicode string: a count of UTF-16 units, then the units; trailing NULs dropped. +fn unicode(r: &mut Reader<'_>) -> Result { + let n = text_len(r.u32()? as usize)?; + let bytes = r.take(n.checked_mul(2).ok_or(TRUNCATED)?)?; + let units: Vec = bytes.as_chunks::<2>().0.iter().map(|u| u16::from_be_bytes(*u)).collect(); + Ok(String::from_utf16_lossy(&units).trim_end_matches('\0').to_string()) +} + +/// A descriptor's value, as far as the slices need it. +#[derive(Clone, Debug, PartialEq)] +enum Value { + Long(i32), + Double(f64), + Bool(bool), + Text(String), + /// An enumeration's type and value. + Enum(String, String), + Object(Vec<(String, Value)>), + List(Vec), + /// A value of another type, read past. + Other, +} + +/// The value with key `key`. +fn field<'a>(d: &'a [(String, Value)], key: &str) -> Option<&'a Value> { + d.iter().find(|(k, _)| k == key).map(|(_, v)| v) +} + +/// A whole number: a long, or a finite double rounded. +fn number(v: Option<&Value>) -> Option { + match v? { + Value::Long(n) => Some(i64::from(*n)), + Value::Double(d) if d.is_finite() && d.abs() < 1e12 => Some(d.round() as i64), + _ => None, + } +} + +/// A key or class ID: a length, then that many characters, or four when the length is 0. +fn id(r: &mut Reader<'_>) -> Result { + let n = text_len(r.u32()? as usize)?; + let b = r.take(if n == 0 { 4 } else { n })?; + Ok(String::from_utf8_lossy(b).into_owned()) +} + +/// A class: its Unicode name, then its ID. +fn class(r: &mut Reader<'_>) -> Result<(), String> { + unicode(r)?; + id(r)?; + Ok(()) +} + +/// A descriptor: its class, the count of its items, then each item's key, type and value. +fn descriptor(r: &mut Reader<'_>, depth: usize, budget: &mut usize) -> Result, String> { + if depth > MAX_DEPTH { + return Err("the slices' descriptor nests too deep".into()); + } + class(r)?; + let count = r.u32()? as usize; + let mut out = vec![]; + for _ in 0..count { + let key = id(r)?; + let kind = r.array::<4>()?; + out.push((key, value(r, &kind, depth, budget)?)); + } + Ok(out) +} + +/// A value of type `kind`. +fn value(r: &mut Reader<'_>, kind: &[u8; 4], depth: usize, budget: &mut usize) -> Result { + *budget = budget.checked_sub(1).ok_or(TOO_MANY_VALUES)?; + Ok(match kind { + b"long" => Value::Long(r.u32()? as i32), + b"doub" => Value::Double(f64::from_be_bytes(r.array()?)), + b"UntF" => { + r.take(4)?; + Value::Double(f64::from_be_bytes(r.array()?)) + } + b"UnFl" => { + r.take(4)?; + Value::Double(f64::from(f32::from_be_bytes(r.array()?))) + } + b"bool" => Value::Bool(r.u8()? != 0), + b"TEXT" => Value::Text(unicode(r)?), + b"enum" => Value::Enum(id(r)?, id(r)?), + b"Objc" | b"GlbO" => Value::Object(descriptor(r, depth + 1, budget)?), + b"VlLs" => { + if depth >= MAX_DEPTH { + return Err("the slices' descriptor nests too deep".into()); + } + let count = r.u32()? as usize; + let mut items = vec![]; + for _ in 0..count { + let kind = r.array::<4>()?; + items.push(value(r, &kind, depth + 1, budget)?); + } + Value::List(items) + } + b"comp" => { + r.take(8)?; + Value::Other + } + b"type" | b"GlbC" => { + class(r)?; + Value::Other + } + b"tdta" | b"alis" => { + let n = r.u32()? as usize; + r.take(n)?; + Value::Other + } + b"obj " => { + let count = r.u32()? as usize; + for _ in 0..count { + *budget = budget.checked_sub(1).ok_or(TOO_MANY_VALUES)?; + match &r.array::<4>()? { + b"prop" => { + class(r)?; + id(r)?; + } + b"Clss" => class(r)?, + b"Enmr" => { + class(r)?; + id(r)?; + id(r)?; + } + b"rele" => { + class(r)?; + r.take(4)?; + } + b"Idnt" | b"indx" => { + r.take(4)?; + } + b"name" => { + class(r)?; + unicode(r)?; + } + k => return Err(format!("the slices' descriptor has a reference of an unknown kind ({})", String::from_utf8_lossy(k))), + } + } + Value::Other + } + k => return Err(format!("the slices' descriptor has a value of an unknown type ({})", String::from_utf8_lossy(k))), + }) +} + +/// The slices of PSD or PSB file `bytes` laid over `canvas`, the box its canvas takes (points), +/// each clipped to the canvas, with warnings: for slices that lie wholly outside it or have no +/// size, and for slices that can't be read (the file comes in without them). Empty, without a +/// warning, when the file stores no slices. +pub(crate) fn over(bytes: &[u8], canvas: Rect) -> (Vec<(Rect, SliceOptions)>, Vec) { + match read(bytes) { + Ok(None) => (vec![], vec![]), + Ok(Some(s)) => laid_over(&s.slices, s.size, canvas), + Err(e) => (vec![], vec![format!("the slices can't be read ({e}) and were left out")]), + } +} + +/// `slices` of a canvas of `size` pixels laid over `canvas` (points). +fn laid_over(slices: &[PsdSlice], (w, h): (u32, u32), canvas: Rect) -> (Vec<(Rect, SliceOptions)>, Vec) { + let (sx, sy) = (canvas.width() / f64::from(w.max(1)), canvas.height() / f64::from(h.max(1))); + let mut out = vec![]; + for s in slices { + let [l, t, r, b] = s.rect; + let (l, r) = (l.clamp(0, i64::from(w)), r.clamp(0, i64::from(w))); + let (t, b) = (t.clamp(0, i64::from(h)), b.clamp(0, i64::from(h))); + if l < r && t < b { + let x = |v: i64| canvas.x0 + v as f64 * sx; + let y = |v: i64| canvas.y0 + v as f64 * sy; + out.push((Rect::new(x(l), y(t), x(r), y(b)), s.options.clone())); + } + } + let warnings = match slices.len() - out.len() { + 0 => vec![], + 1 => vec!["1 slice lies outside the canvas or has no size and was left out".to_string()], + n => vec![format!("{n} slices lie outside the canvas or have no size and were left out")], + }; + (out, warnings) +} + +/// How many slices of PSD or PSB file `bytes` come in (`document.psdInfo`): 0 when it stores none +/// or they can't be read. +pub(crate) fn count(bytes: &[u8]) -> usize { + match read(bytes) { + Ok(Some(Stored { slices, size: (w, h) })) => laid_over(&slices, (w, h), Rect::new(0.0, 0.0, f64::from(w), f64::from(h))).0.len(), + _ => 0, + } +} diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 2799d34b7a..867b9f9f87 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1239,14 +1239,23 @@ fn revert(s: &mut Session, _: &Value) -> Result { let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?; let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); doc.template = false; + // The color management policies apply as when the file is opened. + let (notes, policy_changed) = crate::cmd::colormgmt::apply_open_policies(&mut doc); // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); + if policy_changed { + s.doc_mut()?.mark_unsaved(); + } if let Some(pdf) = native_pdf_compatible { s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf)); } - Ok(json!({ "path": path })) + let mut reply = json!({ "path": path }); + if !notes.is_empty() { + reply["warnings"] = json!(notes); + } + Ok(reply) } /// `file.formatOptions`: a writable format's options with the values Save would use. @@ -1345,7 +1354,7 @@ pub(super) fn specs() -> Vec { "New from Template…", ["File"], Some("Cmd+Shift+N"), - "{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document → {index, title, format, warnings}", + "{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document; the color management policies of edit.colorSettings apply as at document.open → {index, title, format, warnings (with the policies' notes)}", always, super::load::new_from_template ), @@ -1354,7 +1363,7 @@ pub(super) fn specs() -> Vec { "Revert", ["File"], Some("F12"), - "{confirmed?: bool (the app's own: it asks first, then runs the command with it)} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true", + "{confirmed?: bool (the app's own: it asks first, then runs the command with it)} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path, warnings?: the notes of the color management policies of edit.colorSettings, which apply as at document.open}. The app asks first (a confirm dialog) unless confirmed: true", can_revert, revert ), diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index b66b22d1a8..bbf6883834 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -202,6 +202,25 @@ fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() { } } +/// An opaque 16-bit CMYK TIFF's inks are rounded to the nearest 8-bit level, chunky and planar, and +/// the stored image keeps its 16-bit samples. +#[test] +fn an_opaque_16_bit_cmyk_tiffs_inks_are_rounded_to_8_bits() { + let c = vec![0u16, 200, 0x80FF, 0x8100, 65535, 32896]; + let m = vec![511u16, 65535, 0, 128, 129, 0]; + let none = vec![0u16; 6]; + let channels = [c, m, none.clone(), none]; + let want: Vec = [[0, 2, 0, 0], [1, 255, 0, 0], [128, 0, 0, 0], [128, 0, 0, 0], [255, 1, 0, 0], [128, 0, 0, 0]].concat(); + let chunky_bytes = hand_tiff(5, 16, &[], false, &channels); + let chunky = raster_image(&chunky_bytes).unwrap(); + assert_eq!(chunky.blob.cmyk().map(|i| i.data), Some(want.clone()), "chunky: the inks"); + assert_eq!(*chunky.blob.bytes, chunky_bytes, "chunky: the file is stored as it is"); + let planar = raster_image(&hand_tiff(5, 16, &[], true, &channels)).unwrap(); + assert_eq!(planar.blob.cmyk().map(|i| i.data), Some(want), "planar: the inks"); + let stored = image::load_from_memory(&planar.blob.bytes).unwrap(); + assert_eq!(stored.color(), image::ColorType::Rgb16, "planar: the stored image keeps 16-bit samples"); +} + /// A CMYK or grayscale TIFF whose alpha follows another extra sample opens and places with that /// alpha, at 8 and 16 bits, chunky and planar; a CMYK one is not kept as inks. A CMYK one with two /// more samples than its ExtraSamples values count does too. diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 8b588975c4..78cca92873 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -966,7 +966,7 @@ fn an_ai_file_opens_its_variable_library() { {"name": "v_file", "kind": "image", "bindings": 0}, {"name": "v_graph", "kind": "graph", "bindings": 0}, {"name": "v_unbound", "kind": "unknown", "bindings": 0}, - ], "locked": true}) + ], "locked": true, "sort": {"by": "none", "descending": false}}) ); let r = s.execute("dataset.list", &json!({})).unwrap(); assert_eq!(r["active"], "ds_two", "{r}"); diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 59caf85d6e..6245a001bd 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -277,3 +277,17 @@ fn ps_and_epsf_files_open_and_place() { let e = writable_format(None, Some("out.ps")).unwrap_err(); assert_eq!(e, "PostScript files can be opened but not written (see document.formats)"); } + +/// A program in the legacy `.ai` format with a template box and no art size (version 3) opens as a +/// `.ai` or `.ait` on a US Letter artboard around the box's center, and as an EPS on its bounding +/// box. +#[test] +fn a_legacy_ai_program_opens_on_its_artboard_and_an_eps_on_its_page() { + let program = b"%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 30 30\n%AI3_TemplateBox: 100 100 100 100\n\ + %%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /k {setcmykcolor} def\n\ + %%EndProlog\n%%BeginSetup\n%%EndSetup\n0 0 0 1 k\n10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + for (name, size) in [("old.ai", (612.0, 792.0)), ("old.ait", (612.0, 792.0)), ("old.eps", (20.0, 20.0))] { + let r = load(name, program).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(r.doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(size.0, size.1), "{name}: {:?}", r.warnings); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_psdlayers.rs b/crates/engine/src/cmd/fileio/tests_psdlayers.rs index 31f670d7ff..298f3d22dc 100644 --- a/crates/engine/src/cmd/fileio/tests_psdlayers.rs +++ b/crates/engine/src/cmd/fileio/tests_psdlayers.rs @@ -1,7 +1,8 @@ //! Opening PSD and PSB files with their layers as layers (`document.open`'s `layers` and //! `hiddenLayers`): names, visibility, opacity, blend modes, groups, layer masks, clipped layers //! and blend modes VectorCraft lacks merged, 16-bit and PSB files, VectorCraft's own PSD export -//! read back, and damaged files read without a panic. +//! read back, and damaged files read without a panic. Placing them with their layers as objects +//! (`file.place`'s `layers` and `hiddenLayers`), and `document.psdInfo`. use serde_json::{Value, json}; use vectorcraft_color::BlendMode; @@ -967,10 +968,12 @@ fn damaged_layers_never_panic() { let mut f = good.clone(); f[i] = v; let _ = psdlayers::import(&f, true, 1 << 30, cms); + let _ = psdlayers::info(&f); } } for n in 0..good.len() { let _ = psdlayers::import(&good[..n], true, 1 << 30, cms); + let _ = psdlayers::info(&good[..n]); } // A layer past the memory allowed is refused, not read. assert!(psdlayers::import(&good, true, 64, cms).unwrap_err().contains("too large")); @@ -1015,3 +1018,186 @@ fn merges_keep_pass_through_groups_blending_with_what_is_below() { assert!(px.iter().zip([75, 150, 38, 255]).all(|(&a, b)| a.abs_diff(b) <= 1), "{px:?}"); } } + +/// `file.place` of `f` with `p` into a new 100 × 100 pt document, centred on (50, 50). +fn place(f: &[u8], p: Value) -> (Session, crate::cmd::Result) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = s.execute("file.place", &merge(json!({"name": "layers.psd", "dataBase64": vectorcraft_format::base64_encode(f), "at": [50, 50]}), p)); + (s, r) +} + +/// The one object `file.place` placed. +fn placed(s: &Session, r: &Value) -> Node { + let id = vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap()); + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +#[test] +fn placing_with_layers_embeds_the_layers_as_one_group() { + let f = file(8, 8, &stack(), 8, false, false); + let (mut s, r) = place(&f, json!({"layers": true})); + let r = r.unwrap(); + assert_eq!((&r["format"], &r["linked"], &r["warnings"]), (&json!("psd"), &json!(false), &json!([HIDDEN_1])), "{r}"); + let g = placed(&s, &r); + assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "one group, unclipped: its layers lie on the canvas"); + assert_eq!(name(&g), "layers.psd"); + let kids = g.children().unwrap(); + assert_eq!(kids.iter().map(|c| name(c)).collect::>(), ["Bottom", "Folder", "Screens", "Faded"], "bottom first"); + // A layer is its image, where the merged image would put it: the 8 pt canvas centred on (50, 50). + let ((x, y), px) = pixels_of(&s, &kids[0]); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(3, 3).0), ((46.0, 46.0), (8, 8), [255, 0, 0, 255])); + // A group is a group of its layers; a pass-through group isn't isolated, another mode's is. + let folder = &kids[1]; + assert!(matches!(folder.kind, NodeKind::Group { .. }) && !folder.isolate && folder.blend == BlendMode::Normal); + let inner = folder.children().unwrap(); + assert_eq!(inner.iter().map(|c| name(c)).collect::>(), ["Inner A"], "the hidden layer is left out"); + assert!(matches!(inner[0].kind, NodeKind::Image(_)) && inner[0].blend == BlendMode::Multiply && (inner[0].opacity - 128.0 / 255.0).abs() < 1e-6); + let screens = &kids[2]; + assert!(screens.isolate && screens.blend == BlendMode::Screen); + let ((x, y), px) = pixels_of(&s, &screens.children().unwrap()[0]); + assert_eq!(((x, y), px.get_pixel(0, 0).0), ((48.0, 48.0), [10, 20, 30, 128])); + assert!((kids[3].opacity - 102.0 / 255.0).abs() < 1e-6, "fill opacity: {}", kids[3].opacity); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.children().is_none_or(Vec::is_empty))); + // Hidden layers come in hidden when asked for. + let (s, r) = place(&f, json!({"layers": true, "hiddenLayers": true})); + let r = r.unwrap(); + assert_eq!(r["warnings"], json!([]), "{r}"); + let g = placed(&s, &r); + let inner: Vec<(&str, bool)> = g.children().unwrap()[1].children().unwrap().iter().map(|c| (name(c), c.visible)).collect(); + assert_eq!(inner, [("Inner A", true), ("Inner B", false)]); + // Without `layers`, the merged image, in the same box. + let (s, r) = place(&f, json!({})); + let r = r.unwrap(); + let flat = placed(&s, &r); + let ((x, y), px) = pixels_of(&s, &flat); + assert_eq!(((x, y), px.dimensions(), px.get_pixel(0, 0).0), ((46.0, 46.0), (8, 8), [128, 128, 128, 255]), "{r}"); +} + +#[test] +fn placed_layers_keep_their_masks_and_are_clipped_to_the_canvas() { + let mut masked = layer("Masked", [0, 0, 8, 8], [255, 255, 255, 255]); + masked.mask = Some(([2, 2, 4, 4], 0, 0, vec![255, 128, 0, 255])); + let (s, r) = place(&file(8, 8, &[masked], 8, false, false), json!({"layers": true})); + let g = placed(&s, &r.unwrap()); + let kids = g.children().unwrap(); + assert!(matches!(kids[0].kind, NodeKind::Image(_)), "the layer is its image"); + let m = kids[0].extras().mask.as_deref().expect("the layer mask as the image's opacity mask"); + let ((x, y), px) = pixels_of(&s, &m.art); + assert_eq!(((x, y), px.dimensions(), m.clip), ((48.0, 48.0), (2, 2), true), "the mask moves with its layer"); + // A layer that reaches past the canvas: the group is clipped to the canvas, as the merged + // image shows only the canvas. + let over = layer("Over", [-2, -2, 6, 6], [0, 255, 0, 255]); + let (s, r) = place(&file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [255, 0, 0, 255]), over], 8, false, false), json!({"layers": true})); + let r = r.unwrap(); + let g = placed(&s, &r); + assert!(matches!(g.kind, NodeKind::Group { clip: true, .. }), "{r}"); + let kids = g.children().unwrap(); + let b = kids[0].geometric_bounds_with(&s.doc().unwrap().doc.symbol_boxes()).unwrap(); + assert!(matches!(kids[0].kind, NodeKind::Path { clipping: true, .. })); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (46.0, 46.0, 54.0, 54.0), "the clipping path is the canvas"); + assert_eq!(kids[1..].iter().map(|c| name(c)).collect::>(), ["Bottom", "Over"]); + assert_eq!(pixels_of(&s, &kids[2]).0, (44.0, 44.0)); + // A hidden layer that reaches past the canvas clips the group too, so showing it later draws + // only the canvas. Left out, it doesn't. + let mut hidden = layer("Hidden", [-2, -2, 6, 6], [0, 255, 0, 255]); + hidden.hidden = true; + let f = file(8, 8, &[layer("Bottom", [0, 0, 8, 8], [255, 0, 0, 255]), hidden], 8, false, false); + let (s, r) = place(&f, json!({"layers": true, "hiddenLayers": true})); + let r = r.unwrap(); + let g = placed(&s, &r); + assert!(matches!(g.kind, NodeKind::Group { clip: true, .. }), "{r}"); + assert_eq!(g.children().unwrap()[1..].iter().map(|c| (name(c), c.visible)).collect::>(), [("Bottom", true), ("Hidden", false)]); + let (s, r) = place(&f, json!({"layers": true})); + assert!(matches!(placed(&s, &r.unwrap()).kind, NodeKind::Group { clip: false, .. })); +} + +#[test] +fn placing_with_layers_never_links_and_refuses_values_it_cant_use() { + let f = file(8, 8, &stack(), 8, false, false); + let dir = std::env::temp_dir().join(format!("vc-psd-place-layers-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("layers.psd"); + std::fs::write(&path, &f).unwrap(); + let from_path = |p: Value| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = s.execute("file.place", &merge(json!({"path": path.to_str().unwrap()}), p)); + (s, r) + }; + // From a path, `layers` embeds: `link` is then false unless given, and `link: true` is refused. + let (s, r) = from_path(json!({"layers": true})); + let r = r.unwrap(); + assert_eq!(r["linked"], json!(false), "{r}"); + assert!(matches!(placed(&s, &r).kind, NodeKind::Group { .. })); + let (s, r) = from_path(json!({"layers": true, "link": true})); + assert!(r.unwrap_err().to_string().contains("a linked PSD file is placed as its flat image")); + assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.children().is_none_or(Vec::is_empty)), "nothing is placed"); + // Without `layers` the file is linked as before. + let (s, r) = from_path(json!({})); + let r = r.unwrap(); + assert_eq!(r["linked"], json!(true), "{r}"); + assert!(matches!(placed(&s, &r).kind, NodeKind::Image(_))); + // A file without layers is embedded as its merged image, without a warning. + let flat = { + let mut f = file(2, 2, &[layer("x", [0, 0, 2, 2], [9, 9, 9, 255])], 8, false, false); + let at = 26 + 8; + let len = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) as usize; + f.splice(at..at + 4 + len, 0u32.to_be_bytes()); + f + }; + std::fs::write(&path, &flat).unwrap(); + let (s, r) = from_path(json!({"layers": true})); + let r = r.unwrap(); + assert_eq!((&r["linked"], &r["warnings"]), (&json!(false), &json!([])), "{r}"); + assert!(matches!(placed(&s, &r).kind, NodeKind::Image(_))); + // Layers that are all left out: the merged image, with the importer's warnings. + let mut gone = layer("Gone", [0, 0, 2, 2], [9, 9, 9, 255]); + gone.hidden = true; + let (s, r) = place(&file(2, 2, &[gone], 8, false, false), json!({"layers": true})); + let r = r.unwrap(); + assert_eq!((&r["linked"], r["warnings"].as_array().map(Vec::len)), (&json!(false), Some(1)), "{r}"); + assert!(matches!(placed(&s, &r).kind, NodeKind::Image(_))); + // Layers that can't be read: the merged image, with a warning. + let mut cut = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false); + let at = cut.windows(4).position(|w| w == [0, 0, 0, 18]).unwrap(); + cut[at..at + 4].copy_from_slice(&1000u32.to_be_bytes()); + let (s, r) = place(&cut, json!({"layers": true})); + let r = r.unwrap(); + assert!(r["warnings"][0].as_str().unwrap().starts_with("the layers can't be read"), "{r}"); + assert!(matches!(placed(&s, &r).kind, NodeKind::Image(_))); + // Values the command can't use are errors, and nothing is placed. + for (p, why) in [ + (json!({"hiddenLayers": true}), "hiddenLayers applies to a PSD file placed with layers: true"), + (json!({"layers": true, "link": true}), "a linked PSD file is placed as its flat image"), + (json!({"layers": "yes"}), "layers must be true or false"), + (json!({"layers": true, "hiddenLayers": 1}), "hiddenLayers must be true or false"), + ] { + let (s, r) = place(&f, p.clone()); + assert!(r.unwrap_err().to_string().contains(why), "{p}"); + assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.children().is_none_or(Vec::is_empty)), "{p}"); + } + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let q = s.execute("file.place.queue", &json!({"paths": [path.to_str().unwrap()], "layers": "yes"})); + assert!(q.unwrap_err().to_string().contains("`layers` must be true or false")); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn psd_info_counts_the_layers_without_opening_the_file() { + let mut s = Session::new(); + let info = |s: &mut Session, f: &[u8]| s.execute("document.psdInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(f)})); + let r = info(&mut s, &file(8, 8, &stack(), 8, false, false)).unwrap(); + assert_eq!(r, json!({"format": "psd", "width": 8, "height": 8, "layers": 5, "hiddenLayers": 1, "groups": 2, "slices": 0})); + let r = info(&mut s, &file(4, 2, &[layer("x", [0, 0, 2, 2], [9, 9, 9, 255])], 16, true, true)).unwrap(); + assert_eq!((&r["format"], &r["width"], &r["height"], &r["layers"]), (&json!("psb"), &json!(4), &json!(2), &json!(1)), "{r}"); + // Layer records that can't be read count as no layers: the file opens as its merged image. + let mut cut = file(4, 4, &[layer("x", [0, 0, 4, 4], [9, 9, 9, 255])], 8, false, false); + let at = cut.windows(4).position(|w| w == [0, 0, 0, 18]).unwrap(); + cut[at..at + 4].copy_from_slice(&1000u32.to_be_bytes()); + assert_eq!(info(&mut s, &cut).unwrap()["layers"], json!(0)); + assert!(info(&mut s, b"not a psd").unwrap_err().to_string().contains("not a PSD file")); +} diff --git a/crates/engine/src/cmd/fileio/tests_psdread.rs b/crates/engine/src/cmd/fileio/tests_psdread.rs index 4fe5f1e49b..6d66b8da4e 100644 --- a/crates/engine/src/cmd/fileio/tests_psdread.rs +++ b/crates/engine/src/cmd/fileio/tests_psdread.rs @@ -386,10 +386,32 @@ fn replacing_a_clipped_psd_keeps_its_scale() { assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}"); let (anchors, _, _) = clip_group(&s.doc().unwrap().doc, NodeId(r["ids"][0].as_u64().unwrap())); assert_eq!(anchors, [[200.0, 150.0], [300.0, 250.0], [100.0, 250.0]]); - // Other art replacing the clip group takes its centre only, as before. + // A plain image replacing the clip group keeps its scale too. let plain = vectorcraft_format::base64_encode(&red_psd(&[])); let r = s.execute("file.place", &json!({"name": "plain.psd", "dataBase64": plain, "replace": true})).unwrap(); - assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(100.0), Some(50.0)), "{r}"); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}"); +} + +/// A plain image replacing a clipped PSD file takes the clipped image's scale and rotation. +#[test] +fn a_plain_image_replacing_a_clipped_psd_keeps_its_rotation() { + let data = vectorcraft_format::base64_encode(&red_psd(&triangle_resources())); + let plain = vectorcraft_format::base64_encode(&red_psd(&[])); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let r = s.execute("file.place", &json!({"name": "cut.psd", "dataBase64": data, "at": [200, 200]})).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + s.execute("object.scale", &json!({"sx": 200, "sy": 150})).unwrap(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let xf = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Image(im)) => im.xf.as_coeffs(), + other => panic!("not an image: {other:?}"), + }; + let (_, _, image) = clip_group(&s.doc().unwrap().doc, group); + let before = xf(&s, image); + let r = s.execute("file.place", &json!({"name": "plain.psd", "dataBase64": plain, "replace": true})).unwrap(); + let after = xf(&s, NodeId(r["ids"][0].as_u64().unwrap())); + assert!(after.iter().zip(before).all(|(a, b)| (a - b).abs() < 1e-9), "{after:?} != {before:?}"); } #[test] diff --git a/crates/engine/src/cmd/fileio/tests_psdslices.rs b/crates/engine/src/cmd/fileio/tests_psdslices.rs new file mode 100644 index 0000000000..42dc33c600 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_psdslices.rs @@ -0,0 +1,459 @@ +//! A PSD or PSB file's slices (image resource 1050) opened and placed as user slices +//! (`document.open`'s and `file.place`'s `importSlices`): version 6 records and version 7 and 8 +//! descriptors, built from the Photoshop File Formats Specification's "Slices resource format"; +//! their options, slices outside the canvas, damaged or unknown slice data, placing scaled and +//! moved in one undo step, linked files, and `document.psdInfo`'s count. + +use serde_json::{Value, json}; +use vectorcraft_doc::{SliceKind, SliceOptions}; +use vectorcraft_geom::Rect; + +use crate::Session; + +/// A Unicode string: a count of UTF-16 units, then the units. +fn ustr(s: &str) -> Vec { + let units: Vec = s.encode_utf16().collect(); + let mut b = (units.len() as u32).to_be_bytes().to_vec(); + b.extend(units.iter().flat_map(|u| u.to_be_bytes())); + b +} + +/// One version 6 slice record. +#[derive(Clone)] +struct S { + /// 0: generated automatically, 1: follows a layer, 2: made by the user. + origin: u32, + name: &'static str, + /// 0: No Image, 1: Image. + kind: u32, + /// Left, top, right, bottom (px). + rect: [i32; 4], + url: &'static str, + target: &'static str, + message: &'static str, + alt: &'static str, + html: bool, + cell: &'static str, + /// Alpha, red, green, blue. + argb: [u8; 4], +} + +fn user(name: &'static str, rect: [i32; 4]) -> S { + S { origin: 2, name, kind: 1, rect, url: "", target: "", message: "", alt: "", html: false, cell: "", argb: [0; 4] } +} + +/// Resource 1050 in version 6, each record followed by `trailing` (a Photoshop 7 descriptor, or +/// nothing). +fn v6(slices: &[S], trailing: &[u8]) -> Vec { + let mut b = 6u32.to_be_bytes().to_vec(); + b.extend([0u8; 16]); + b.extend(ustr("group")); + b.extend((slices.len() as u32).to_be_bytes()); + for (i, s) in slices.iter().enumerate() { + b.extend((i as u32 + 1).to_be_bytes()); + b.extend(0u32.to_be_bytes()); + b.extend(s.origin.to_be_bytes()); + if s.origin == 1 { + b.extend(7u32.to_be_bytes()); + } + b.extend(ustr(s.name)); + b.extend(s.kind.to_be_bytes()); + for v in s.rect { + b.extend(v.to_be_bytes()); + } + for t in [s.url, s.target, s.message, s.alt] { + b.extend(ustr(t)); + } + b.push(u8::from(s.html)); + b.extend(ustr(s.cell)); + b.extend([0u8; 8]); + b.extend(s.argb); + b.extend(trailing); + } + b +} + +/// A descriptor value. +enum D { + Long(i32), + Doub(f64), + Bool(bool), + Text(&'static str), + Enum(&'static str, &'static str), + Obj(Vec<(&'static str, D)>), + List(Vec), +} + +/// A key or class ID: a four-character one with length 0, else its length and characters. +fn key(k: &str) -> Vec { + let mut b = if k.len() == 4 { 0u32 } else { k.len() as u32 }.to_be_bytes().to_vec(); + b.extend(k.as_bytes()); + b +} + +/// A descriptor: an empty class name, the class ID, the count, then each item. +fn descriptor(items: &[(&str, D)]) -> Vec { + let mut b = ustr(""); + b.extend(key("null")); + b.extend((items.len() as u32).to_be_bytes()); + for (k, v) in items { + b.extend(key(k)); + b.extend(value(v)); + } + b +} + +/// A value's type, then the value. +fn value(v: &D) -> Vec { + let mut b = vec![]; + match v { + D::Long(n) => { + b.extend(b"long"); + b.extend(n.to_be_bytes()); + } + D::Doub(d) => { + b.extend(b"doub"); + b.extend(d.to_be_bytes()); + } + D::Bool(on) => { + b.extend(b"bool"); + b.push(u8::from(*on)); + } + D::Text(t) => { + b.extend(b"TEXT"); + b.extend(ustr(t)); + } + D::Enum(t, e) => { + b.extend(b"enum"); + b.extend(key(t)); + b.extend(key(e)); + } + D::Obj(items) => { + b.extend(b"Objc"); + b.extend(descriptor(items)); + } + D::List(items) => { + b.extend(b"VlLs"); + b.extend((items.len() as u32).to_be_bytes()); + for i in items { + b.extend(value(i)); + } + } + } + b +} + +/// A bounds object: top, left, bottom and right. +fn bounds(l: i32, t: i32, r: i32, b: i32) -> D { + D::Obj(vec![("Top ", D::Long(t)), ("Left", D::Long(l)), ("Btom", D::Long(b)), ("Rght", D::Long(r))]) +} + +/// Resource 1050 in `version` 7 or 8 holding `slices`. +fn v7(version: u32, slices: Vec) -> Vec { + let mut b = version.to_be_bytes().to_vec(); + b.extend(16u32.to_be_bytes()); + b.extend(descriptor(&[("bounds", bounds(0, 0, 20, 20)), ("baseName", D::Text("poster")), ("slices", D::List(slices))])); + b +} + +/// A 20 × 20 px PSD file written by `document.export`, with its layers (two) or flat. +fn psd(layers: bool) -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + s.execute("paint.setFill", &json!({ "color": "#ff0000" })).unwrap(); + s.execute("shape.rectangle", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + s.execute("layer.new", &json!({ "name": "Top" })).unwrap(); + s.execute("shape.rectangle", &json!({"x": 8, "y": 8, "width": 10, "height": 10})).unwrap(); + let r = s.execute("document.export", &json!({"format": "psd", "layers": layers})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() +} + +/// `psd` with image resource `id` holding `data` added to its image resources. +fn with_resource(psd: &[u8], id: u16, data: &[u8]) -> Vec { + let u32_at = |at: usize| u32::from_be_bytes(psd[at..at + 4].try_into().unwrap()) as usize; + let colors = 26 + 4 + u32_at(26); + let len = u32_at(colors); + let mut block = b"8BIM".to_vec(); + block.extend(id.to_be_bytes()); + block.extend([0, 0]); + block.extend((data.len() as u32).to_be_bytes()); + block.extend(data); + block.resize(block.len().next_multiple_of(2), 0); + let mut out = psd[..colors].to_vec(); + out.extend(((len + block.len()) as u32).to_be_bytes()); + out.extend(&psd[colors + 4..colors + 4 + len]); + out.extend(block); + out.extend(&psd[colors + 4 + len..]); + out +} + +fn b64(f: &[u8]) -> String { + vectorcraft_format::base64_encode(f) +} + +fn open(f: &[u8], extra: Value) -> (Session, Value) { + let mut s = Session::new(); + let mut p = json!({"name": "poster.psd", "dataBase64": b64(f)}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let r = s.execute("document.open", &p).unwrap(); + (s, r) +} + +/// The document's user slices: rectangle and options. +fn slices(s: &Session) -> Vec<(Rect, SliceOptions)> { + s.doc().unwrap().doc.slices.iter().map(|x| (x.rect, x.options.clone())).collect() +} + +fn warnings(r: &Value) -> Vec { + r["warnings"].as_array().map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default() +} + +#[test] +fn version_6_slices_open_as_user_slices_with_their_options() { + let linked = S { origin: 1, url: "https://example.com/", target: "_blank", message: "Go", alt: "Logo", ..user("logo", [2, 4, 10, 8]) }; + let plain = S { kind: 0, cell: "a < b & c", argb: [0, 0, 0, 0], ..user("note", [10, 10, 20, 20]) }; + let html = S { kind: 0, html: true, cell: "Hi", argb: [255, 0x12, 0x34, 0x56], ..user("", [0, 12, 6, 18]) }; + let matte = S { argb: [0, 1, 2, 3], cell: "ignored", ..user("matte", [12, 0, 18, 6]) }; + let auto = S { origin: 0, ..user("auto", [0, 0, 20, 2]) }; + let data = v6(&[linked, plain, html, matte, auto], &[]); + for layers in [true, false] { + let f = with_resource(&psd(layers), 1050, &data); + // Either conversion brings the slices in; the automatic one stays out. + for p in [json!({}), json!({"layers": false})] { + let (s, r) = open(&f, p); + assert!(warnings(&r).is_empty(), "{r}"); + let got = slices(&s); + assert_eq!(got.len(), 4, "{got:?}"); + assert_eq!(got[0].0, Rect::new(2.0, 4.0, 10.0, 8.0)); + assert_eq!( + got[0].1, + SliceOptions { + kind: SliceKind::Image, + name: "logo".into(), + url: "https://example.com/".into(), + target: "_blank".into(), + message: "Go".into(), + alt: "Logo".into(), + ..Default::default() + } + ); + // Plain cell text is escaped as the HTML a No Image cell shows; HTML stays as it is. + assert_eq!((got[1].1.kind, got[1].1.text.as_str(), got[1].1.background.as_str()), (SliceKind::NoImage, "a < b & c", "")); + assert_eq!((got[2].1.text.as_str(), got[2].1.background.as_str(), got[2].1.name.as_str()), ("Hi", "#123456", "")); + // An Image slice's cell text isn't kept; a background with no alpha is the matte. + assert_eq!((got[3].1.text.as_str(), got[3].1.background.as_str()), ("", "matte")); + // Web output lays them out with auto slices around them. + let layout = s.doc().unwrap().doc.slice_layout(); + assert_eq!(layout.iter().filter(|a| a.source == vectorcraft_doc::SliceSource::User).count(), 4); + assert!(layout.iter().any(|a| a.source == vectorcraft_doc::SliceSource::Auto)); + } + } +} + +#[test] +fn photoshop_7_descriptors_after_version_6_records_are_read_past() { + let trailing = [16u32.to_be_bytes().to_vec(), descriptor(&[("topOutset", D::Long(0))])].concat(); + let f = with_resource(&psd(false), 1050, &v6(&[user("a", [0, 0, 5, 5]), user("b", [5, 5, 10, 10])], &trailing)); + let (s, r) = open(&f, json!({})); + assert!(warnings(&r).is_empty(), "{r}"); + let names: Vec = slices(&s).into_iter().map(|(_, o)| o.name).collect(); + assert_eq!(names, ["a", "b"]); +} + +#[test] +fn version_6_descriptors_share_one_budget_of_values() { + // Each record's descriptor holds 2,501 values, well under the 200,000 one descriptor may hold; + // 100 of them hold 250,100 together. + let list = D::List((0..2_500).map(D::Long).collect()); + let trailing = [16u32.to_be_bytes().to_vec(), descriptor(&[("many", list)])].concat(); + let records: Vec = (0..100).map(|_| user("a", [0, 0, 5, 5])).collect(); + // Alone, one such descriptor is read past. + let (s, r) = open(&with_resource(&psd(false), 1050, &v6(&records[..1], &trailing)), json!({})); + assert!(warnings(&r).is_empty(), "{r}"); + assert_eq!(slices(&s).len(), 1); + // Together they are refused, and the file opens without slices. + let (s, r) = open(&with_resource(&psd(false), 1050, &v6(&records, &trailing)), json!({})); + let w = warnings(&r); + assert!(w.iter().any(|w| w.starts_with("the slices can't be read (") && w.contains("too many values")), "{w:?}"); + assert!(slices(&s).is_empty()); +} + +#[test] +fn version_7_and_8_descriptors_open_as_user_slices() { + let slice = |origin: &'static str, kind: &'static str, l: i32, t: i32, r: i32, b: i32| { + D::Obj(vec![ + ("sliceID", D::Long(1)), + ("groupID", D::Long(0)), + ("origin", D::Enum("ESliceOrigin", origin)), + ("Type", D::Enum("ESliceType", kind)), + ("bounds", bounds(l, t, r, b)), + ("Nm ", D::Text("cell")), + ("url", D::Text("https://example.com/")), + ("null", D::Text("_top")), + ("Msge", D::Text("msg")), + ("altTag", D::Text("alt")), + ("cellTextIsHTML", D::Bool(false)), + ("cellText", D::Text("x>y")), + ("bgColorType", D::Enum("ESliceBGColorType", "Clr ")), + ("bgColor", D::Obj(vec![("Rd ", D::Doub(255.0)), ("Grn ", D::Doub(128.0)), ("Bl ", D::Long(0))])), + ]) + }; + for version in [7, 8] { + let data = v7( + version, + vec![slice("userGenerated", "Img ", 1, 2, 9, 10), slice("autoGenerated", "Img ", 0, 0, 20, 1), slice("layer", "noImage", 10, 10, 15, 15)], + ); + let (s, r) = open(&with_resource(&psd(true), 1050, &data), json!({})); + assert!(warnings(&r).is_empty(), "{r}"); + let got = slices(&s); + assert_eq!(got.len(), 2, "{got:?}"); + assert_eq!(got[0].0, Rect::new(1.0, 2.0, 9.0, 10.0)); + assert_eq!( + got[0].1, + SliceOptions { + name: "cell".into(), + url: "https://example.com/".into(), + target: "_top".into(), + message: "msg".into(), + alt: "alt".into(), + background: "#ff8000".into(), + ..Default::default() + } + ); + assert_eq!((got[1].1.kind, got[1].1.text.as_str(), got[1].1.url.as_str()), (SliceKind::NoImage, "x>y", "")); + } +} + +#[test] +fn import_slices_false_leaves_them_out_and_must_be_a_switch() { + let f = with_resource(&psd(true), 1050, &v6(&[user("a", [0, 0, 5, 5])], &[])); + let (s, _) = open(&f, json!({"importSlices": false})); + assert!(slices(&s).is_empty()); + let mut s = Session::new(); + let e = s.execute("document.open", &json!({"name": "poster.psd", "dataBase64": b64(&f), "importSlices": "yes"})).unwrap_err(); + assert!(e.to_string().contains("importSlices must be true or false"), "{e}"); +} + +#[test] +fn slices_that_cant_be_read_warn_and_the_file_still_opens() { + let good = v6(&[user("a", [0, 0, 5, 5])], &[]); + let mut bad_descriptor = v7(7, vec![]); + bad_descriptor[4..8].copy_from_slice(&15u32.to_be_bytes()); + let cases: Vec<(Vec, &str)> = vec![ + (good[..good.len() - 3].to_vec(), "cut short"), + ([9u32.to_be_bytes().to_vec(), good[4..].to_vec()].concat(), "version 9"), + (bad_descriptor, "version other than 16"), + // The count follows the version, the bounds and the group's name. + ([&good[..34], &u32::MAX.to_be_bytes()[..], &good[38..]].concat(), "more than the 10000 read"), + (v7(8, vec![D::Long(3)]), "other than a slice"), + // A whole record whose name is longer than a slice's text may be. + (v6(&[user("a".repeat(65_537).leak(), [0, 0, 5, 5])], &[]), "more than the 65536 characters read"), + ]; + for (data, why) in cases { + for layers in [true, false] { + let (s, r) = open(&with_resource(&psd(layers), 1050, &data), json!({})); + let w = warnings(&r); + assert!(w.iter().any(|w| w.starts_with("the slices can't be read (") && w.contains(why)), "{why}: {w:?}"); + assert!(slices(&s).is_empty()); + // The art still came in. + assert!(s.doc().unwrap().doc.art_bounds().is_some(), "{why}"); + } + } + // A name of the longest length read comes in. + let longest = v6(&[user("a".repeat(65_536).leak(), [0, 0, 5, 5])], &[]); + let (s, r) = open(&with_resource(&psd(false), 1050, &longest), json!({})); + assert!(warnings(&r).is_empty(), "{r}"); + assert_eq!(slices(&s).first().map(|(_, o)| o.name.len()), Some(65_536)); + // A file without slices opens without a word about them. + let (s, r) = open(&psd(true), json!({})); + assert!(warnings(&r).is_empty() && slices(&s).is_empty(), "{r}"); +} + +#[test] +fn slices_are_clipped_to_the_canvas_and_those_outside_it_are_left_out() { + let data = v6(&[user("over", [-5, -5, 5, 30]), user("out", [25, 0, 30, 5]), user("empty", [4, 4, 4, 9]), user("in", [1, 1, 2, 2])], &[]); + let (s, r) = open(&with_resource(&psd(false), 1050, &data), json!({})); + assert_eq!(warnings(&r), ["2 slices lie outside the canvas or have no size and were left out"]); + let got: Vec<(Rect, String)> = slices(&s).into_iter().map(|(r, o)| (r, o.name)).collect(); + assert_eq!(got, [(Rect::new(0.0, 0.0, 5.0, 20.0), "over".to_string()), (Rect::new(1.0, 1.0, 2.0, 2.0), "in".to_string())]); +} + +#[test] +fn placed_embedded_files_bring_their_slices_with_the_art_in_one_undo_step() { + let data = v6(&[user("a", [0, 0, 10, 10]), user("b", [10, 10, 20, 20])], &[]); + for layers in [false, true] { + let f = with_resource(&psd(layers), 1050, &data); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + // Fitted into a 40 pt square at (100, 60): twice the size. + let r = s.execute("file.place", &json!({"name": "poster.psd", "dataBase64": b64(&f), "rect": [100, 60, 40, 40], "layers": layers})).unwrap(); + assert_eq!(r["slices"].as_array().map(Vec::len), Some(2), "{r}"); + let got: Vec = slices(&s).into_iter().map(|(r, _)| r).collect(); + assert_eq!(got, [Rect::new(100.0, 60.0, 120.0, 80.0), Rect::new(120.0, 80.0, 140.0, 100.0)]); + // The placed art stays selected, and one undo takes the art and the slices away. + assert_eq!(s.doc().unwrap().selection.objects.len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(slices(&s).is_empty()); + assert!(s.doc().unwrap().doc.art_bounds().is_none()); + // Centered on `at` at 100%. + s.execute("file.place", &json!({"name": "poster.psd", "dataBase64": b64(&f), "at": [100, 100], "layers": layers})).unwrap(); + assert_eq!(slices(&s)[0].0, Rect::new(90.0, 90.0, 100.0, 100.0)); + // `importSlices: false` places the art alone. + let r = s.execute("file.place", &json!({"name": "poster.psd", "dataBase64": b64(&f), "importSlices": false, "layers": layers})).unwrap(); + assert!(r.get("slices").is_none()); + assert_eq!(slices(&s).len(), 2); + } +} + +#[test] +fn linked_files_are_placed_without_their_slices() { + let dir = std::env::temp_dir().join(format!("vc-psd-slices-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("poster.psd"); + std::fs::write(&path, with_resource(&psd(false), 1050, &v6(&[user("a", [0, 0, 5, 5])], &[]))).unwrap(); + let path = path.to_string_lossy().into_owned(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + assert_eq!((&r["linked"], r.get("slices")), (&json!(true), None)); + assert!(slices(&s).is_empty()); + let e = s.execute("file.place", &json!({ "path": path, "importSlices": true })).unwrap_err(); + assert!(e.to_string().contains("importSlices: a linked PSD file is placed as its flat image"), "{e}"); + // Embedded from its path, they come in. + let r = s.execute("file.place", &json!({ "path": path, "link": false })).unwrap(); + assert_eq!((&r["linked"], r["slices"].as_array().map(Vec::len)), (&json!(false), Some(1))); + // file.place.info counts what a place would bring in. + let info = s.execute("file.place.info", &json!({ "path": path, "link": false })).unwrap(); + assert_eq!(info["slices"], json!(1)); + let info = s.execute("file.place.info", &json!({ "path": path })).unwrap(); + assert_eq!(info["slices"], json!(0)); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn psd_info_counts_the_slices_that_come_in() { + let mut s = Session::new(); + let info = |s: &mut Session, f: &[u8]| s.execute("document.psdInfo", &json!({"dataBase64": b64(f)})).unwrap(); + let data = v6(&[user("a", [0, 0, 5, 5]), S { origin: 0, ..user("auto", [5, 5, 20, 20]) }, user("out", [30, 30, 40, 40])], &[]); + assert_eq!(info(&mut s, &with_resource(&psd(false), 1050, &data))["slices"], json!(1)); + assert_eq!(info(&mut s, &with_resource(&psd(true), 1050, &data[..data.len() - 4]))["slices"], json!(0)); + assert_eq!(info(&mut s, &psd(true))["slices"], json!(0)); +} + +#[test] +fn damaged_slices_never_panic() { + let data = v7(8, vec![D::Obj(vec![("origin", D::Enum("ESliceOrigin", "userGenerated")), ("bounds", bounds(0, 0, 5, 5))])]); + let base = with_resource(&psd(false), 1050, &data); + let start = base.windows(data.len()).position(|w| w == data).unwrap(); + for at in (start - 12..start + data.len()).step_by(3) { + for v in [0u8, 0x7f, 0xff] { + let mut f = base.clone(); + f[at] = v; + let mut s = Session::new(); + let _ = s.execute("document.open", &json!({"name": "poster.psd", "dataBase64": b64(&f)})); + let _ = s.execute("document.psdInfo", &json!({"dataBase64": b64(&f)})); + } + } +} diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 02b4097684..cc803c59b9 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -51,7 +51,7 @@ mod options; mod presets; mod report; -pub(crate) use analysis::{outlined_text, see_through_images}; +pub(crate) use analysis::{carry, outlined_text, see_through_images}; pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images}; #[cfg(test)] pub(crate) use apply::{with_clips_ignored, with_every_region}; diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index 37adcf438d..4525a3aa5c 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -56,7 +56,7 @@ pub(crate) fn outlined_text(n: &Node) -> Option { } /// `to` (art made from `from`) with `from`'s name, transparency, opacity mask and effects. -fn carry(from: &Node, mut to: Node) -> Node { +pub(crate) fn carry(from: &Node, mut to: Node) -> Node { to.name = from.name.clone(); to.opacity = from.opacity; to.blend = from.blend; diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 7c8f664cf3..28662f013c 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -10,9 +10,10 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun}; use vectorcraft_geom::Rect; +use super::panelcmds::listed_artboards; use super::select::on_artboard; use super::typecmd::refresh_bounds; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; pub fn specs() -> Vec { vec![ @@ -39,7 +40,7 @@ pub fn specs() -> Vec { "Select Text by Font", [], None, - "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the text whose inline graphics draw such a symbol, the objects painted with a pattern whose art uses it (by a visible fill or stroke whose opacity is above 0, a stroke wider than 0) and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", + "{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based whole numbers)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, the instances of the symbols whose art uses it, the text whose inline graphics draw such a symbol, the objects painted with a pattern whose art uses it (by a visible fill or stroke whose opacity is above 0, a stroke wider than 0) and the objects whose opacity mask uses it (in type that is not hidden or locked in that art), that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; a pattern swatch that nothing paints, or a layer's mask, selects nothing; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}", has_doc, select_font ), @@ -1080,9 +1081,9 @@ impl FontScope { Some(_) => Err(bad(C, format!("`scope.{key}` must be a non-empty array"))), } }; - if let Some(a) = list("artboards")? { - let rect = |v: &Value| v.as_u64().and_then(|i| d.artboards.get(index(i))).map(|b| b.rect); - return a.iter().map(|v| rect(v).ok_or_else(|| bad(C, format!("no such artboard {v}")))).collect::>().map(Self::Artboards); + if let (Some(v), Some(_)) = (scope.get("artboards"), list("artboards")?) { + let indices = listed_artboards(d, v, "scope.artboards", C)?; + return Ok(Self::Artboards(indices.into_iter().filter_map(|i| d.artboards.get(i)).map(|b| b.rect).collect())); } if let Some(a) = list("layers")? { let layer = |v: &Value| v.as_u64().map(NodeId).filter(|id| d.node(*id).is_some_and(Node::is_layer)); @@ -1292,6 +1293,40 @@ mod tests { } } + /// Select Text by Font refuses a `scope.artboards` entry that is not a whole number, naming the + /// parameter, before it looks at the artboards, and leaves the selection as it was; a whole + /// number past the last artboard is `no such artboard`. + #[test] + fn select_font_refuses_artboards_that_are_not_whole_numbers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap(); + let text = |s: &mut Session, x: i32| { + NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap()) + }; + let first = text(&mut s, 10); + let second = text(&mut s, 350); + let run = + |s: &mut Session, artboards: Value| s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": artboards}})); + let malformed = "invalid parameters for `select.font`: `scope.artboards` must contain whole numbers"; + for (artboards, want) in [ + (json!(["1"]), malformed), + (json!([-1]), malformed), + (json!([1.5]), malformed), + (json!([true]), malformed), + (json!([null]), malformed), + (json!([9, "1"]), malformed), + (json!([9]), "no such artboard"), + (json!([1, 9]), "no such artboard"), + ] { + s.execute("select.set", &json!({"ids": [first.0]})).unwrap(); + assert_eq!(run(&mut s, artboards.clone()).unwrap_err().to_string(), want, "{artboards}"); + assert_eq!(s.doc().unwrap().selection.objects, [first], "{artboards}"); + } + run(&mut s, json!([1, 1])).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, [second]); + } + /// Select Text by Font with a layer scope takes the text in the layers, their sublayers /// included, and still leaves out hidden and locked text. #[test] diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 06557e6f59..7be0a122df 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -40,7 +40,9 @@ pub use commands::specs; pub(crate) use data::{apply_csv, parse_csv}; #[cfg(test)] use data::{fmt_value, to_csv}; -pub(crate) use edit::{graphs_holding, keep_placed}; +pub use edit::{GraphTypeSubject, graph_type_subject}; +pub(crate) use edit::{edit_graph_art, graphs_holding, keep_placed}; +pub use generate::calculated_axes; #[cfg(test)] use generate::generate; #[cfg(test)] @@ -48,8 +50,8 @@ use series::default_series_color; pub(crate) use series::{ LabelTextChange, capture_label_anti_alias, capture_label_changes, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art, - label_object_opacity_differs, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_groups_among, series_marks_in, - series_object_opacity_differs, shown_paint, + label_object_opacity_differs, labels_in, paint_targets, plain_labels, series_group_marks, series_group_marks_where, series_groups_among, + series_marks_in, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index 77ce7a7b2a..378a8e3745 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -2,7 +2,7 @@ use vectorcraft_doc::graph::graph_art_without_type as art_bounds; use super::axis::{MAX_TICK_MARKS, count_given, one_line}; use super::data::apply_data; -use super::generate::{children_bounds, generate}; +use super::generate::{calculated_axes, children_bounds, generate}; use super::series::{picked_series, reselect_series}; use super::{ Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, f64_req, json, @@ -88,6 +88,23 @@ pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option, Opt } } +/// Let `edit` change the art of graph node `n` (a copy outside any document) without the next redraw reading the +/// change as a move or scale: the plot rect first takes any move or scale of the art since it was generated, as a +/// redraw measures it, and the bounds the graph keeps become those of the changed art (also for a graph that had none, +/// so a later move is followed). +pub(crate) fn edit_graph_art(n: &mut Node, edit: impl FnOnce(&mut Node)) { + let moved = measured(n).and_then(|(placed, now)| n.extras().graph.as_deref().and_then(|g| carried(g.rect, placed, now))); + if let (Some(rect), Some(g)) = (moved, n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) { + g.rect = rect; + } + edit(n); + let children = n.children(); + let (whole, art) = (children.and_then(|c| children_bounds(c)), children.and_then(|c| art_bounds(c))); + if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + (g.placed, g.placed_art) = (whole, art); + } +} + /// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last /// generation, which is recorded in the spec's `placed_art` bounds, or `placed` in graphs saved without them). pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { @@ -169,6 +186,28 @@ pub(super) fn spec_of(s: &Session, id: NodeId) -> Result { s.doc()?.doc.node(id).and_then(|n| n.extras().graph.as_deref().cloned()).ok_or(EngineError::NoNode(id)) } +/// What `graph.setType` without `id` or `seriesIndexes` acts on, for the Graph Type dialog. +pub struct GraphTypeSubject { + /// The selected graph, or the graph that holds the selection. + pub id: NodeId, + /// The series picked in it with Group Selection, in order; empty when the type goes to the whole graph. + pub series: Vec, + /// The least value, the greatest and the number of divisions each value axis is drawn with while its tick values + /// are calculated: the left (or only) axis, then the right axis' own scale (the left one's without Separate Scales). + pub calculated: [(f64, f64, usize); 2], +} + +/// The graph and series that `graph.setType {}` reads its options from, as [`GraphTypeSubject`]. An error without a +/// selected graph, as the command gives. +pub fn graph_type_subject(s: &Session) -> Result { + const C: &str = "graph.setType"; + let p = json!({}); + let id = target(s, &p, C)?; + let spec = spec_of(s, id)?; + let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); + Ok(GraphTypeSubject { id, series: picked_series(s, &p, id, count, C)?, calculated: calculated_axes(&spec) }) +} + fn query_type_options(spec: &GraphSpec, count: usize, picked: &[usize]) -> Value { // With series picked, the type and value axis they share, so the dialog's OK keeps them. let kinds: Vec = picked.iter().map(|i| spec.series_kind(*i)).collect(); diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 5fadcb8768..f589ed9ae5 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -38,6 +38,101 @@ fn nth(list: &[Option], s: usize) -> Option<&T> { list.get(s).and_then(Option::as_ref) } +/// The data a value range is read from: each series' type, the cells (a blank is `None`), which series use the right +/// axis' own scale, and the number of categories. +struct Values<'a> { + kinds: &'a [GraphKind], + cells: &'a [Vec>], + on_right: &'a [bool], + ncat: usize, +} + +impl Values<'_> { + fn cell(&self, c: usize, s: usize) -> Option { + self.cells.get(c).and_then(|r| r.get(s)).copied().flatten() + } + + fn axis(&self, s: usize) -> usize { + usize::from(self.on_right.get(s).copied().unwrap_or(false)) + } + + /// The value range per axis (stacked graphs stack positives and negatives separately), and a scatter's x range. + fn range(&self, scatter: bool) -> ([f64; 2], [f64; 2], f64, f64) { + let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); + let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); + for c in 0..self.ncat { + if !scatter { + for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { + self.extend(c, a, (lo, hi)); + } + } else { + for s in (0..self.kinds.len()).step_by(2) { + if let (Some(y), Some(x)) = (self.cell(c, s), self.cell(c, s + 1)) { + hi[0] = hi[0].max(y); + lo[0] = lo[0].min(y); + xlo = xlo.min(x); + xhi = xhi.max(x); + } + } + } + } + (lo, hi, xlo, xhi) + } + + /// Widen the range of axis `a` by category `c`'s values. + fn extend(&self, c: usize, a: usize, (lo, hi): (&mut f64, &mut f64)) { + let val = |s: usize| self.cell(c, s).unwrap_or(0.0); + let on = |s: &usize, kinds: &[GraphKind]| self.axis(*s) == a && self.kinds.get(*s).is_some_and(|k| kinds.contains(k)); + // Stacked columns (or bars) stack together, and so do areas; other series count one value each. + for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { + let members = || (0..self.kinds.len()).filter(|s| on(s, &stack)); + *hi = hi.max(members().map(|s| val(s).max(0.0)).sum()); + *lo = lo.min(members().map(|s| val(s).min(0.0)).sum()); + } + let single = |s: &usize| on(s, &[GraphKind::Column, GraphKind::Bar, GraphKind::Line]); + for v in (0..self.kinds.len()).filter(single).filter_map(|s| self.cell(c, s)) { + *hi = hi.max(v); + *lo = lo.min(v); + } + } +} + +/// The tick values `g`'s value axes are drawn with while they are calculated from the data (Graph Type › Tick Values +/// without Override Calculated Values): the least value, the greatest and the number of divisions of the left (or +/// only) axis, then of the right axis' own scale, which is the left one's unless Separate Scales gives the series on +/// the right axis a scale of their own. A pie graph has no value axis and gives the axis of a graph without data. A +/// single bound the graph has (only a minimum or only a maximum) is calculated with, as the axis is drawn. +pub fn calculated_axes(g: &GraphSpec) -> [(f64, f64, usize); 2] { + // Adding 0 makes a negative zero, which a nice axis around 0 can start at, a plain 0. + let divisions = |(lo, hi, step): (f64, f64, f64)| (lo + 0.0, hi + 0.0, ((hi - lo) / step).round().clamp(1.0, 100.0) as usize); + // The bounds the axis is calculated with: all but a pair that overrides the calculation. + let partial = |min: Option, max: Option| match (min, max) { + (Some(a), Some(b)) if b > a && (b - a).is_finite() => (None, None), + bounds => bounds, + }; + let (min, max) = partial(g.axis_min, g.axis_max); + if g.kind == GraphKind::Radar { + let top = g.rows.iter().flatten().copied().fold(0.0, f64::max); + return [divisions(value_axis(g.ticks, min, max, 0.0, top)); 2]; + } + let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); + let (scatter, horizontal) = (g.kind == GraphKind::Scatter, matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar)); + let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !horizontal && !scatter; + let on_right: Vec = (0..nser).map(|s| separate && g.right_series.contains(&s)).collect(); + let cells = if g.kind == GraphKind::Pie { vec![] } else { g.cells() }; + let ncat = g.rows.len().clamp(1, vectorcraft_doc::MAX_GRAPH_CATEGORIES); + let (lo, hi, ..) = Values { kinds: &kinds, cells: &cells, on_right: &on_right, ncat }.range(scatter); + let left = divisions(value_axis(g.ticks, min, max, lo[0], hi[0])); + let used = |right: bool| on_right.contains(&right); + if !used(true) { + return [left; 2]; + } + let (right_min, right_max) = partial(g.right_axis_min, g.right_axis_max); + let right = divisions(value_axis(g.right_ticks, right_min, right_max, lo[1], hi[1])); + if used(false) { [left, right] } else { [right; 2] } +} + /// One graph being generated: its data, the nodes drawn so far and what the legend and shadows still need. struct Plot<'a> { g: &'a GraphSpec, @@ -198,7 +293,7 @@ fn bar_rect(horizontal: bool, a: f64, w: f64, v0: f64, v1: f64) -> Rect { } /// White text on a dark wedge. -fn is_dark(fill: &Paint) -> bool { +pub(super) fn is_dark(fill: &Paint) -> bool { fill.color().is_some_and(|c| { let [r, g, b] = c.to_rgb(); 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 @@ -526,41 +621,7 @@ impl<'a> Plot<'a> { /// The value range per axis (stacked graphs stack positives and negatives separately), and a scatter's x range. fn value_range(&self, geo: &Geo, scatter: bool) -> ([f64; 2], [f64; 2], f64, f64) { - let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); - let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); - for c in 0..self.ncat { - if !scatter { - for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { - self.extend_range(geo, c, a, (lo, hi)); - } - } else { - for s in (0..self.nser).step_by(2) { - if let (Some(y), Some(x)) = (self.cell(c, s), self.cell(c, s + 1)) { - hi[0] = hi[0].max(y); - lo[0] = lo[0].min(y); - xlo = xlo.min(x); - xhi = xhi.max(x); - } - } - } - } - (lo, hi, xlo, xhi) - } - - /// Widen the range of axis `a` by category `c`'s values. - fn extend_range(&self, geo: &Geo, c: usize, a: usize, (lo, hi): (&mut f64, &mut f64)) { - let nser = self.nser; - // Stacked columns (or bars) stack together, and so do areas; other series count one value each. - for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { - let members = || (0..nser).filter(|s| geo.axis(*s) == a && stack.contains(&self.kind(*s))); - *hi = hi.max(members().map(|s| self.val(c, s).max(0.0)).sum()); - *lo = lo.min(members().map(|s| self.val(c, s).min(0.0)).sum()); - } - let single = |s: &usize| geo.axis(*s) == a && matches!(self.kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line); - for v in (0..nser).filter(single).filter_map(|s| self.cell(c, s)) { - *hi = hi.max(v); - *lo = lo.min(v); - } + Values { kinds: &self.kinds, cells: &self.cells, on_right: &geo.on_right, ncat: self.ncat }.range(scatter) } /// The value axes with ticks and labels, the legend's start after them, and the axis lines. diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 681c1a6a4a..ee33f23517 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,8 +1,13 @@ use vectorcraft_color::BlendMode; +use vectorcraft_geom::Shape; use super::axis::radar_axis; -use super::labels::{Role, filled_lines_on, label_para, label_style}; -use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad}; +use super::generate::is_dark; +use super::labels::{Role, filled_lines_on, label_para, label_size, label_style}; +use super::{ + Affine, Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad, + refresh_bounds, +}; // ---------- generation ---------- @@ -639,35 +644,460 @@ fn graph_labels(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { let Some(g) = n.extras().graph.as_deref() else { return }; - for c in n.children().into_iter().flatten() { - match (&c.kind, c.name.as_deref(), c.extras().series_index) { - (NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => { - out.insert(c.id, (n.id, Role::Category)); - } - (NodeKind::Group { .. }, Some("Legend"), None) => { - for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) { - out.insert(t.id, (n.id, Role::Legend)); - } - } - (NodeKind::Group { .. }, Some("Axes"), None) => { - let texts: Vec = - c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect(); - let cats = match g.kind { - GraphKind::Scatter => 0, - GraphKind::Radar => texts.len(), - _ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()), - }; - for (i, t) in texts.iter().enumerate() { - out.insert(*t, (n.id, if i + cats >= texts.len() { Role::Category } else { Role::Value })); - } - } - _ => {} - } + for (id, role) in own_labels(n, g) { + out.insert(id, (n.id, role)); } }); out } +/// The generated labels of graph `n` (with spec `g`) and what each shows, as [`graph_labels`] finds them. +fn own_labels(n: &Node, g: &GraphSpec) -> Vec<(NodeId, Role)> { + let mut out = vec![]; + for c in n.children().into_iter().flatten() { + match (&c.kind, c.name.as_deref(), c.extras().series_index) { + (NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => { + out.push((c.id, Role::Category)); + } + (NodeKind::Group { .. }, Some("Legend"), None) => { + for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) { + out.push((t.id, Role::Legend)); + } + } + (NodeKind::Group { .. }, Some("Axes"), None) => { + let texts: Vec = c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect(); + let cats = match g.kind { + GraphKind::Scatter => 0, + GraphKind::Radar => texts.len(), + _ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()), + }; + for (i, t) in texts.iter().enumerate() { + out.push((*t, if i + cats >= texts.len() { Role::Category } else { Role::Value })); + } + } + _ => {} + } + } + out +} + +/// Set the labels of graph node `n` (a copy outside any document) in a new graph's label formats: the character and +/// paragraph formats the graph keeps for them are dropped, with the mark that its legend labels' fill is their own, and +/// each label takes its kind's default character style, unscaled (keeping its rotation), and default paragraph +/// attributes, keeping its alignment and where it starts. A pie's legend label on a dark wedge is white, as a redraw +/// sets it. Every other object in the graph goes to `other`. The rest of the graph's art, and what its labels say, stay +/// as they are. +pub(crate) fn plain_labels(n: &mut Node, other: &dyn Fn(&mut Node)) { + fn plain(l: &mut vectorcraft_doc::LabelStyles) { + (l.values, l.categories, l.legend) = (None, None, None); + (l.values_para, l.categories_para, l.legend_para) = (None, None, None); + l.legend_fill_set = false; + } + // Which legend labels a redraw sets on a dark wedge, read while the labels are as the graph drew them. + let on_dark = match n.extras().graph.as_deref() { + Some(g) if g.kind == GraphKind::Pie && g.legend && g.pie_legend_in_wedges => { + let mut redraw = g.clone(); + plain(&mut redraw.label_styles); + redrawn_on_dark(n, &redraw).unwrap_or_else(|| legend_on_dark(n, g)) + } + _ => HashMap::new(), + }; + let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) else { return }; + plain(&mut g.label_styles); + let Some(g) = n.extras().graph.as_deref() else { return }; + let roles: HashMap = own_labels(n, g).into_iter().collect(); + struct Plain<'a> { + roles: HashMap, + other: &'a dyn Fn(&mut Node), + } + fn visit(c: &mut Node, p: &Plain) { + let role = p.roles.get(&c.id).copied(); + if let (Some(role), NodeKind::Text(t)) = (role, &mut c.kind) { + let plain = vectorcraft_doc::LabelStyles::default(); + let justify = t.para_at(0).justify; + let text = t.plain_text(); + t.runs = vec![vectorcraft_doc::TextRun { text, style: label_style(&plain, role), inline: None }]; + let mut para = label_para(&plain, role); + para.justify = justify; + t.set_all_paras(para); + // The label loses its scale and keeps its rotation (and a reflection), so it stays turned with the + // rest of a rotated graph's art. + let [a, b, c, d, _, _] = t.xf.as_coeffs(); + let flip = if a * d - b * c < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY }; + t.xf = Affine::translate(t.xf.translation()) * Affine::rotate(b.atan2(a)) * flip; + refresh_bounds(t); + return; + } + // The Axes and Legend groups, and any other group of the graph's own, are looked through. + if role.is_none() && matches!(c.kind, NodeKind::Group { .. }) && c.extras().graph.is_none() { + for k in c.children_mut().into_iter().flatten() { + visit(Arc::make_mut(k), p); + } + return; + } + (p.other)(c); + } + let p = Plain { roles, other }; + for c in n.children_mut().into_iter().flatten() { + visit(Arc::make_mut(c), &p); + } + set_legend_fills(n, &on_dark); +} + +/// Give each legend label of graph `n` in `on_dark` white characters (`true`) or black ones. +fn set_legend_fills(n: &mut Node, on_dark: &HashMap) { + if on_dark.is_empty() { + return; + } + // The legend labels are in the graph's Legend group. + for c in n.children_mut().into_iter().flatten() { + if !c.children().into_iter().flatten().any(|t| on_dark.contains_key(&t.id)) { + continue; + } + for t in Arc::make_mut(c).children_mut().into_iter().flatten() { + let Some(dark) = on_dark.get(&t.id) else { continue }; + let fill = Paint::solid(if *dark { Color::WHITE } else { Color::BLACK }); + if matches!(&t.kind, NodeKind::Text(text) if text.runs.iter().all(|r| r.style.fill == fill)) { + continue; + } + if let NodeKind::Text(text) = &mut Arc::make_mut(t).kind { + text.runs.iter_mut().for_each(|r| r.style.fill = fill.clone()); + } + } + } +} + +/// The legend labels of graph `n`: the type in its Legend group, as [`own_labels`] finds them. +fn legend_labels(n: &Node) -> Vec<&Node> { + legend_in(n.children().map_or(&[], |c| c.as_slice())) +} + +/// The type in the Legend group among a graph's `children`. +fn legend_in(children: &[Arc]) -> Vec<&Node> { + children + .iter() + .filter(|c| matches!(c.kind, NodeKind::Group { .. }) && c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none()) + .flat_map(|c| c.children().into_iter().flatten()) + .map(|t| t.as_ref()) + .filter(|t| matches!(t.kind, NodeKind::Text(_))) + .collect() +} + +/// A pie wedge: its series, its outline and whether the fill it shows is dark. +struct Wedge { + series: u32, + bounds: vectorcraft_geom::Rect, + path: vectorcraft_geom::BezPath, + dark: bool, +} + +/// The wedges of pie `n` (the paths of its series groups), in paint order. +fn pie_wedges(n: &Node) -> Vec { + wedges_in(n.children().map_or(&[], |c| c.as_slice())) +} + +/// The wedges among a pie's `children`, as [`pie_wedges`] finds them. +fn wedges_in(children: &[Arc]) -> Vec { + let mut wedges = vec![]; + for c in children { + let Some(series) = c.extras().series_index else { continue }; + c.walk(&mut |m| { + if let NodeKind::Path { path, .. } = &m.kind { + let path = path.to_bezpath(); + wedges.push(Wedge { series, bounds: path.bounding_box(), path, dark: is_dark(&shown_paint(m, true).0) }); + } + }); + } + wedges +} + +/// The text of the label of each series of graph `g` in its wedges, with the series that show it (several when series +/// share a name). +fn series_by_label(g: &GraphSpec) -> HashMap> { + let mut names: HashMap> = HashMap::new(); + for (s, name) in g.series.iter().enumerate() { + if let Ok(s) = u32::try_from(s) { + names.entry(name.replace('|', "\n")).or_default().push(s); + } + } + names +} + +/// The legend labels of pie `n`, each with whether a redraw from spec `g` sets it on a dark wedge, by the fill each wedge +/// of `n` shows now. The pie is drawn again apart from any document, which places each label in its wedge as `g` sets +/// it and tells each label's series. Each redrawn wedge takes the fill of the wedge of `n` of its series with the same +/// bounds, and each legend label of `n` is the nearest redrawn one with the same text, places measured in the bounds of +/// all the wedges. `None` when a wedge or a label has no counterpart of its own, or a wedge out of its place in paint +/// order has several (art or labels added, removed, edited or restyled since the pie was drawn). +fn redrawn_on_dark(n: &Node, g: &GraphSpec) -> Option> { + use vectorcraft_geom::kurbo::ParamCurveNearest; + // Each series name that shows a label is drawn with a mark and its index after it, which moves no line, so each + // redrawn label tells its series. + const MARK: char = '\u{1f}'; + let mut marked = g.clone(); + for (s, name) in marked.series.iter_mut().enumerate() { + if !super::labels::blank_label(name) { + name.push(MARK); + name.push_str(&s.to_string()); + } + } + let legend = legend_labels(n); + let mut scratch = Document::new(1.0, 1.0); + let art = super::generate::generate(&mut scratch, &marked); + let redrawn = legend_in(&art); + let now = pie_wedges(n); + let mut wedges = wedges_in(&art); + if redrawn.len() != legend.len() || wedges.len() != now.len() { + return None; + } + // Places are measured in the bounds of all the wedges, since the pie may have been moved or resized since it was drawn. + let frame = |ws: &[Wedge]| ws.iter().map(|w| w.bounds).reduce(|a, b| a.union(b)); + let (Some(was), Some(is)) = (frame(&wedges), frame(&now)) else { return None }; + let at = |f: vectorcraft_geom::Rect, p: vectorcraft_geom::Point| ((p.x - f.x0) / f.width().max(1e-9), (p.y - f.y0) / f.height().max(1e-9)); + let corners = |f: vectorcraft_geom::Rect, w: &Wedge| { + let (a, b) = (at(f, vectorcraft_geom::Point::new(w.bounds.x0, w.bounds.y0)), at(f, vectorcraft_geom::Point::new(w.bounds.x1, w.bounds.y1))); + [a.0, a.1, b.0, b.1] + }; + // A wedge and a redrawn one are the same wedge when they show the same series with the same bounds. Wedges are + // compared this many times in all, labels included; past it, nothing is matched. + let mut budget: usize = 1 << 20; + let mut copied_wedges: HashMap> = HashMap::new(); + for (k, w) in now.iter().enumerate() { + copied_wedges.entry(w.series).or_default().push((k, corners(is, w))); + } + // The wedge in the same place in paint order when its bounds are the same, else the one of the series whose bounds + // are nearest, when they are the same to within 0.001 of the whole; each copied wedge is matched once. + let off = |c: &[f64; 4], d: &[f64; 4]| c.iter().zip(d).map(|(p, q)| (p - q).abs()).fold(0.0, f64::max); + let mut used = vec![false; now.len()]; + let mut fills = Vec::with_capacity(wedges.len()); + for (i, r) in wedges.iter().enumerate() { + let c = corners(was, r); + let k = match now.get(i) { + Some(w) if w.series == r.series && off(&c, &corners(is, w)) <= 1e-7 => i, + _ => { + let group = copied_wedges.get(&r.series)?; + if budget < group.len() { + return None; + } + budget -= group.len(); + let (k, d) = group.iter().map(|(k, d)| (*k, off(&c, d))).min_by(|a, b| a.1.total_cmp(&b.1))?; + if d > 1e-3 { + return None; + } + k + } + }; + if std::mem::replace(used.get_mut(k)?, true) { + return None; + } + fills.push(now.get(k)?.dark); + } + for (r, dark) in wedges.iter_mut().zip(fills) { + r.dark = dark; + } + // Each label's words and origin, in the frame of its pie's wedges. + let place = |labels: &[&Node], f| { + labels + .iter() + .map(|t| match &t.kind { + NodeKind::Text(text) => Some((text.plain_text(), at(f, text.xf * vectorcraft_geom::Point::ORIGIN))), + _ => None, + }) + .collect::>>() + }; + let (Some(copied), Some(mut drawn)) = (place(&legend, is), place(&redrawn, was)) else { return None }; + // The series of each redrawn label, and its words without the mark. + let mut series = Vec::with_capacity(drawn.len()); + for (words, _) in &mut drawn { + let (said, s) = words.rsplit_once(MARK)?; + let s: usize = s.parse().ok()?; + let name = g.series.get(s)?; + if *said != name.replace('|', "\n") { + return None; + } + series.push((s, name.as_str())); + words.truncate(said.len()); + } + // Each label is the nearest redrawn label that says the same, and no two labels are the same redrawn one. + let mut same: HashMap<&str, Vec> = HashMap::new(); + for (j, (words, _)) in drawn.iter().enumerate() { + same.entry(words.as_str()).or_default().push(j); + } + let mut taken = vec![false; drawn.len()]; + let mut matched = Vec::with_capacity(copied.len()); + for (words, p) in &copied { + let group = same.get(words.as_str())?; + if budget < group.len() { + return None; + } + budget -= group.len(); + let away = |j: &usize| drawn.get(*j).map_or(f64::INFINITY, |(_, q)| (p.0 - q.0).powi(2) + (p.1 - q.1).powi(2)); + let j = *group.iter().min_by(|a, b| away(a).total_cmp(&away(b)))?; + if std::mem::replace(taken.get_mut(j)?, true) { + return None; + } + matched.push(j); + } + let mut by_series: HashMap> = HashMap::new(); + for w in &wedges { + by_series.entry(w.series).or_default().push(w); + } + // A redraw puts the middle of a label's lines (its series name split at `|` and line breaks, at the default leading) + // 0.35 of the label size below a point inside its wedge, at the label's origin across. The label is on the nearest + // wedge of its series to that point, the one holding it. When the wedges of the series are all dark or all light, the + // place does not matter. + let uniform: HashMap> = + by_series.iter().map(|(s, ws)| (*s, ws.first().map(|w| w.dark).filter(|d| ws.iter().all(|w| w.dark == *d)))).collect(); + let ls = label_size(&g.label_styles, Role::Legend); + let mut out = HashMap::new(); + for (t, j) in legend.iter().zip(matched) { + let (Some(r), Some((s, name))) = (redrawn.get(j), series.get(j).copied()) else { return None }; + let s = u32::try_from(s).ok()?; + if let Some(Some(dark)) = uniform.get(&s) { + out.insert(t.id, *dark); + continue; + } + let NodeKind::Text(text) = &r.kind else { return None }; + let middle = name.split(['|', '\n']).count().saturating_sub(1) as f64 * ls * 1.2 / 2.0; + let point = text.xf * vectorcraft_geom::Point::new(0.0, middle - ls * 0.35); + let named = by_series.get(&s)?; + if budget < named.len() { + return None; + } + budget -= named.len(); + // The nearest wedge, measured exactly only while its bounds are nearer than the nearest one found so far. + let to_box = |b: vectorcraft_geom::Rect| { + let (dx, dy) = ((b.x0 - point.x).max(point.x - b.x1).max(0.0), (b.y0 - point.y).max(point.y - b.y1).max(0.0)); + dx * dx + dy * dy + }; + let away = |w: &Wedge| { + if w.path.contains(point) { 0.0 } else { w.path.segments().map(|seg| seg.nearest(point, 1e-3).distance_sq).fold(f64::INFINITY, f64::min) } + }; + let mut by_box: Vec<(f64, &Wedge)> = named.iter().map(|w| (to_box(w.bounds), *w)).collect(); + by_box.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut best: Option<(f64, bool)> = None; + for (near, w) in by_box { + if best.is_some_and(|b| near >= b.0) { + break; + } + let d = away(w); + if best.is_none_or(|b| d < b.0) { + best = Some((d, w.dark)); + } + } + out.insert(t.id, best?.1); + } + Some(out) +} + +/// The legend labels of graph `n` (a pie with Legends in Wedges, its labels where it drew them), each with whether a +/// redraw sets it on a dark wedge, by the fill each wedge shows now. A label is placed by the point a redraw centers it +/// on, which lies inside its wedge: the wedge holding it of the series the label names (the nearest one when none holds +/// it), or of any series when the label names none. When the wedges of the series a label names are all dark or all +/// light, the label's place does not matter. +fn legend_on_dark(n: &Node, g: &GraphSpec) -> HashMap { + wedge_contrast(&legend_labels(n), &pie_wedges(n), g) +} + +/// The point a redraw centers legend label `text` of series `name` on, in a pie of spec `g` whose legend labels are +/// `ls` points: 0.35 of the label size above where a redraw (`label` in `labels.rs`) puts the middle of the label's +/// lines, at the label's origin across. The lines are those of the series name, split at `|` and line breaks, at the +/// default leading; with kept paragraph attributes, a redraw puts the middle of the first and last baselines there +/// instead, when each of those lines that holds characters has one baseline and the first and last hold characters. +fn label_anchor(text: &vectorcraft_doc::TextObject, name: &str, g: &GraphSpec, ls: f64) -> vectorcraft_geom::Point { + let lines: Vec<&str> = name.split(['|', '\n']).collect(); + let ys: Vec = text.baselines().map(|(a, _)| a.y).collect(); + let set = lines.iter().filter(|l| !l.is_empty()).count(); + let ends_set = lines.first().is_some_and(|l| !l.is_empty()) && lines.last().is_some_and(|l| !l.is_empty()); + let middle = match (ys.first(), ys.last()) { + (Some(first), Some(last)) if g.label_styles.legend_para.is_some() && ends_set && ys.len() == set => (first + last) / 2.0, + _ => lines.len().saturating_sub(1) as f64 * ls * 1.2 / 2.0, + }; + text.xf * vectorcraft_geom::Point::new(0.0, middle - ls * 0.35) +} + +/// [`legend_on_dark`] for legend labels `legend` of a pie of spec `g` with wedges `wedges`. +fn wedge_contrast(legend: &[&Node], wedges: &[Wedge], g: &GraphSpec) -> HashMap { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let mut out = HashMap::new(); + if legend.is_empty() { + return out; + } + let mut by_series: HashMap> = HashMap::new(); + for w in wedges { + by_series.entry(w.series).or_default().push(w); + } + // For each label text, the wedges of the series that show it, and how many of them are dark. + let named_wedges: HashMap, usize)> = series_by_label(g) + .into_iter() + .map(|(name, series)| { + let named: Vec<&Wedge> = series.iter().filter_map(|s| by_series.get(s)).flatten().copied().collect(); + let darks = named.iter().filter(|w| w.dark).count(); + (name, (named, darks)) + }) + .collect(); + let none = (vec![], 0); + // For each label text, the first series name that shows it (names that differ only in `|` against a line break of + // their own show the same text, and nothing in the label tells them apart). + let mut series_names: HashMap = HashMap::new(); + for name in &g.series { + series_names.entry(name.replace('|', "\n")).or_insert(name.as_str()); + } + let ls = label_size(&g.label_styles, Role::Legend); + // The search for the nearest wedge looks at this many wedges in all; past it, a label takes the contrast most of the + // wedges of its series show. + let mut budget: usize = 1 << 20; + for t in legend { + let NodeKind::Text(text) = &t.kind else { continue }; + let id = t.id; + let words = text.plain_text(); + let (named, darks) = named_wedges.get(&words).unwrap_or(&none); + let darks = *darks; + if (darks == 0 || darks == named.len()) + && let Some(w) = named.first() + { + out.insert(id, w.dark); + continue; + } + let name = series_names.get(&words).map_or_else(|| words.replace('\n', "|"), |s| (*s).to_owned()); + let at = label_anchor(text, &name, g, ls); + if named.is_empty() { + let cost = wedges.len(); + let dark = budget >= cost && wedges.iter().any(|w| w.dark && w.bounds.contains(at) && w.path.contains(at)); + budget = budget.saturating_sub(cost); + out.insert(id, dark); + continue; + } + if budget < named.len() { + out.insert(id, darks * 2 > named.len()); + continue; + } + budget -= named.len(); + // The nearest wedge, measured exactly only while its bounds are nearer than the nearest one found so far. + let to_box = |r: vectorcraft_geom::Rect| { + let (dx, dy) = ((r.x0 - at.x).max(at.x - r.x1).max(0.0), (r.y0 - at.y).max(at.y - r.y1).max(0.0)); + dx * dx + dy * dy + }; + let away = |w: &Wedge| { + if w.path.contains(at) { 0.0 } else { w.path.segments().map(|s| s.nearest(at, 1e-3).distance_sq).fold(f64::INFINITY, f64::min) } + }; + let mut by_box: Vec<(f64, &Wedge)> = named.iter().map(|w| (to_box(w.bounds), *w)).collect(); + by_box.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut best = (f64::INFINITY, darks * 2 > named.len()); + for (near, w) in by_box { + if near >= best.0 { + break; + } + let d = away(w); + if d < best.0 { + best = (d, w.dark); + } + } + out.insert(id, best.1); + } + out +} + /// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as /// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed. pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 6677edac31..ca1f1d2c45 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -4670,6 +4670,58 @@ fn group_selected_series_go_on_the_chosen_axis() { assert!(spec(&s, id).right_series.is_empty()); } +/// The Graph Type dialog's subject: the selected graph, the series picked in it, and the tick values each value axis +/// is drawn with while they are calculated, which are the ones the graph's value labels show. +#[test] +fn graph_type_subject_gives_the_picked_series_and_the_calculated_tick_values() { + let mut s = Session::new(); + assert!(super::graph_type_subject(&s).is_err(), "no document"); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + assert!(super::graph_type_subject(&s).is_err(), "no graph selected"); + // Q1 3, 2; Q2 5, 4; Q3 -1, 6. + let id = graph(&mut s, "column"); + let subject = super::graph_type_subject(&s).unwrap(); + assert_eq!((subject.id, subject.series), (id, vec![])); + assert_eq!(subject.calculated, [(-2.0, 6.0, 4); 2]); + let shown = |s: &Session| value_labels(s, id).into_iter().map(|(t, _)| t).collect::>(); + assert_eq!(shown(&s), ["-2", "0", "2", "4", "6"]); + // Overriding with them draws the same axis, and the calculated values stay the data's. + s.execute("graph.setType", &json!({"axisMin": -2, "axisMax": 6, "ticks": 4})).unwrap(); + assert_eq!(shown(&s), ["-2", "0", "2", "4", "6"]); + s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 100, "ticks": 0})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated[0], (-2.0, 6.0, 4)); + // A single bound is calculated with, as the axis is drawn. + s.execute("graph.setType", &json!({"axisMin": -10, "axisMax": null})).unwrap(); + let (lo, hi, _) = super::graph_type_subject(&s).unwrap().calculated[0]; + let labels = shown(&s); + assert!(lo <= -10.0, "{lo}"); + assert_eq!((labels.first().and_then(|t| t.parse::().ok()), labels.last().and_then(|t| t.parse::().ok())), (Some(lo), Some(hi))); + s.execute("graph.setType", &json!({"axisMin": null, "axisMax": null})).unwrap(); + // Stacked columns stack their positive and their negative values: Q2 reaches 9, Q3 from -1 to 6. + s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated[0], (-2.0, 10.0, 6)); + assert_eq!(shown(&s), ["-2", "0", "2", "4", "6", "8", "10"]); + // A radar graph's axis starts at 0, and a pie graph has none. + s.execute("graph.setType", &json!({"type": "radar"})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 6.0, 3); 2]); + s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 1.0, 5); 2]); + // Series picked with Group Selection. + s.execute("graph.setType", &json!({"type": "column"})).unwrap(); + let picked = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + s.execute("select.set", &json!({"ids": [picked.0]})).unwrap(); + let subject = super::graph_type_subject(&s).unwrap(); + assert_eq!((subject.id, subject.series), (id, vec![1])); + // Separate Scales: the right axis has the tick values of its own series. + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 1000.0, 5); 2]); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 10.0, 5), (0.0, 1000.0, 5)]); + assert_eq!(spec(&s, id).right_series, [1]); +} + #[test] fn bar_graphs_keep_their_value_axis_along_the_bottom() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/info.rs b/crates/engine/src/cmd/info.rs new file mode 100644 index 0000000000..2f99e9aab2 --- /dev/null +++ b/crates/engine/src/cmd/info.rs @@ -0,0 +1,163 @@ +//! Window › Info (`info.readout`): the selection's size and the active tool's readout, as the Info +//! panel shows them. + +use serde_json::{Value, json}; +use vectorcraft_render::effects; + +use super::{CommandSpec, Result, Session, always, cmd}; + +pub fn specs() -> Vec { + vec![cmd!( + query "info.readout", + "Info", + [], + None, + "{} → {tool: the active tool's id, width?, height?: the sides of the selection's bounding box in pt (the box around the anchors selected with Direct Selection, when there are some), measured as prefs usePreviewBounds says; left out with nothing selected, readout?: what the Info panel shows for the tool in use: {kind: \"drag\", dx, dy, distance, angle} while the Selection, Direct Selection or Group Selection tool moves the selection or the Gradient tool draws a gradient vector on the art; {kind: \"scale\", width, height} (percent) during a Scale tool drag; {kind: \"rotate\", center: [x, y], angle: deg counter-clockwise, as object.rotate takes it} and {kind: \"reflect\", center, angle: the axis in deg counter-clockwise from horizontal, as object.reflect takes it} during a Rotate or Reflect tool drag; {kind: \"shear\", center, axis: 0 for a horizontal shear and 90 for a vertical one, angle: deg, as object.shear takes it} during a Shear tool drag; {kind: \"brush\", brush: name|null} while the Paintbrush is the tool; left out for any other tool and between drags. Lengths are in pt, a drag's angle is in deg counter-clockwise from the x axis, and center is the center of the selection's bounding box as the drag has carried it, in document coordinates}", + always, + readout + )] +} + +impl Session { + /// The sides of the selection's bounding box as the Info panel shows them: of the box around + /// the anchors selected with Direct Selection when there are some, else of the bounding box + /// rotated with rotated objects ([`Session::transform_box`]), measured as Use Preview Bounds + /// says. None with nothing selected. + pub fn info_size(&self) -> Option<(f64, f64)> { + let st = self.active()?; + let anchors = effects::preview::anchor_bounds_with(&st.selection, &st.doc, self.bounds_measure()); + let r = anchors.or_else(|| self.transform_box(&st.selection.objects).map(|b| b.rect))?; + Some((r.width(), r.height())) + } +} + +fn readout(s: &mut Session, _: &Value) -> Result { + let mut out = json!({ "tool": s.tool_id() }); + if let Some((w, h)) = s.info_size() { + out["width"] = json!(w); + out["height"] = json!(h); + } + if let Some(r) = s.tool_readout() { + out["readout"] = r.to_json(); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_tools::{PointerEvent, PointerKind}; + + use crate::{Session, ViewInfo}; + + fn view() -> ViewInfo { + ViewInfo { smart_guides: false, snap_to_point: false, ..Default::default() } + } + + /// A 100 by 60 rectangle at (50, 50), selected, with a 10 pt stroke. + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 60})).unwrap()["id"].clone(); + s.execute("select.set", &json!({ "ids": [id] })).unwrap(); + s.execute("stroke.set", &json!({"weight": 10})).unwrap(); + s + } + + fn tool(s: &mut Session, id: &str) { + s.select_tool(id, view()).unwrap(); + } + + fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::new(kind, x, y), view()).unwrap(); + } + + fn info(s: &mut Session) -> serde_json::Value { + s.execute("info.readout", &json!({})).unwrap() + } + + fn near(v: &serde_json::Value, want: f64) -> bool { + v.as_f64().is_some_and(|v| (v - want).abs() < 1e-6) + } + + #[test] + fn the_size_follows_use_preview_bounds() { + let mut s = session(); + let i = info(&mut s); + assert!(near(&i["width"], 100.0) && near(&i["height"], 60.0), "{i}"); + assert!(i.get("readout").is_none(), "{i}"); + s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": true})).unwrap(); + let i = info(&mut s); + assert!(near(&i["width"], 110.0) && near(&i["height"], 70.0), "{i}"); + s.execute("select.none", &json!({})).unwrap(); + let i = info(&mut s); + assert!(i.get("width").is_none() && i.get("height").is_none(), "{i}"); + } + + #[test] + fn a_move_reads_how_far_it_has_gone_until_the_release() { + let mut s = session(); + tool(&mut s, "selection"); + pointer(&mut s, PointerKind::Down, 100.0, 80.0); + assert!(info(&mut s).get("readout").is_none()); + pointer(&mut s, PointerKind::Drag, 130.0, 40.0); + let i = info(&mut s); + let r = &i["readout"]; + assert_eq!(r["kind"], "drag", "{i}"); + assert!(near(&r["dx"], 30.0) && near(&r["dy"], -40.0) && near(&r["distance"], 50.0), "{i}"); + assert!(near(&r["angle"], 53.13010235415598), "{i}"); + pointer(&mut s, PointerKind::Up, 130.0, 40.0); + let i = info(&mut s); + assert!(i.get("readout").is_none(), "{i}"); + assert!(near(&i["width"], 100.0), "{i}"); + } + + #[test] + fn each_transform_tool_reads_its_drag() { + for (id, check) in [ + ("scale", (|r| r["kind"] == "scale" && near(&r["width"], 200.0) && near(&r["height"], 200.0)) as fn(&serde_json::Value) -> bool), + ("rotate", |r| r["kind"] == "rotate" && near(&r["center"][0], 100.0) && near(&r["center"][1], 80.0) && near(&r["angle"], -90.0)), + ("reflect", |r| r["kind"] == "reflect" && near(&r["center"][0], 100.0) && near(&r["angle"], 0.0)), + ("shear", |r| { + r["kind"] == "shear" + && near(&r["center"][0], 100.0) + && near(&r["center"][1], 80.0) + && near(&r["axis"], 0.0) + && near(&r["angle"], 45.0) + }), + ] { + let mut s = session(); + tool(&mut s, id); + // The reference point is the box's center (100, 80). + let from = if id == "shear" { (100.0, 60.0) } else { (120.0, 100.0) }; + pointer(&mut s, PointerKind::Down, from.0, from.1); + let to = match id { + "scale" => (140.0, 120.0), + "rotate" => (80.0, 100.0), + "reflect" => (160.0, 80.0), + _ => (120.0, 60.0), + }; + pointer(&mut s, PointerKind::Drag, to.0, to.1); + let i = info(&mut s); + assert_eq!(i["tool"], id); + assert!(check(&i["readout"]), "{id}: {i}"); + pointer(&mut s, PointerKind::Up, to.0, to.1); + assert!(info(&mut s).get("readout").is_none(), "{id}"); + } + } + + #[test] + fn the_paintbrush_reads_its_brush() { + let mut s = session(); + tool(&mut s, "paintbrush"); + let i = info(&mut s); + assert_eq!(i["readout"], json!({"kind": "brush", "brush": null}), "{i}"); + } + + #[test] + fn a_parameter_is_refused() { + let mut s = session(); + let e = s.execute("info.readout", &json!({"tool": "scale"})).unwrap_err().to_string(); + assert!(e.contains("tool"), "{e}"); + } +} diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index c3a2898571..d55fdda7b2 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -292,8 +292,22 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str> .collect()) } +/// The key anchor ([`vectorcraft_doc::Selection::key_anchor`]) when Align and Distribute act on the +/// selected anchor points and align to the key ([`align_to`]): the path and the point. +fn key_anchor(s: &Session, p: &Value) -> Option<(NodeId, (usize, usize))> { + (align_to(s, p) == Some("key")).then(|| s.doc().ok()?.selection.key_anchor).flatten() +} + +/// Where the key anchor of [`key_anchor`] is. +fn key_anchor_point(s: &Session, p: &Value) -> Option { + key_anchor(s, p)?; + let st = s.doc().ok()?; + st.selection.key_anchor_point(&st.doc) +} + /// [`align`] of the selected anchor points (whole paths among them give all theirs): each point -/// moves to the edge or centre of the points' bounds, or of the artboard with Align To Artboard. +/// moves to the edge or centre of the points' bounds, of the artboard with Align To Artboard, or to +/// the key anchor when aligning to it. fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&str>, Option<&str>)) -> Result { let targets = crate::cmd::path::anchor_targets(s)?; let d = &s.doc()?.doc; @@ -304,9 +318,10 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else { return ok(); }; - // shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does - // (or to a key guide). - let (r, along_x, along_y) = align_reference(s, p, ids, Measure::default(), Some(bounds))?; + // Aligning to the key, the points go to the key anchor, which stays where it is. Without one, + // a key object aligns them to their bounds as Selection does (a key guide lines them up on it). + let own = key_anchor_point(s, p).map_or(bounds, |k| Rect::from_points(k, k)); + let (r, along_x, along_y) = align_reference(s, p, ids, Measure::default(), Some(own))?; let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y)); let target = |q: Point| { let x = match h { @@ -410,9 +425,10 @@ fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: /// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them /// give all theirs): the points between the first and the last along the axis are spaced evenly, -/// or `gap` apart from the first. A point has no size, so every Distribute option of an axis -/// gives the same result. -fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option) -> Result { +/// or `gap` apart from the first. With a gap and a key anchor `key` among them, the key anchor stays +/// where it is and the others are placed `gap` apart outward from it, in their order along the +/// axis. A point has no size, so every Distribute option of an axis gives the same result. +fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option, key: Option<(NodeId, (usize, usize))>) -> Result { let targets = crate::cmd::path::anchor_targets(s)?; let d = &s.doc()?.doc; let along = |q: Point| if horiz { q.x } else { q.y }; @@ -431,8 +447,11 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option = - pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, first + step * i as f64 - at)).collect(); + pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, at_from + step * (i as f64 - from as f64) - at)).collect(); if moves.iter().all(|m| m.3.abs() <= 1e-9) { return ok(); } @@ -466,11 +485,13 @@ pub(super) fn distribute(s: &mut Session, p: &Value) -> Result { let m = measure(s, p, "object.distribute")?; let board = distribute_to(s, p, &ids, m, "object.distribute")?; let spacing = spacing(p, "object.distribute")?; - // Anchor points selected with Direct Selection are distributed themselves, as Align aligns them. + // Anchor points selected with Direct Selection are distributed themselves, as Align aligns them; + // with a key anchor, a spacing places them from it. if !s.doc()?.selection.anchors.is_empty() { + let key = key_anchor(s, p); return match direction(p, "object.distribute")? { (None, None) => Err(bad("object.distribute", "give horizontal or vertical")), - (h, _) => distribute_anchors(s, "Distribute", h.is_some(), None), + (h, _) => distribute_anchors(s, "Distribute", h.is_some(), spacing.filter(|_| key.is_some()), key), }; } let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? { @@ -543,7 +564,8 @@ pub(super) fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let board = distribute_to(s, p, &ids, m, "object.distributeSpacing")?; let spacing = spacing(p, "object.distributeSpacing")?; if !s.doc()?.selection.anchors.is_empty() { - return distribute_anchors(s, "Distribute Spacing", horiz, spacing); + let key = key_anchor(s, p); + return distribute_anchors(s, "Distribute Spacing", horiz, spacing, key); } // The selected guides across the axis are spaced with the objects, as items with no size (#1597). let mut items = spaced_items(s, &ids, m, horiz)?; diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index b8c4199623..3ea501c775 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -260,7 +260,7 @@ pub fn specs() -> Vec { "Align", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, to the artboard (#1180), or, aligning to the key with a key anchor (select.key {id, anchor}, the last point a Shift-click added), to the key anchor, which stays put", has_selection_or_guides, align ), @@ -269,7 +269,7 @@ pub fn specs() -> Vec { "Distribute", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key, or with anchor points selected and no key anchor), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; with anchor points selected, with \"selection\" or \"artboard\" the key anchor is not the key and all the points are spaced as without one; not used with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636); with spacing and a key anchor (select.key {id, anchor}), the key anchor stays put and the others are placed spacing apart, outward from it, in their order along the axis", can_distribute, distribute ), @@ -278,7 +278,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", + "{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing, and with a key guide; with anchor points selected, with \"selection\" or \"artboard\" the key anchor is not the key and all the points are spaced as without one; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636), or with a key anchor (select.key {id, anchor}) `spacing` apart outward from the key anchor, which stays put", can_distribute, distribute_spacing ), diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 8f4e3c71f4..20905ffdae 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\"|\"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), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), 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), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), 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), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs; a form other than \"default\" takes away their glyph choice), 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), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), 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), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), 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), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs; a form other than \"default\" takes away their glyph choice), 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\"|\"standardSingleLine\"|\"standardEveryLine\" (line breaking, Every-line by default; singleLine and everyLine are the Middle Eastern & South Asian composers, which shape Indic, Arabic and Hebrew type; the standard ones set it glyph by glyph as the font maps its characters, for legacy fonts that keep a script on Latin character codes), 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 ), @@ -255,7 +255,7 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s /// list of artboard indices", and a list with an entry that is not a whole number is "`{key}` must /// contain whole numbers"; a whole number past the last artboard is `no such artboard`. Every entry /// is checked for its form before any is checked against the artboards. -fn listed_artboards(d: &vectorcraft_doc::Document, v: &Value, key: &str, cmd: &str) -> Result> { +pub(crate) fn listed_artboards(d: &vectorcraft_doc::Document, v: &Value, key: &str, cmd: &str) -> Result> { let entries = v.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be a list of artboard indices")))?; if entries.is_empty() { return Err(bad(cmd, format!("`{key}` must be a non-empty list of artboard indices"))); @@ -645,7 +645,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { Some(v) => Some(match v.as_str() { Some("singleLine") => Composer::SingleLine, Some("everyLine") => Composer::EveryLine, - _ => return Err(bad(C, "composer must be \"singleLine\" or \"everyLine\"")), + Some("standardSingleLine") => Composer::StandardSingleLine, + Some("standardEveryLine") => Composer::StandardEveryLine, + _ => return Err(bad(C, "composer must be \"singleLine\", \"everyLine\", \"standardSingleLine\" or \"standardEveryLine\"")), }), }; let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?; diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index db1edc98e9..fdf7dd4b19 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -369,6 +369,12 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> { return Err(bad(id, "`origin` must be [x, y] in points")); } + if effect == vectorcraft_render::effects::SVG_FILTER + && let Some(axes) = given.get("axes") + && vectorcraft_render::effects::svg_filter::valid_axes(axes).is_none() + { + return Err(bad(id, "`axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0")); + } // Its name is the filter's id. if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") { return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`")); @@ -529,7 +535,6 @@ pub const UNCHECKED: &[&str] = &[ "assets.settings.set", "attributes.info", "attributes.set", - "brush.freehand", "css.export", "css.generate", "css.selection", diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 0fb450fa43..4d160c7c49 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling, Selection}; -use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2}; +use vectorcraft_geom::{Anchor, AnchorKind, Anchors, PathData, Point, SubPath, Vec2}; use super::create::anchor_from_json; use super::draw2::usize_opt; @@ -544,10 +544,12 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: && before > 2 && picked(0) && picked(before - 1) - && let [first, .., last] = sp.anchors.as_mut_slice() - && same(first, last) + && let (Some(&first), Some(&last)) = (sp.anchors.first(), sp.anchors.last()) + && same(&first, &last) { - first.h_in = last.h_in; + if let Some(first) = sp.anchors.first_mut() { + first.h_in = last.h_in; + } sp.anchors.pop(); if let Some(smooth) = join_kind { join_point(sp, 0, smooth); @@ -565,10 +567,12 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: for i in (1..len).rev() { if picked(i - 1) && picked(i) - && let Some([a, b]) = anchors.get_mut(i - 1..=i) - && same(a, b) + && let (Some(&a), Some(&b)) = (anchors.get(i - 1), anchors.get(i)) + && same(&a, &b) { - a.h_out = b.h_out; + if let Some(a) = anchors.get_mut(i - 1) { + a.h_out = b.h_out; + } if let Some(slot) = gone.get_mut(i) { *slot = true; } @@ -603,7 +607,7 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: } /// [`join_point`] on anchor `ai` of `anchors` with its neighbors given by index. -fn join_point_between(anchors: &mut [Anchor], ai: usize, prev: Option, next: Option, smooth: bool) { +fn join_point_between(anchors: &mut Anchors, ai: usize, prev: Option, next: Option, smooth: bool) { let at = |j: Option| j.and_then(|j| anchors.get(j)).map(|a| a.p); let (prev, next) = (at(prev), at(next)); if let Some(a) = anchors.get_mut(ai) { @@ -1565,7 +1569,7 @@ enum Stretch { /// anchor, so the two handles of a smooth anchor stay in line with it. That share is below 0 or /// above 1 for a handle long enough beside the span. A closed subpath with no anchor that stays /// moves whole when it has a focal point. -fn stretch_weights(anchors: &[Anchor], closed: bool, roles: &[Stretch]) -> Vec<[f64; 3]> { +fn stretch_weights(anchors: &Anchors, closed: bool, roles: &[Stretch]) -> Vec<[f64; 3]> { let n = anchors.len(); let mut shares = vec![[0.0; 3]; n]; let role = |i: usize| match roles.get(i) { @@ -1891,10 +1895,10 @@ mod tests { && before > 2 && picked(0) && picked(before - 1) - && let [first, .., last] = sp.anchors.as_mut_slice() - && same(first, last) + && let (Some(&first), Some(&last)) = (sp.anchors.first(), sp.anchors.last()) + && same(&first, &last) { - first.h_in = last.h_in; + sp.anchors[0].h_in = last.h_in; sp.anchors.pop(); if let Some(smooth) = join_kind { join_point(sp, 0, smooth); @@ -1903,10 +1907,10 @@ mod tests { for i in (1..sp.anchors.len()).rev() { if picked(i - 1) && picked(i) - && let Some([a, b]) = sp.anchors.get_mut(i - 1..=i) - && same(a, b) + && let (Some(&a), Some(&b)) = (sp.anchors.get(i - 1), sp.anchors.get(i)) + && same(&a, &b) { - a.h_out = b.h_out; + sp.anchors[i - 1].h_out = b.h_out; sp.anchors.remove(i); if let Some(smooth) = join_kind { join_point(sp, i - 1, smooth); diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 114f5b4f61..83216b2f43 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -4,6 +4,10 @@ //! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution //! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with //! `link` ([`super::links`]). +//! - A PSD or PSB file placed with `layers` becomes one group of its layers as objects, embedded +//! ([`fileio::psd_layers`]); otherwise it is an image like any other, its merged image. An +//! embedded one brings its slices in as user slices, placed with its art (`importSlices`, +//! [`fileio::psd_slices`]). //! - 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, WMF or PICT picture becomes one group, clipped to its frame (`crop: "crop"`) or @@ -45,7 +49,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, Scaling, SymbolBoxes}; +use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, Scaling, SliceOptions, SymbolBoxes}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_pdf::CropTo; @@ -75,7 +79,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, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; 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), showImportOptions?: true (in the app, a text file without text, a DXF drawing without dxf and a PDF with several pages or a password and no page ask for those options first; false places them with the options last confirmed, a PDF on page 1 (or page), and asks only an encrypted PDF's password)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. 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", + "{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, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; 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), layers?: false (a PSD or PSB file: true embeds its layers as one group named after the file, read as document.open reads them: an image for each layer and a group for each layer group, with the layer's name, visibility, opacity, blend mode and opacity mask, at the place its merged image would take and clipped to the canvas when layers, hidden ones included, reach past it; never linked: link is then false unless given, and link: true is refused; a file without layers is embedded as its merged image, and so are one whose layers are all left out and one whose layers can't be read, with a warning), hiddenLayers?: false (with layers: true, the hidden layers come in hidden instead of being left out; refused without it), importSlices?: bool (a PSD or PSB file: its slices become user slices, moved and scaled with the placed art, in the same undo step; default true when the file is embedded, false when it is linked, and true with link is refused; slices the file generated automatically are left out), 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), showImportOptions?: true (in the app, a text file without text, a DXF drawing without dxf, a PSD or PSB file embedded without layers (with two layers or more, or with slices) and a PDF with several pages or a password and no page ask for those options first; false places them with the options last confirmed, a PDF on page 1 (or page), and asks only an encrypted PDF's password)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take, slices?: [the ids of the user slices a PSD file brought in]}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. 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 ), @@ -84,7 +88,7 @@ pub fn specs() -> Vec { "Placed File Info", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), page?, crop?, password?, thumbnail?: px, link?: bool, dxf?: {…} (both as file.place)} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), plates?: [ink names] (a DCS file), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}", + "{path | name | dataBase64 (a file's path, or its name with its bytes), page?, crop?, password?, thumbnail?: px, link?: bool, dxf?: {…}, layers?, hiddenLayers?, importSlices? (all as file.place)} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), plates?: [ink names] (a DCS file), slices?: the user slices a PSD or PSB file would bring in, warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}", always, info ), @@ -102,7 +106,7 @@ pub fn specs() -> Vec { "Load Place Cursor", [], None, - "{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, dxf?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}", + "{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, dxf?, layers?, hiddenLayers?, importSlices?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}", has_doc, queue ), @@ -148,16 +152,33 @@ struct Loaded { board: Option, } +/// A PSD or PSB file's slices, embedded with `importSlices`: their rectangles over the natural box +/// of its art, and their options. +type Slices = Vec<(Rect, SliceOptions)>; + /// Points per pixel of a raster image at the resolution it declares (72 ppi when none). pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) { let (x, y) = ppi.unwrap_or((72.0, 72.0)); (72.0 / x, 72.0 / y) } +/// [`load_sliced`] without the slices. +fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result { + load_into(p, cmd, board, link_pdf, &mut vec![]) +} + +/// [`load_into`], with the slices of a PSD or PSB file that `importSlices` brings in. +fn load_sliced(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result<(Loaded, Slices)> { + let mut slices = vec![]; + let l = load_into(p, cmd, board, link_pdf, &mut slices)?; + Ok((l, slices)) +} + /// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board` /// (unless the params name their own box). A PDF/.ai page read from a path is placed linked with -/// Link when `link_pdf` (false for the queries, which read it embedded). -fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result { +/// Link when `link_pdf` (false for the queries, which read it embedded). A PSD or PSB file's slices +/// go to `slices`. +fn load_into(p: &Value, cmd: &str, board: Option, link_pdf: bool, slices_out: &mut Slices) -> Result { let src = fileio::source(p, cmd)?; let name = fileio::file_name(src.name); // An Affinity file is recognised by its signature, whatever its name says. @@ -237,12 +258,52 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result match psd_groups(doc) { + Some((art, natural)) => { + *slices_out = psd_slices(slices_from, natural, &mut warnings); + return Ok(Loaded { name, format, art, natural, warnings, link: None, board: None }); + } + // No layer has art: the merged image, embedded. + None => unread = warnings, + }, + // No layers: the merged image, embedded. + Ok(None) => {} + Err(e) => unread.push(format!("the layers can't be read ({e}): the file was placed as its merged image")), + } + } + } if format.raster { let img = fileio::raster_image(&src.bytes)?; let (sx, sy) = pt_per_px(img.ppi); let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy); - let link = src.path.map(|path| super::links::link_info(path, &src.bytes)); - let warnings = fileio::size_note(&img.blob.bytes).into_iter().collect(); + let link = src.path.filter(|_| !embed).map(|path| super::links::link_info(path, &src.bytes)); + let mut warnings: Vec = unread.into_iter().chain(fileio::size_note(&img.blob.bytes)).collect(); + *slices_out = psd_slices(slices_from, natural, &mut warnings); let clip = fileio::psdread::clipping_path(&src.bytes); return Ok(Loaded { name, format, art: Art::Image(img, clip), natural, warnings, link, board: None }); } @@ -303,6 +364,56 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result Option<(Art, Rect)> { + let canvas = doc.artboards.first()?.rect; + let nodes = psd_objects(&std::mem::take(&mut doc.layers)); + // Hidden layers count too: showing one later must not draw past the canvas. + let symbols = doc.symbol_boxes(); + let reached = nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, reach(&symbols, n)))?; + let past = reached.union(canvas) != canvas; + Some((Art::Vector { src: Box::new(doc), nodes, clip: past.then_some(canvas) }, canvas)) +} + +/// The slices of the PSD or PSB file `bytes` (none without it) laid over `canvas`, the natural box +/// of its art; their warnings go to `warnings`. +fn psd_slices(bytes: Option<&[u8]>, canvas: Rect, warnings: &mut Vec) -> Slices { + let Some(bytes) = bytes else { return vec![] }; + let (slices, notes) = fileio::psd_slices(bytes, canvas); + warnings.extend(notes); + slices +} + +/// The bounds of `n` and every object inside it, hidden ones included. +fn reach(symbols: &SymbolBoxes, n: &Node) -> Option { + match n.children() { + Some(kids) => kids.iter().fold(None, |acc, k| vectorcraft_geom::union_opt(acc, reach(symbols, k))), + None => n.geometric_bounds_with(symbols), + } +} + +/// The layers a PSD import made as objects: a layer of sublayers as a group of them, and a layer +/// holding one image as that image, each with the layer's name, visibility, opacity, blend mode, +/// isolation and opacity mask. +fn psd_objects(layers: &[Arc]) -> Vec { + layers + .iter() + .map(|l| { + let mut n = (**l).clone(); + let NodeKind::Layer { children, .. } = &l.kind else { return n }; + n.kind = match &children[..] { + [c] if matches!(c.kind, NodeKind::Image(_)) => c.kind.clone(), + _ => NodeKind::Group { children: psd_objects(children).into_iter().map(Arc::new).collect(), clip: false }, + }; + n + }) + .collect() +} + /// Page `page` (1-based) of the PDF/.ai/.ait file `src` (`bytes`, read from `path` when there is /// one) as art to place: clipped to the box `opts.crop` names, or (`bounding`) the bounds of its /// art; read with `opts.password`. Only the page placed is read. A .ai saved without PDF @@ -495,14 +606,14 @@ fn at_param(p: &Value) -> Result> { /// The transform that puts new art with the natural box `natural` where `old` is: an image's scale /// and rotation relative to its 100% size (its resolution's) and its centre are kept (with -/// `image_xf`), also for an image alone in a clip group when the new art is an image clipped by -/// its file's clipping path (`clipped`); other objects keep their centre. -fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool, clipped: bool) -> Affine { +/// `image_xf`), also for an image alone in a clip group when the new art is an image, clipped by +/// its file's clipping path or not (`new_image`); other objects keep their centre. +fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool, new_image: bool) -> Affine { // An image alone in a clip group (as a PSD or PSB file's clipping path places it, - // [`fileio::file_clipped`]) is measured as the image when the new art brings its own clip - // group; a DCS file's plates are not. + // [`fileio::file_clipped`]) is measured as the image when the new art is an image; a DCS + // file's plates are not. let image = match &old.kind { - NodeKind::Group { children, clip: true } if clipped => match &children[..] { + NodeKind::Group { children, clip: true } if new_image => match &children[..] { [path, image] if matches!(path.kind, NodeKind::Path { clipping: true, .. }) => match &image.kind { NodeKind::Image(im) if d.images.get(&im.key).is_none_or(|b| b.separated().is_none()) => image, _ => old, @@ -548,6 +659,7 @@ pub struct PlaceJob { pub struct PlaceRead { job: PlaceJob, loaded: Loaded, + slices: Slices, } impl PlaceJob { @@ -562,7 +674,7 @@ impl PlaceJob { /// the renderers' image store for the first frame that draws them /// ([`super::links::readable_and_kept`]). pub fn read(self) -> Result { - let mut loaded = load(&self.params, PLACE, self.target, true)?; + let (mut loaded, slices) = load_sliced(&self.params, PLACE, self.target, true)?; let linked = loaded.link.is_some() && bool_or(&self.params, "link", true); match &mut loaded.art { Art::Image(img, _) => { @@ -572,7 +684,7 @@ impl PlaceJob { Art::Dcs(a) => a.img.blob = super::links::readable(a.img.blob.clone(), false, true)?, _ => {} } - Ok(PlaceRead { job: self, loaded }) + Ok(PlaceRead { job: self, loaded, slices }) } } @@ -653,7 +765,7 @@ fn prepare(s: &Session, p: &Value) -> Result { /// Place the file `read` holds in the active document. fn apply(s: &mut Session, read: PlaceRead) -> Result { - let PlaceRead { job: PlaceJob { params, old, target: read_for, .. }, mut loaded } = read; + let PlaceRead { job: PlaceJob { params, old, target: read_for, .. }, mut loaded, slices: read_slices } = read; let p = ¶ms; let template = bool_or(p, "template", false); let at = at_param(p)?; @@ -681,7 +793,7 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { } let natural = loaded.natural; let m = match (old_node, rect) { - (Some(o), _) => replace_xf(&st.doc, o, natural, !is_text, matches!(loaded.art, Art::Image(_, Some(_)))), + (Some(o), _) => replace_xf(&st.doc, o, natural, !is_text, matches!(loaded.art, Art::Image(..))), (None, Some(r)) => fit(natural, r), // Its artboard's bottom-left corner on the target's. (None, None) if let (Some(src), Some(to)) = (loaded.board, target) => Affine::translate((to.x0 - src.x0, to.y1 - src.y1)), @@ -699,7 +811,22 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { if let Art::Dcs(a) = &loaded.art { warnings.extend(dcs::swatch_notes(&st.doc, &a.spots)); } - let (id, size) = s.edit("Place", |d, sel| { + // A PSD file's slices move and scale with its art; one that would leave the canvas is left out. + let read_count = read_slices.len(); + let slices: Slices = read_slices + .into_iter() + .filter_map(|(r, o)| { + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| m * c); + let placed = corners.iter().skip(1).fold(Rect::from_points(corners[0], corners[0]), |acc, c| acc.union_pt(*c)); + super::slices::slice_rect_ok(placed).then_some((placed, o)) + }) + .collect(); + match read_count - slices.len() { + 0 => {} + 1 => warnings.push("1 slice would lie outside the canvas and was left out".into()), + n => warnings.push(format!("{n} slices would lie outside the canvas and were left out")), + } + let (id, size, slice_ids) = s.edit("Place", |d, sel| { let node = transformed(build(d, loaded, link), m); let size = node.geometric_bounds_with(&d.symbol_boxes()).unwrap_or_default(); let (into, index) = match old { @@ -712,15 +839,19 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { None => (parent, usize::MAX), }; let id = d.insert(into, index, node)?; + let slice_ids: Vec = slices.into_iter().map(|(r, o)| super::slices::add_slice(d, r, o).0).collect(); // Template art is locked: nothing to select. if template { sel.clear() } else { sel.set([id]) } - Ok((id, size)) + Ok((id, size, slice_ids)) })?; let mut out = json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }); + if !slice_ids.is_empty() { + out["slices"] = json!(slice_ids); + } // A DCS, PSD or PSB file clipped by its clipping path: its image, which the Links commands // take. if file_clipped { @@ -824,7 +955,7 @@ fn read_queue(doc: u64, p: Value, queued: Vec) -> Result { let thumbnail = p.get("thumbnail").and_then(Value::as_f64); let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]); for mut q in queued { - for k in ["link", "template", "page", "crop", "text", "dxf"] { + for k in ["link", "template", "page", "crop", "text", "dxf", "layers", "hiddenLayers", "importSlices"] { if let Some(v) = p.get(k) { q[k] = v.clone(); } @@ -870,7 +1001,7 @@ impl Session { /// desktop the Place dialog's OK with several files does this off the UI thread). pub fn prepare_place_queue(&self, params: &Value) -> Result { has_doc(self).map_err(|why| EngineError::Disabled(QUEUE.into(), why))?; - check_switches(params, &["link", "template"], QUEUE)?; + check_switches(params, &["link", "template", "layers", "hiddenLayers", "importSlices"], QUEUE)?; check_numbers(params, &["thumbnail"], QUEUE)?; let files = queued_files(params)?; Ok(QueueJob { doc: self.doc()?.uid, params: params.clone(), files }) @@ -948,7 +1079,7 @@ pub fn read_info(p: &Value) -> Result { check_switches(p, &["link"], "file.place.info")?; check_numbers(p, &["thumbnail"], "file.place.info")?; // A PDF/.ai page linked or not has the same size and art. - let mut l = load(p, "file.place.info", None, false)?; + let (mut l, slices) = load_sliced(p, "file.place.info", None, false)?; let natural = l.natural; let mut out = json!({ "name": l.name, @@ -957,6 +1088,9 @@ pub fn read_info(p: &Value) -> Result { "height": natural.height(), "warnings": std::mem::take(&mut l.warnings), }); + if matches!(l.format.id, "psd" | "psb") { + out["slices"] = json!(slices.len()); + } if let Art::Image(img, _) = &l.art { let (x, y) = img.ppi.unwrap_or((72.0, 72.0)); out["pixelWidth"] = json!(img.width); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 2b1826328e..67d0fec2a3 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -206,6 +206,7 @@ pub(super) const ALLOWED: &[&str] = &[ "imageTrace.makeAndExpand", "imageTrace.release", "imageTrace.setView", + "info.readout", "layer.clippingMask.toggle", "layer.collectInNew", "layer.delete", @@ -693,6 +694,7 @@ pub(super) const ALLOWED: &[&str] = &[ "variable.makeTextDynamic", "variable.makeVisibilityDynamic", "variable.rename", + "variable.sort", "variable.unbind", ]; diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 8f5f532c51..5f412e080d 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -334,10 +334,19 @@ pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: boo /// The image Object › Rasterize makes of `n` with the options of `n`'s Effect › Rasterize, when /// that is its only visible raster effect and is its own (not a fill's or a stroke's): made of `n` -/// without that effect and without its transparency, which stays on the object. `None` otherwise, -/// or when the image can't be made. +/// without that effect and without its transparency, which stays on the object. When Transform +/// effects after the Rasterize effect move its image ([`vectorcraft_render::rasterize_stage`]), +/// the image is made of the art before them and moved by them, as the canvas draws it. `None` +/// otherwise, when those Transform effects flatten the image, or when the image can't be made. fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option { let [RasterFx::Rasterize(r)] = raster_fx(n)[..] else { return None }; + let staged = vectorcraft_render::rasterize_stage(d, n); + if let Some((_, map)) = &staged + && !(map.determinant().is_finite() && map.determinant().abs() > 1e-12) + { + return None; + } + let n = staged.as_ref().map_or(n, |(stage, _)| stage); let at = n.appearance.effects.iter().position(|e| e.visible && e.id == effects::RASTERIZE)?; let p = json!({ "ppi": r.ppi.unwrap_or(d.raster_effects_ppi), @@ -354,7 +363,11 @@ fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option { // shortcut: expanded Rasterize effects stay RGB, as the object keeps its own opacity mask and the // image's transparency would need one too; upgrade when CMYK images carry alpha. let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize", false).ok()?; - Some(image.into_node(d)) + let mut image = image.into_node(d); + if let Some((_, map)) = staged { + image.transform(map, false); + } + Some(image) } /// `v` (an object without its raster effects) replaced by `image` of the object with them: the @@ -473,7 +486,9 @@ fn imaged(n: &Node, which: Which) -> bool { let live = filters && pixels == 1 && effects::svg_filter::writes_live(&n.appearance.effects) - && fx.iter().any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if s.in_points() && (!s.measured() || fx.len() == 1) && s.writes_as_svg11())); + && fx + .iter() + .any(|f| matches!(f, RasterFx::Pixel(effects::PixelFx::Svg(s)) if (!s.measured() || fx.len() == 1) && s.writes_as_svg11() && s.opens_again())); (pixels > 0 && !live) || (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx))) } @@ -589,6 +604,112 @@ fn walk_all(out: &mut Document, nodes: &[Arc], report: &mut FlattenReport) .then(|| nodes.iter().map(|n| walk(out, n, report).map(Arc::new).unwrap_or_else(|| n.clone())).collect()) } +/// `n` as an image when its Rasterize image moves with the Transform effects after it +/// ([`vectorcraft_render::rasterize_stage`]): the image is made of the art before those Transform +/// effects and moved by them, as the canvas draws it, which baking the geometry effects first would +/// lose. A map that flattens the image leaves an empty group, as the canvas draws nothing. Inside +/// groups, layers and opacity masks, the members are looked at the same way. `None` when nothing in +/// `n` changes; hidden objects and template layers stay as they are. +fn staged(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option { + if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) { + return None; + } + if let Some((_, map)) = vectorcraft_render::rasterize_stage(out, n) { + let mut v = n.clone(); + strip_raster(&mut v, false); + let det = map.determinant(); + if !(det.is_finite() && det.abs() > 1e-12) { + v.appearance = Default::default(); + v.kind = NodeKind::Group { children: vec![], clip: false }; + return Some(v); + } + let image = raster_image(out, n, false, false)?; + report.record(n, &image); + let mut m = replace_with_image(v, image); + staged_mask(out, &mut m, report); + return Some(m); + } + let mut m = n.clone(); + let mut changed = staged_mask(out, &mut m, report); + if is_container(n) + && let Some(ch) = staged_all(out, n.children()?, report) + && let Some(slot) = m.children_mut() + { + *slot = ch; + changed = true; + } + changed.then_some(m) +} + +/// The art of `n`'s opacity mask with [`staged`] applied; `true` when it changed. +fn staged_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) -> bool { + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && let Some(art) = staged(out, &m.art, report) + { + m.art = Arc::new(art); + return true; + } + false +} + +/// Does `n`, or anything inside it or its opacity mask, list a Rasterize effect whose image the +/// Transform effects after it move ([`effects::rasterize_stage`])? +fn holds_stage(n: &Node) -> bool { + effects::rasterize_stage(&n.appearance.effects).is_some() + || n.extras().mask.as_ref().is_some_and(|m| holds_stage(&m.art)) + || n.children().is_some_and(|ch| ch.iter().any(|c| holds_stage(c))) +} + +/// A copy of `doc` with [`staged`] applied in its layers, symbol definitions and pattern tiles, +/// `None` when nothing changes. +fn staged_document(doc: &Document, report: &mut FlattenReport) -> Option { + let holds = doc.layers.iter().any(|n| holds_stage(n)) + || doc.symbols.iter().any(|s| holds_stage(&s.art)) + || doc.patterns.iter().any(|p| p.art.iter().any(|n| holds_stage(n))); + if !holds { + return None; + } + let mut out = doc.clone(); + let mut changed = false; + if let Some(layers) = staged_all(&mut out, &doc.layers, report) { + out.layers = layers; + changed = true; + } + for i in 0..out.symbols.len() { + if let Some(art) = out.symbols.get(i).map(|s| s.art.clone()).and_then(|a| staged(&mut out, &a, report)) + && let Some(s) = out.symbols.get_mut(i) + { + s.art = Arc::new(art); + changed = true; + } + } + for i in 0..out.patterns.len() { + if let Some(art) = out.patterns.get(i).map(|p| p.art.clone()).and_then(|a| staged_all(&mut out, &a, report)) + && let Some(p) = out.patterns.get_mut(i) + { + p.art = art; + changed = true; + } + } + changed.then_some(out) +} + +/// `nodes` with [`staged`] applied, `None` when none changes. +fn staged_all(out: &mut Document, nodes: &[Arc], report: &mut FlattenReport) -> Option>> { + let mut changed = false; + let all = nodes + .iter() + .map(|n| match staged(out, n, report) { + Some(m) => { + changed = true; + Arc::new(m) + } + None => n.clone(), + }) + .collect(); + changed.then_some(all) +} + /// A copy of `doc` with raster effects turned into images at the document's raster effects /// resolution, or `None` when there are none: on every kind of object (paths, groups and layers, /// type, images, symbol instances, live objects) and on single fills and strokes, in the layers, @@ -616,6 +737,9 @@ fn flatten(doc: &Document, report: &mut FlattenReport) -> Option { if !any(&doc.layers) && !doc.symbols.iter().any(|s| needs(&s.art, which)) && !doc.patterns.iter().any(|p| any(&p.art)) { return None; } + // Rasterize images that Transform effects after them move, before those effects are baked. + let pre = staged_document(doc, report); + let doc = pre.as_ref().unwrap_or(doc); // Geometry effects first, so the vector objects kept above shadows are final. let baked = effects::bake_document(doc); let src = baked.as_ref().unwrap_or(doc); diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 11c4744f47..e54d68204e 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex); may be empty: an empty row recolours nothing, adds no library swatch and gives `group` no colour], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded and empty rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step (none when nothing changes). On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", has_doc, apply ), @@ -1614,8 +1614,11 @@ fn apply(s: &mut Session, p: &Value) -> Result { Some(k) => super::swatchlib::library_swatches(s, k).unwrap_or_default(), None => vec![], }; - let row_swatches: Vec> = - rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); + // A row with no current colors recolors nothing, so its swatch does not join the document. + let row_swatches: Vec> = rows + .iter() + .map(|r| r.new_color().filter(|_| !r.from.is_empty()).and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())) + .collect(); // While an object of a dynamic symbol instance's art is selected, the selection is refused, as // `selected_roots` refuses it. super::edit::refuse_symbol_part(s)?; @@ -1657,7 +1660,8 @@ fn apply(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(C, format!("bad colour {v} in groupColors"))) }) .collect::>()?, - None => rows.iter().filter_map(Row::new_color).map(|c| mapped_adjust.apply(c)).collect(), + // A row with no current colors gives the group no color. + None => rows.iter().filter(|r| !r.from.is_empty()).filter_map(Row::new_color).map(|c| mapped_adjust.apply(c)).collect(), }; let rename = text_param(p, "rename", C)?; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index e5107c247b..193e86b600 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -999,8 +999,9 @@ impl RecoveryJob { pub fn write(&self) -> std::result::Result { let store = self.store.as_ref(); let doc = self.save.doc(); - // A linked image keeps its preview, as a save writes it. - let linked = doc.linked_only_images(); + // A linked image keeps its preview, as a save writes it (the images in the stored brushes' + // art counted too). + let linked = super::links::with_brush_art(doc).linked_only_images(); let mut lean = Document::clone(doc); let blobs = std::mem::take(&mut lean.images); let enc = self.save.encode_doc(&lean).map_err(|e| e.to_string())?; diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 03bbd11ab6..e5fa5f75b3 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -8,9 +8,10 @@ use vectorcraft_doc::{Document, LockedArtboards, Node, NodeId, NodeKind}; use vectorcraft_tools::builder::is_live_paint; use super::object::can_align; +use super::panelcmds::artboard_at; use super::wand::{fill_of, stroke_of}; use super::{ - CommandSpec, Result, Session, bad, bool_or, checked_id, checked_ids_param, cmd, color_value, has_doc, has_selection, index, ok, str_param, + CommandSpec, Result, Session, bad, bool_or, checked_id, checked_ids_param, cmd, color_value, has_doc, has_selection, index_opt, ok, str_param, switch_or, text_param, }; use crate::DocState; @@ -31,7 +32,7 @@ pub fn specs() -> Vec { "All on Active Artboard", ["Select"], Some("Cmd+Alt+A"), - "{artboard?: index (0-based; the app passes the active one; default: the first)}", + "{artboard?: index (0-based whole number; the app passes the active one; default, or null: the first)}", has_doc, all_on_artboard ), @@ -81,11 +82,19 @@ pub fn specs() -> Vec { "Cancel Key Object", [], None, - "{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; on the key again: no id), guide?: index (a selected ruler guide, guide.list, with one or more objects selected: the Selection tool's click on a selected guide; Align lines the objects up on it and the guide stays put)} the key object Align aligns to and Distribute Spacing spaces from; without `id` or `guide` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members) and no ruler guide, or a key guide with no object selected or taken out of the selection; any other id or guide is an error that leaves the key as it was → {key, guide?} (the resulting key object, or null; guide: the key guide, left out without one)", + "{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; on the key again: no id), guide?: index (a selected ruler guide, guide.list, with one or more objects selected: the Selection tool's click on a selected guide; Align lines the objects up on it and the guide stays put), anchor?: [subpath, anchor] with `id` the path (an anchor point selected with Direct Selection beside at least one other point becomes the key anchor: Align and Distribute move the other points relative to it and it stays put; select.anchors add or toggle of one point makes it the key anchor, as a Shift-click does)} the key object Align aligns to and Distribute Spacing spaces from; without `id` or `guide` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members) and no ruler guide, or a key guide with no object selected or taken out of the selection, or a key anchor that is deselected, deleted or left as the only selected point; any other id, guide or anchor is an error that leaves the key as it was → {key, guide?, keyAnchor?} (the resulting key object, or null; guide: the key guide, left out without one; keyAnchor: {id, anchor}, left out without one)", has_doc, key ), - cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors), + cmd!( + "select.anchors", + "Select Anchors", + [], + None, + "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}; add or toggle that selects one point makes it the key anchor while another point is selected too (see select.key)", + has_doc, + anchors + ), cmd!( "select.segments", "Select Segments", @@ -221,7 +230,7 @@ pub fn specs() -> Vec { "Appearance Attribute", ["Select", "Same"], None, - "{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill (compound paths as select.same.fillColor)", + "{item?: appearance item index (0-based whole number; default, or null: the Appearance panel's active item)} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill (compound paths as select.same.fillColor)", has_selection, same_attribute ), @@ -420,7 +429,7 @@ fn all(s: &mut Session, _: &Value) -> Result { fn all_on_artboard(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let i = p.get("artboard").and_then(Value::as_u64).map_or(0, index); + let i = artboard_at(&st.doc, p, "artboard", "select.allOnArtboard")?.unwrap_or(0); let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?; let symbols = st.doc.symbol_boxes(); let ids: Vec = @@ -670,6 +679,9 @@ fn key(s: &mut Session, p: &Value) -> Result { } None => None, }; + if let Some(v) = p.get("anchor").filter(|v| !v.is_null()) { + return key_anchor(s, id, guide, v); + } if let Some(g) = guide { let sel = &s.doc()?.selection; if id.is_some() { @@ -684,6 +696,7 @@ fn key(s: &mut Session, p: &Value) -> Result { } s.select(|_, sel| { sel.key = None; + sel.key_anchor = None; sel.key_guide = Some(gid); })?; return key_result(s); @@ -700,17 +713,52 @@ fn key(s: &mut Session, p: &Value) -> Result { s.select(|_, sel| { sel.key = id; sel.key_guide = None; + sel.key_anchor = None; })?; key_result(s) } -/// `{key}`, with `guide` when a guide is the key. +/// `select.key {id, anchor: [subpath, anchor]}`: anchor point `anchor` of path `id`, selected with +/// Direct Selection beside at least one other point, becomes the key anchor +/// ([`vectorcraft_doc::Selection::key_anchor`]). +fn key_anchor(s: &mut Session, id: Option, guide: Option, v: &Value) -> Result { + const C: &str = "select.key"; + let r = match checked_refs(Some(&Value::Array(vec![v.clone()])), C)?.as_slice() { + [r] => *r, + _ => return Err(bad(C, "`anchor` must be a [subpath, anchor] pair")), + }; + if guide.is_some() { + return Err(bad(C, "give `anchor` or `guide`, not both")); + } + let id = id.ok_or_else(|| bad(C, "`anchor` needs `id`, the path the point is on"))?; + let st = s.doc()?; + let sel = &st.selection; + let on_path = st.doc.node(id).and_then(|n| n.path_data()).is_some_and(|p| p.subpaths.get(r.0).is_some_and(|sp| r.1 < sp.anchors.len())); + if !on_path || sel.segments.contains_key(&id) || !sel.anchors.get(&id).is_some_and(|set| set.contains(&r)) { + return Err(bad(C, format!("anchor [{}, {}] of object {id} is not a selected anchor point", r.0, r.1))); + } + // Check the new key on a copy first, so a request that fails leaves the key as it was. + let mut trial = sel.clone(); + trial.key_anchor = Some((id, r)); + trial.prune(&st.doc); + if trial.key_anchor.is_none() { + return Err(bad(C, "select another anchor point to align to the key anchor")); + } + s.select(|_, sel| sel.key_anchor = Some((id, r)))?; + key_result(s) +} + +/// `{key}`, with `guide` when a guide is the key and `keyAnchor` (`{id, anchor}`) when an anchor +/// point is. fn key_result(s: &Session) -> Result { let st = s.doc()?; let mut out = json!({ "key": st.selection.key }); if let Some(g) = st.selection.key_guide.and_then(|id| super::docmenu::guide_indexes(&st.doc, &[id]).first().copied()) { out["guide"] = json!(g); } + if let Some((id, (si, ai))) = st.selection.key_anchor { + out["keyAnchor"] = json!({ "id": id.0, "anchor": [si, ai] }); + } Ok(out) } @@ -843,15 +891,23 @@ fn anchors(s: &mut Session, p: &Value) -> Result { if whole && let Some(path) = d.node(id).and_then(|n| n.path_data()) { e.extend(path.anchors().map(|(s, i, _)| (s, i))); } + // One point that add or toggle selects becomes the key anchor (a Shift-click with Direct + // Selection); `prune` drops it unless another point is selected too. + let single = refs.len() == 1; + let mut newest = None; for r in refs { if mode == "toggle" && e.contains(&r) { e.remove(&r); - } else { - e.insert(r); + } else if e.insert(r) && single { + newest = Some(r); } } if e.is_empty() { sel.remove(id); + } else if mode != "set" + && let Some(r) = newest + { + sel.key_anchor = Some((id, r)); } })?; ok() @@ -1162,10 +1218,10 @@ enum Attribute { fn same_attribute(s: &mut Session, p: &Value) -> Result { let cmd = "select.same.appearanceAttribute"; + let item = index_opt(p, "item", cmd)?.or(s.appearance_item()); let st = s.doc()?; let (refn, _) = same_reference(st, cmd)?; let ap = &refn.appearance; - let item = p.get("item").and_then(Value::as_u64).map(index).or(s.appearance_item()); let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) { (Some(it), _) => Attribute::Item(it.clone()), (None, Some(e)) => Attribute::Effect(e.clone()), diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 3388b8344b..8868143ef3 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Merge Swatches", ["Window", "Swatches"], None, - "{names: [two or more solid-colour swatches], name?: the merged swatch's name (one swatch kept)} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), as one undo step → {name, merged: [deleted names], relinked}", + "{names: [two or more solid-colour swatches], name?: the merged swatch's name (one swatch kept)} keep the first (its name and colour) and delete the others; paints linked to them take its colour and link to it (or unlink when it isn't global), and gradient stops linked to them drop their process plates, as one undo step → {name, merged: [deleted names], relinked}", has_doc, swatch_merge ), @@ -688,6 +688,11 @@ impl Relink { /// batch, and a canceled batch preview, put back the paints the batch started with. A change is /// recorded with the step just made, so Undo and Redo put these paints back with the document. pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()> { + map_default_paints_by(s, &mut |p| p.map_links(f)) +} + +/// [`map_default_paints`] with `f` applied to each whole paint; `f` returns true when it changed it. +fn map_default_paints_by(s: &mut Session, f: &mut dyn FnMut(&mut Paint) -> bool) -> Result<()> { if s.in_interaction() && s.batch_stash.is_none() { return Ok(()); } @@ -699,7 +704,7 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()> }); let mut changed = false; for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) { - changed |= p.map_links(f); + changed |= f(p); } if changed { super::colormgmt::record_new_art_colors(s, start)?; @@ -1124,17 +1129,37 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { *l = link.clone(); true }; + // A stop linked to a merged swatch loses its process plates, which printed with that ink. + let mut unplate = |p: &Paint| { + let mut q = p.clone(); + drop_stop_plates(&mut q, &merged_names).then_some(q) + }; let colorized_ok = super::paint::check_colorized(d).is_ok(); let relinked = s.edit("Merge Swatches", |d, _| { take_swatches(d, merged, |_| true); + map_all_paints(d, &mut unplate); let relinked = map_swatch_links(d, &mut relink); super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; Ok(relinked) })?; - map_default_paints(s, &mut relink)?; + map_default_paints_by(s, &mut |p| { + let unplated = drop_stop_plates(p, &merged_names); + p.map_links(&mut relink) | unplated + })?; Ok(json!({"name": keep, "merged": merged, "relinked": relinked})) } +/// Clear the process plates of `p`'s gradient stops linked to a swatch in `names`. True when any +/// stop had plates. +fn drop_stop_plates(p: &mut Paint, names: &HashSet<&str>) -> bool { + let Paint::Gradient(g) = p else { return false }; + g.gradient + .stops + .iter_mut() + .filter(|st| st.swatch.as_deref().is_some_and(|l| names.contains(l))) + .fold(false, |ch, st| st.plates.take().is_some() | ch) +} + fn swatch_ungroup(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.ungroup"; let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 7ddc6e4bb2..63096c9d98 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -384,7 +384,7 @@ fn weather_icons() -> SymbolLibrary { Anchor { p: Point::new(5.0, 16.0), h_in: Point::new(-6.0, 16.0), h_out: Point::new(0.0, 8.0), kind: AnchorKind::Corner }, Anchor { p: Point::new(-4.0, -1.0), h_in: Point::new(-4.0, 6.0), h_out: Point::new(-4.0, -8.0), kind: AnchorKind::Smooth }, ]; - let moon = PathData::single(SubPath { anchors: moon_anchors, closed: true }); + let moon = PathData::single(SubPath::new(moon_anchors, true)); // Helper for cloud contour let cloud_subpath = |offset_y: f64, scale: f64| -> SubPath { diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index c8da4b578c..b2ceb94508 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1652,6 +1652,30 @@ fn paragraph_style_options_edit_applies_to_users_and_is_one_saved_undo_step() { } } +/// A paragraph style can hold a Standard composer: it applies to its paragraphs, redefining and +/// resetting treat it like any attribute, and the style and the paragraphs keep it through a save. +#[test] +fn paragraph_styles_keep_a_standard_composer_through_a_save() { + use vectorcraft_doc::Composer; + let (mut s, id) = session_with_text("One\nTwo"); + s.execute("paraStyle.new", &json!({"name": "Legacy", "attrs": {"composer": "standardSingleLine"}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Legacy", "id": id, "start": 0, "end": 0})).unwrap(); + assert_eq!(paras(&s, id).iter().map(|p| p.composer).collect::>(), [Composer::StandardSingleLine, Composer::EveryLine]); + let doc = s.doc().unwrap().doc.clone(); + let saved = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + let style = saved.para_styles.iter().find(|d| d.name == "Legacy").unwrap(); + assert_eq!(style.attrs["composer"], json!("standardSingleLine")); + let mut composers = vec![]; + saved.walk(|n| { + if let vectorcraft_doc::NodeKind::Text(t) = &n.kind { + composers.extend(t.paragraph_styles().iter().map(|p| p.composer)); + } + }); + assert_eq!(composers, [Composer::StandardSingleLine, Composer::EveryLine]); + s.execute("paraStyle.setAttrs", &json!({"name": "Legacy", "attrs": {"composer": "standardEveryLine"}})).unwrap(); + assert_eq!(paras(&s, id)[0].composer, Composer::StandardEveryLine, "an edited style reaches its paragraphs"); +} + /// A changed paragraph attribute outside the range `text.setFormat` keeps it to is an error for /// `paraStyle.edit`, `paraStyle.setAttrs` and `paraStyle.new`, with no change to the document; the /// ends of each range are accepted, and an out-of-range value the style already has can be kept. diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 2cc7c3c1d3..b92f70e910 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Create Outlines", ["Type"], Some("Cmd+Shift+O"), - "{} convert the selected text to groups of compound paths (one per glyph); the type object's own fills and strokes (not its characters') go on paths of its whole outline, below or above the glyphs as its Characters row places them; a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}", + "{} convert the selected text to groups of compound paths (one per glyph); the type object's own fills and strokes (not its characters') go on paths of its whole outline, below or above the glyphs as its Characters row places them; the group keeps the object's transparency, opacity mask and effects, once; a frame of threaded text leaves its thread with the text it shows; a dimension whose label is outlined becomes a plain group of its art → {ids}", has_selection, create_outlines ), @@ -739,11 +739,13 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { if children.is_empty() { continue; } + // The group takes the object's name, transparency, opacity mask and effects, which + // apply to the type as a whole: once, not to each glyph. It is marked as type's + // outlines, so geometry effects move its placed gradients with the whole outline, as + // they do on the type. let gid = d.alloc_id(); - let mut g = Node::group(gid, children); - g.opacity = n.opacity; - g.blend = n.blend; - g.name = n.name.clone(); + let mut g = super::flatten::carry(&n, Node::group(gid, children)); + g.extras_mut().type_outlines = true; d.insert(par, idx, g)?; out.push(gid); if roots.contains(&tid) { diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 01615e5f4f..0701f6eeca 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec { "Paste without Formatting", ["Edit"], Some("Cmd+Alt+V"), - "{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs → {ids, added, merged, renamed}", + "{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs, which take a new graph's label character style and paragraph attributes (keeping their alignment); a pasted graph no longer keeps its labels' character and paragraph formats, so a later redraw sets them the same way → {ids, added, merged, renamed}", has_clipboard, paste_plain ), @@ -1767,15 +1767,12 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) { t.set_all_paras(Default::default()); refresh_bounds(t); } - // A graph keeps the names of its labels' character and paragraph styles for each redraw. - if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { - let l = &mut g.label_styles; - for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { - st.style_name = None; - } - for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() { - pa.style_name = None; - } + // A graph keeps its labels' character and paragraph formats for each redraw: they go with the + // labels' own formatting, and the labels are set in a new graph's label formats. The graph's generated + // bounds follow its changed labels, so the next redraw does not read them as a move. + if n.extras().graph.is_some() { + super::graph::edit_graph_art(n, |n| super::graph::plain_labels(n, &strip_formatting)); + return; } if let Some(ch) = n.children_mut() { for c in ch.iter_mut() { diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index a73c16448b..8e58132ce0 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -245,39 +245,92 @@ fn define(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": name, "kind": kind.label()})) } +/// Whether the object `id`, bound to the variable `name`, can stay bound to it once the variable +/// is of `kind`: a text variable drives type that holds no other text variable, an image variable +/// drives an image object, a graph variable drives a graph root, and a visibility variable drives +/// any object. Locks and hidden layers don't count, as they don't unbind anything else either. +fn keeps_binding(doc: &Document, id: NodeId, name: &str, kind: VariableKind) -> bool { + let Some(n) = doc.node(id) else { return false }; + match kind { + VariableKind::Text => { + matches!(n.kind, NodeKind::Text(_)) + && !doc.variables.bindings.get(&id).is_some_and(|bound| { + doc.variables.variables.iter().any(|v| v.kind == VariableKind::Text && v.name != name && bound.contains(&v.name)) + }) + } + VariableKind::Visibility => true, + VariableKind::Image => matches!(n.kind, NodeKind::Image(_)), + VariableKind::Graph => n.extras().graph.is_some(), + VariableKind::Unknown => false, + } +} + +/// Variable Options: rename a variable (`newName`), change its kind (`kind`), or both, in one undo +/// step, carrying its bindings and its values in every data set to the new name. A kind change +/// unbinds the objects that can't hold the new kind ([`keeps_binding`]) and drops the variable's +/// value in every data set. A call that changes nothing adds no undo step. fn rename(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.rename"; let from = var_name(p, C)?; - let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?; - let st = s.doc()?; - if st.doc.variables.variable(&from).is_none() { - return Err(bad(C, format!("no variable named `{from}`"))); + // An omitted key is no change; a null, like any other value that isn't a name or a kind, is refused. + let to = match p.get("newName") { + None => None, + Some(v) => Some(named(v.as_str(), "newName", C)?), + }; + let kind = match p.get("kind") { + None => None, + Some(v) => Some(v.as_str().and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text`, `visibility`, `image` or `graph`"))?), + }; + if to.is_none() && kind.is_none() { + return Err(bad(C, "missing `newName` or `kind`")); } - if st.doc.variables.variable(&to).is_some() { + let doc = &s.doc()?.doc; + let Some(old) = doc.variables.variable(&from) else { + return Err(bad(C, format!("no variable named `{from}`"))); + }; + let to = to.unwrap_or_else(|| from.clone()); + if to != from && doc.variables.variable(&to).is_some() { return Err(bad(C, format!("variable `{to}` already exists"))); } - s.edit("Rename Variable", |d, _| { - let v = d.variables.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?; + let kind = kind.unwrap_or(old.kind); + let retyped = kind != old.kind; + let unbound: Vec = + if retyped { doc.variables.objects_of(&from).into_iter().filter(|id| !keeps_binding(doc, *id, &from, kind)).collect() } else { Vec::new() }; + let label = if retyped { "Variable Options" } else { "Rename Variable" }; + let dropped = edit_variables(s, label, |vars| { + let v = vars.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?; v.name = to.clone(); - for bound in d.variables.bindings.values_mut() { + v.kind = kind; + for id in &unbound { + vars.unbind(*id, &from); + } + for bound in vars.bindings.values_mut() { if bound.remove(&from) { bound.insert(to.clone()); } } - for ds in &mut d.variables.datasets { - if let Some(value) = ds.values.remove(&from) { - ds.values.insert(to.clone(), value); + let mut dropped = 0; + for ds in &mut vars.datasets { + match ds.values.remove(&from) { + // A rename alone carries every value, one that doesn't fit the kind included; a kind + // change drops them all, as none was given for the new kind. + Some(value) if !retyped => { + ds.values.insert(to.clone(), value); + } + Some(_) => dropped += 1, + None => {} } } - Ok(()) + Ok(dropped) })?; - // The highlight names a variable, so it follows the rename rather than going stale. + // The highlight names a variable, so it follows the rename rather than going stale. A call + // that leaves the name as it was leaves the revision alone too. let st = s.doc_mut()?; - if st.variables_highlight.as_deref() == Some(from.as_str()) { + if to != from && st.variables_highlight.as_deref() == Some(from.as_str()) { st.variables_highlight = Some(to.clone()); + st.revision += 1; } - st.revision += 1; - Ok(json!({"from": from, "name": to})) + Ok(json!({"from": from, "name": to, "kind": kind.label(), "unbound": unbound.iter().map(|i| i.0).collect::>(), "droppedValues": dropped})) } /// Make Text / Visibility / Image / Graph Dynamic: define a variable of that kind and bind the @@ -330,18 +383,19 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn list(s: &mut Session, _p: &Value) -> Result { + let xml_id = s.prefs.identify_objects_by == "xmlId"; let st = s.doc()?; - let variables: Vec = st - .doc - .variables - .variables - .iter() + let vars = &st.doc.variables.variables; + let variables: Vec = variable_order(st, xml_id) + .into_iter() + .filter_map(|i| vars.get(i)) .map(|v| { let bindings = st.doc.variables.objects_of(&v.name).len(); json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings}) }) .collect(); - Ok(json!({"variables": variables, "locked": st.doc.variables.locked})) + let sort = json!({"by": st.variables_sort.by.id(), "descending": st.variables_sort.descending}); + Ok(json!({"variables": variables, "locked": st.doc.variables.locked, "sort": sort})) } /// Lock Variables: without `locked` it toggles. Setting the state the document already has @@ -417,6 +471,120 @@ fn highlight(s: &mut Session, p: &Value) -> Result { Ok(json!({"variable": name})) } +/// The column the Variables panel sorts its list by (`variable.sort`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum VariableSortKey { + /// The order the variables were defined in. + #[default] + Defined, + /// The Variables column: the variable's name. + Name, + /// The Objects column: the name of the object the variable is bound to. + Object, + /// The type column: text, image, graph, visibility, then variables of unknown kind. + Type, +} + +impl VariableSortKey { + /// The values `variable.sort` takes for `by`, in the order of [`Self::id`]. + const IDS: [&'static str; 4] = ["name", "object", "type", "none"]; + + /// The value `variable.sort` takes for `by` and `variable.list` reports. + pub fn id(self) -> &'static str { + match self { + Self::Name => "name", + Self::Object => "object", + Self::Type => "type", + Self::Defined => "none", + } + } + + fn parse(s: &str) -> Option { + match s { + "name" => Some(Self::Name), + "object" => Some(Self::Object), + "type" => Some(Self::Type), + "none" => Some(Self::Defined), + _ => None, + } + } +} + +/// How the Variables panel orders its list: a column, and whether the order is reversed. Panel +/// state, not saved and not an undo step. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct VariableSort { + pub by: VariableSortKey, + pub descending: bool, +} + +/// The place of a variable's kind in the order the type column sorts by. +fn kind_rank(kind: VariableKind) -> u8 { + match kind { + VariableKind::Text => 0, + VariableKind::Image => 1, + VariableKind::Graph => 2, + VariableKind::Visibility => 3, + VariableKind::Unknown => 4, + } +} + +/// The Variables panel's rows in its sort order, as indices into the document's variables. +/// Names compare without regard to case. Sorted by object, a variable bound to nothing comes +/// first and one bound to several objects comes after those bound to one; `xml_id` names objects +/// by their XML ids, as the panel does with Identify Objects By set to XML ID. Ties go by name. +pub fn variable_order(st: &crate::DocState, xml_id: bool) -> Vec { + let vars = &st.doc.variables.variables; + let sort = st.variables_sort; + let mut order: Vec = (0..vars.len()).collect(); + if sort.by != VariableSortKey::Defined { + order.sort_by_cached_key(|&i| { + let v = &vars[i]; + let (rank, label) = match sort.by { + VariableSortKey::Object => match st.doc.variables.objects_of(&v.name).as_slice() { + [] => (0, String::new()), + [one] => (1, st.doc.node(*one).map(|n| n.identify_label(xml_id)).unwrap_or_default()), + _ => (2, String::new()), + }, + VariableSortKey::Type => (kind_rank(v.kind), String::new()), + VariableSortKey::Name | VariableSortKey::Defined => (0, String::new()), + }; + (rank, label.to_lowercase(), label, v.name.to_lowercase(), v.name.clone()) + }); + } + if sort.descending { + order.reverse(); + } + order +} + +/// Sort the Variables panel's list. Panel state, like `variable.highlight`: not saved and not an +/// undo step. Setting the order the panel already has changes nothing. +fn sort(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.sort"; + let key = "by"; + let by = match p.get(key) { + None => return Err(bad(C, format!("missing `{key}`"))), + Some(v) => v + .as_str() + .and_then(VariableSortKey::parse) + .ok_or_else(|| bad(C, format!("`{key}` must be one of {}", VariableSortKey::IDS.join(", "))))?, + }; + // A `descending` that is given must be true or false: null is refused, not read as omitted. + let key = "descending"; + let descending = match p.get(key) { + None => false, + Some(v) => v.as_bool().ok_or_else(|| bad(C, format!("`{key}` must be true or false")))?, + }; + let sort = VariableSort { by, descending }; + let st = s.doc_mut()?; + if st.variables_sort != sort { + st.variables_sort = sort; + st.revision += 1; + } + Ok(json!({"by": by.id(), "descending": descending})) +} + fn dataset_new(s: &mut Session, p: &Value) -> Result { const C: &str = "dataset.new"; let name = var_name(p, C)?; @@ -722,11 +890,19 @@ pub fn specs() -> Vec { "Rename Variable", ["Window", "Variables"], None, - "{name, newName} rename a variable, with its bindings and its values in every dataset; refused while the variables are locked", + "{name, newName?, kind?: text|visibility|image|graph} Variable Options: rename a variable, with its bindings and its values in every dataset, change its kind, or both, in one undo step (none when the name and the kind are the ones it has). A kind change unbinds every object that can't hold the new kind (a text variable keeps type that has no other text variable, an image variable keeps image objects, a graph variable keeps graph roots, a visibility variable keeps every object) and drops the variable's values in every dataset; locked and hidden objects keep their binding when they can hold it. Needs `newName` or `kind`, and a null or empty one is refused; refused while the variables are locked → {from, name, kind, unbound: [id…], droppedValues}", variables_unlocked, rename ), - cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts, and whether they are locked → {variables, locked}", has_doc, list), + cmd!( + query "variable.list", + "List Variables", + [], + None, + "{} → variables with kinds and binding counts in the Variables panel's order, whether they are locked, and the panel's sort (as variable.sort sets it) → {variables, locked, sort: {by, descending}}", + has_doc, + list + ), cmd!( "variable.makeTextDynamic", "Make Text Dynamic", @@ -799,6 +975,15 @@ pub fn specs() -> Vec { has_doc, highlight ), + cmd!( + "variable.sort", + "Sort Variables", + [], + None, + "{by: name|object|type|none, descending?: bool} sort the Variables panel's list (a click on a column header) by variable name, by the name of the bound object, or by type (text, image, graph, visibility, then unknown); `none` is the order the variables were defined in, and `descending` reverses the order. Names compare without regard to case; by object, unbound variables come first and variables bound to several objects last, and ties go by name. Panel state: not saved and not an undo step; variable.list reports it → {by, descending}", + has_doc, + sort + ), cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new), cmd!( "dataset.set", diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 780616c07a..9cbdfb6827 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -29,6 +29,9 @@ pub struct DocState { /// Bound Object act on. Panel state, not art selection: not saved, not undoable /// (`variable.highlight`). A name, so renaming a variable moves the highlight with it. pub variables_highlight: Option, + /// The order of the Variables panel's list: the column it is sorted by, and whether it is + /// reversed. Panel state: not saved, not undoable (`variable.sort`). + pub variables_sort: cmd::VariableSort, /// Isolation mode container. pub isolation: Option, /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape @@ -316,6 +319,7 @@ impl DocState { active_layer, layer_rows: vec![], variables_highlight: None, + variables_sort: Default::default(), isolation: None, global_edit: None, similar_outlines: Default::default(), diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 146583778f..323d5d0918 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -308,6 +308,8 @@ fn document_base(s: &Session) -> Value { "key": st.selection.key.map(|k| k.0), // The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`. "keyGuide": st.selection.key_guide.and_then(|id| crate::cmd::guide_indexes(d, &[id]).first().copied()), + // The anchor point that is the key instead (`select.key {id, anchor}`): {id, anchor}. + "keyAnchor": st.selection.key_anchor.map(|(id, (si, ai))| json!({"id": id.0, "anchor": [si, ai]})), "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 890f8622f8..aeb723c963 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ use vectorcraft_tools::{PaintDefaults, Tool}; pub use cmd::clipboard::{Clipboard, SourceLayer}; pub use cmd::create::constrain_click; pub use cmd::distortcmds::perspective_click; +pub use cmd::graph::{GraphTypeSubject, calculated_axes as graph_calculated_axes, graph_type_subject}; pub use cmd::params::{check_keys, check_params}; pub use cmd::pathops::auto_simplify_level; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; @@ -244,6 +245,8 @@ pub struct Session { /// keyboard: those files, and the files they link to, are read under these roots, so a queue /// an agent loaded never reads beyond what it was given. pub(crate) place_queue_roots: Option>, + /// How many times the place cursor has been loaded ([`Session::place_queue_loads`]). + pub(crate) place_queue_loads: u64, /// While [`Session::import_image_read`] runs `clipboard.importImage`: the image read for it, /// which the command loads instead of reading `dataBase64`; `None` otherwise. pub(crate) image_hand_off: Option, diff --git a/crates/engine/src/sanity.rs b/crates/engine/src/sanity.rs index f1b6fd55bb..4737246c9d 100644 --- a/crates/engine/src/sanity.rs +++ b/crates/engine/src/sanity.rs @@ -59,8 +59,23 @@ fn rect_in_range(r: vectorcraft_geom::Rect) -> bool { /// margin passes without being measured: its curves stay inside their control points. Any other /// object, and a path with a point outside that margin, is measured. pub(crate) fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool { - let paths = d.index_paths(ids.iter().copied()); - ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).is_none_or(|n| points_in_range(n) || n.geometric_bounds().is_none_or(rect_in_range))) + if ids.is_empty() { + return true; + } + let node_ok = |n: &vectorcraft_doc::Node| points_in_range(n) || n.geometric_bounds().is_none_or(rect_in_range); + // A cached document or a few ids: look each one up. Otherwise one walk of the tree, with no + // path vectors allocated. + if d.has_lookup_cache() || ids.len() <= 16 { + return ids.iter().all(|id| d.node(*id).is_none_or(node_ok)); + } + let want: std::collections::HashSet = ids.iter().copied().collect(); + let mut ok = true; + d.walk(|n| { + if ok && want.contains(&n.id) && !node_ok(n) { + ok = false; + } + }); + ok } /// Coordinates at most this far from 0 keep a curve's measured bounds within [`MAX_COORD`] diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b54333206f..b2836d159c 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -113,6 +113,7 @@ impl Session { place_hand_off: None, queue_hand_off: None, place_queue_roots: None, + place_queue_loads: 0, image_hand_off: None, trace_hand_off: None, place_cursor_requests: false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f89cc98ff8..1b420d7805 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -86,6 +86,7 @@ mod inline; mod interactivity; mod intertwine; mod journal; +mod keyanchor; mod keyguide; mod knockout; mod labspots; @@ -1544,6 +1545,7 @@ fn shape_commands_refuse_a_number_or_boolean_given_as_text() { ("shape.flare", json!({"cx": 100, "cy": 100, "x2": "300", "y2": 300}), "x2"), ("shape.flare", with(&flare, "direction", json!("45")), "direction"), ("shape.flare", with(&flare, "seed", json!("7")), "seed"), + ("shape.flare", with(&flare, "ringSeed", json!("7")), "ringSeed"), ("shape.spiral", with(&spiral, "decay", json!("80")), "decay"), ("shape.spiral", with(&spiral, "segments", json!("10")), "segments"), ] @@ -2131,7 +2133,7 @@ mod pen_interactions { gesture(&mut s, &[(400.0, 500.0)], Mods::default(), v); let extended = path(&s, id); assert_eq!(extended.anchor_count(), 3); - assert_eq!(extended.subpaths[0].anchors[..2], reversed.subpaths[0].anchors); + assert_eq!(extended.subpaths[0].anchors.to_vec()[..2], reversed.subpaths[0].anchors); assert!(!s.in_interaction() && !s.tool_busy()); assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); } @@ -2166,7 +2168,7 @@ mod pen_interactions { gesture(&mut s, &[(300.0, 400.0)], Mods::default(), v); let extended = path(&s, id); assert_eq!(extended.anchor_count(), 3); - assert_eq!(extended.subpaths[0].anchors[..2], converted.subpaths[0].anchors); + assert_eq!(extended.subpaths[0].anchors.to_vec()[..2], converted.subpaths[0].anchors); assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); } @@ -2225,7 +2227,7 @@ mod pen_interactions { } let extended = path(&s, id); assert_eq!(extended.anchor_count(), 3); - assert_eq!(extended.subpaths[0].anchors[..2], before.subpaths[0].anchors); + assert_eq!(extended.subpaths[0].anchors.to_vec()[..2], before.subpaths[0].anchors); let a = extended.subpaths[0].anchors[2]; assert_eq!(a.p, Point::new(320.0, 450.0)); assert_eq!(a.h_in, Point::new(290.0, 430.0), "Alt freezes the incoming handle and Space translates it"); @@ -3268,6 +3270,70 @@ fn flare_rotation_turns_only_the_rays() { } } +/// `shape.flare`'s `ringSeed` places the rings along the path anew and keeps their count and +/// sizes and the other parts; the same `ringSeed` draws the same flare, neighboring ones differ, +/// and a seed that is not a whole number of at least 0 (for `ringSeed`, below 2^53) is refused. +#[test] +fn flare_ring_seed_moves_only_the_rings() { + let mut s = session(); + let parts = |s: &mut Session, ring_seed: Option| { + let mut p = json!({"cx": 200, "cy": 200, "diameter": 60, "rays": 9, "rings": 6, "x2": 400, "y2": 300, "seed": 3}); + if let Some(r) = ring_seed { + p["ringSeed"] = json!(r); + } + let id = NodeId(s.execute("shape.flare", &p).unwrap()["id"].as_u64().unwrap()); + let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("flare is a group") }; + children.iter().map(|c| (c.name.clone(), c.geometric_bounds().unwrap())).collect::>() + }; + let plain = parts(&mut s, None); + let placed = parts(&mut s, Some(11)); + assert_eq!(placed, parts(&mut s, Some(11))); + assert_eq!(plain.len(), placed.len()); + let mut moved = 0; + for ((name, a), (_, b)) in plain.iter().zip(&placed) { + if name.as_deref() == Some("Ring") { + assert!((a.width() - b.width()).abs() < 1e-9, "a ring keeps its size"); + moved += usize::from(a.center().distance(b.center()) > 1e-6); + } else { + assert_eq!(a, b, "{name:?}"); + } + } + assert_eq!(moved, 6, "every ring moves"); + assert_ne!(parts(&mut s, Some(0)), parts(&mut s, Some(1)), "neighboring seeds place the rings differently"); + let undo = s.doc().unwrap().history.undo.len(); + for bad in [json!(-1), json!(2.5), json!(true), json!(1u64 << 53)] { + assert!(s.execute("shape.flare", &json!({"cx": 0, "cy": 0, "ringSeed": bad})).is_err(), "{bad}"); + } + for bad in [json!(-1), json!(2.5), json!(true)] { + assert!(s.execute("shape.flare", &json!({"cx": 0, "cy": 0, "seed": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +/// Through the session, Ctrl/Cmd in the Flare tool's center drag keeps the center's size while the +/// drag sizes the halo, and the flare drawn keeps both. +#[test] +fn flare_tool_ctrl_keeps_the_center_while_the_drag_sizes_the_halo() { + let mut s = session(); + let v = ViewInfo::default(); + let cmd = vectorcraft_tools::Mods { cmd: true, ..Default::default() }; + s.select_tool("flare", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 240.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 200.0).with_mods(cmd), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 200.0).with_mods(cmd), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 500.0, 400.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 500.0, 400.0), v).unwrap(); + let st = s.doc().unwrap(); + let g = st.doc.node(st.selection.objects[0]).unwrap(); + let NodeKind::Group { children, .. } = &g.kind else { panic!("flare is a group") }; + let width = |name: &str| children.iter().find(|c| c.name.as_deref() == Some(name)).unwrap().geometric_bounds().unwrap().width(); + // The center as the key found it (80); the halo as a 160-point center with 20 % growth would + // have it: 80 × 1.2 × 1.6 = 153.6 radius. + assert!((width("Center") - 80.0).abs() < 0.5, "{}", width("Center")); + assert!((width("Halo") - 2.0 * 153.6).abs() < 0.5, "{}", width("Halo")); +} + #[test] fn reshape_moves_the_grabbed_point_and_its_neighbourhood() { let mut s = session(); @@ -3315,7 +3381,7 @@ fn reshape_open_path(s: &mut Session, anchors: Value) -> NodeId { /// The anchors of the path's first subpath. fn reshape_anchors(s: &Session, id: NodeId) -> Vec { - s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.clone() + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.to_vec() } /// The share of the drag an anchor takes at `u` of the way from an end to the grabbed point. diff --git a/crates/engine/src/tests/actionset.rs b/crates/engine/src/tests/actionset.rs index d80e649ec7..49e5685bdc 100644 --- a/crates/engine/src/tests/actionset.rs +++ b/crates/engine/src/tests/actionset.rs @@ -3,7 +3,7 @@ use serde_json::json; -use crate::cmd::actionset::{AiaStep, read_aia}; +use crate::cmd::actionset::{AiaData, AiaStep, AiaValue, read_aia}; use crate::*; /// `text` as a bracketed hexadecimal value. @@ -37,7 +37,12 @@ fn a_set_reads_its_actions_and_steps() { let names: Vec<&str> = set.actions.iter().map(|a| a.name.as_str()).collect(); assert_eq!(names, ["First", "二番目"]); let step = |a: usize, s: usize| set.actions[a].steps[s].clone(); - let join = AiaStep { name: "sample_join".into(), label: Some("Join".into()), on: Some(1), params: 3, declared: Some(3), line: 9 }; + let values = vec![ + AiaValue { key: Some(1), kind: Some("unit real".into()), value: None }, + AiaValue { key: None, kind: Some("ustring".into()), value: Some(AiaData::Text(b"a } brace".to_vec())) }, + AiaValue { key: None, kind: Some("raw".into()), value: None }, + ]; + let join = AiaStep { name: "sample_join".into(), label: Some("Join".into()), on: Some(1), params: 3, values, declared: Some(3), line: 9 }; assert_eq!(step(0, 0), join); assert_eq!((step(1, 0).name.as_str(), step(1, 0).label.clone(), step(1, 0).on), ("sample)one", None, Some(0))); assert_eq!((step(1, 1).label.as_deref(), step(1, 1).on, step(1, 1).line), (Some("Second Step"), None, 39)); @@ -60,6 +65,55 @@ fn recorded_values_are_counted_and_checked() { assert!(set(&[value("one"), "/value [ 1 zz ]".to_string()]).truncated, "a second value that isn't hexadecimal"); } +/// `actionSet.read` answers with each recorded value's key, type and value: a number, text in +/// parentheses or hexadecimal text that is UTF-8 with its length in bytes, and null for text that +/// isn't UTF-8 and for other values. Fields may come in any order, and a value with the wrong byte +/// count still marks the set truncated. +#[test] +fn recorded_values_come_back_with_their_keys_and_types() { + let mut s = Session::new(); + let text = format!( + "/version 3 /action-1 {{ /event-1 {{ /internalName (sample_values) /parameterCount 6 \ + /parameter-1 {{ /key 1952807028 /type (ustring) /value {note} }} \ + /parameter-2 {{ /value 1 /type (boolean) /key 2003792484 }} \ + /parameter-3 {{ /type (ustring) /value [ 2 c328 ] /key 7 }} \ + /parameter-4 {{ /key 99999999999999999999 /type (real) /value 4.5 }} \ + /parameter-5 {{ /key 3 /type (text) /value (plain) }} \ + /parameter-6 {{ }} }} }}", + note = hex("Größe ✓") + ); + let r = s.execute("actionSet.read", &json!({ "data": text })).unwrap(); + assert_eq!(r["truncated"], false); + assert_eq!( + r["actions"][0]["steps"][0]["values"], + json!([ + {"key": 1952807028, "type": "ustring", "value": "Größe ✓", "bytes": 11}, + {"key": 2003792484, "type": "boolean", "value": 1}, + {"key": 7, "type": "ustring", "value": null, "bytes": 2}, + {"key": null, "type": "real", "value": null}, + {"key": 3, "type": "text", "value": "plain", "bytes": 5}, + {"key": null, "type": null, "value": null}, + ]) + ); + let bad = "/version 3 /action-1 { /event-1 { /parameter-1 { /key 1 /type (ustring) /value [ 3 4142 ] } } }"; + assert_eq!(s.execute("actionSet.read", &json!({ "data": bad })).unwrap()["truncated"], true); +} + +/// At most 100,000 recorded values are kept in a set: the others are still counted, so a step's +/// `values` can be shorter than its `params`. +#[test] +fn recorded_values_past_the_cap_are_counted_but_not_kept() { + let step = format!("/event-1 {{ {} }} ", "/parameter-1 { /key 1 /value 0 } ".repeat(1000)); + let action = format!("/action-1 {{ {} }} ", step.repeat(50)); + let set = read_aia(format!("/version 3 {}", action.repeat(3)).as_bytes()).unwrap(); + assert!(!set.truncated); + let steps = || set.actions.iter().flat_map(|a| &a.steps); + assert_eq!(steps().map(|s| s.params).sum::(), 150_000); + assert_eq!(steps().map(|s| s.values.len()).sum::(), 100_000); + assert_eq!(set.actions[2].steps[0].values.len(), 0); + assert_eq!(set.actions[1].steps[49].values[999], AiaValue { key: Some(1), kind: None, value: Some(AiaData::Int(0)) }); +} + /// `actionSet.read` takes exactly one of `path`, `data` and `dataBase64`, and text for the text /// params; any other value is an error. #[test] @@ -97,7 +151,7 @@ fn read_command_answers_with_the_set() { let text = sample().replacen(&format!("/name {}", hex("Prep Sätze")), "", 1); let r = s.execute("actionSet.read", &json!({ "data": text, "name": "/sets/Mine.aia" })).unwrap(); assert_eq!(r["set"], "Mine"); - assert_eq!(r["actions"][1]["steps"][0], json!({"name": "sample)one", "label": null, "on": false, "params": 0, "line": 34})); + assert_eq!(r["actions"][1]["steps"][0], json!({"name": "sample)one", "label": null, "on": false, "params": 0, "values": [], "line": 34})); assert_eq!(r["actions"][0]["steps"][0]["on"], true); assert_eq!(r["actions"][0]["steps"][0]["label"], "Join"); let blank = format!("/version 3 /action-1 {{ /event-1 {{ /internalName (x) /localizedName {} }} }}", hex(" ")); diff --git a/crates/engine/src/tests/anchor_edit_work.rs b/crates/engine/src/tests/anchor_edit_work.rs index 1055156167..5401bc0bf8 100644 --- a/crates/engine/src/tests/anchor_edit_work.rs +++ b/crates/engine/src/tests/anchor_edit_work.rs @@ -1,6 +1,7 @@ //! An edit of one anchor of a long selected path does not measure the path's segments -//! ([`vectorcraft_geom::path::measured_segments`]) to check that it stays within the canvas, and -//! an edit that leaves the anchor as it was records no undo step. +//! ([`vectorcraft_geom::path::measured_segments`]) to check that it stays within the canvas, its +//! undo step keeps the chunk of anchors it changed rather than a copy of every anchor, and an edit +//! that leaves the anchor as it was records no undo step. use std::collections::BTreeSet; use std::sync::Arc; @@ -74,6 +75,46 @@ fn one_anchor_edit_of_a_long_path_measures_few_segments() { } } +/// Most bytes the undo step of one anchor edit may keep, however many anchors the path has: about +/// one chunk of anchors and the node, with room for the chunk tables of 200,000 anchors. +const MOST_STEP_BYTES: usize = 48 * 1024; + +#[test] +fn one_anchor_edit_of_a_long_path_keeps_one_chunk_in_its_undo_step() { + for n in [10_000, 200_000] { + let (mut s, id) = long_path(n); + let original = s.doc().unwrap().doc.clone(); + let edits = [ + ("path.moveAnchor", json!({"id": id.0, "anchor": 5, "x": 3, "y": 50})), + ("path.setHandle", json!({"id": id.0, "anchor": 5, "which": "out", "x": 4, "y": 40})), + ("path.moveAnchors", json!({"dx": 1, "dy": 0})), + ("object.nudge", json!({"dx": 1, "dy": 0})), + ]; + let mut docs = vec![]; + for (cmd, p) in &edits { + s.execute(cmd, p).unwrap(); + let step = s.doc().unwrap().history.undo.last().unwrap().bytes; + assert!(step <= MOST_STEP_BYTES, "{cmd} on {n} anchors kept {step} bytes in its undo step"); + docs.push(s.doc().unwrap().doc.clone()); + } + assert_eq!(anchor(&s, id, 5).p, Point::new(5.0, 50.0)); + assert_eq!(anchor(&s, id, 5).h_out, Point::new(6.0, 40.0)); + assert_eq!(anchor(&s, id, n - 1), original.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[n - 1]); + // Undo gives back each version exactly, and redo the edits, each step as small. + for k in (0..edits.len()).rev() { + s.execute("edit.undo", &json!({})).unwrap(); + let expected = if k == 0 { &original } else { &docs[k - 1] }; + assert!(s.doc().unwrap().doc == *expected, "undo of {} on {n} anchors", edits[k].0); + let redo = s.doc().unwrap().history.redo.last().unwrap().bytes; + assert!(redo <= MOST_STEP_BYTES, "redo of {} on {n} anchors keeps {redo} bytes", edits[k].0); + } + for (k, doc) in docs.iter().enumerate() { + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc == *doc, "redo of {} on {n} anchors", edits[k].0); + } + } +} + /// A handle past the canvas is measured: the edit stands while the curve stays within the canvas, /// and is refused once the curve leaves it. #[test] diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index bc4ce76c83..a2e9eb3012 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1002,6 +1002,105 @@ fn brush_freehand_refuses_a_brush_or_style_it_cannot_use() { } } +/// A `brush.freehand` call outside an interaction draws the path and paints it with the brush in +/// one undo step: one Undo removes the path and gives back the earlier selection, and one Redo +/// brings back the brushed path. Continuing a path with a brush is one step too. +#[test] +fn brush_freehand_direct_call_has_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + select(&mut s, &[a]); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("brush.freehand", &json!({"points": [[10, 10], [60, 40], [120, 10]], "style": "brush", "brush": "Charcoal"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(*s.doc().unwrap().doc == *before, "one Undo removes the brushed path"); + assert_eq!(selected(&s), [a], "Undo gives back the earlier selection"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal"), "Redo brings back the brush"); + assert_eq!(selected(&s), [id]); + + let undo = s.doc().unwrap().history.undo.len(); + let p = json!({"points": [[120, 10], [160, 60], [200, 10]], "extend": {"id": id.0, "end": "end"}, "brush": "Dots"}); + assert_eq!(s.execute("brush.freehand", &p).unwrap()["id"], json!(id.0)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "continuing is one undo step"); + assert_eq!(brush_of(&s, id).as_deref(), Some("Dots")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal"), "one Undo takes back the stroke and its brush"); + + let undo = s.doc().unwrap().history.undo.len(); + s.execute("brush.freehand", &json!({"points": [[10, 100], [60, 140], [120, 100]], "style": "pencil", "brush": "Charcoal"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Paintbrush"), "a stroke with a brush is a Paintbrush step"); +} + +/// `brush.freehand` refuses a brush for a path with no stroke (a continued path, a redrawn one +/// and a new filled pencil path while the stroke is None) instead of reporting it painted: the +/// document, the selection and the undo history stay as they were. Without a brush the path is +/// drawn as `path.freehand` draws it. +#[test] +fn brush_freehand_refuses_a_brush_on_a_path_without_a_stroke() { + let mut s = session(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let drawn = json!([[10, 10], [60, 20], [90, 80], [20, 90]]); + let p = NodeId(s.execute("path.freehand", &json!({"points": drawn, "fill": true})).unwrap()["id"].as_u64().unwrap()); + assert!(node(&s, p).appearance.stroke_paint().is_none(), "the path has no stroke"); + // The redraw below replaces a section of the path rather than drawing a new one. + let redraw = json!({"points": [[10, 10], [40, 30], [60, 20]], "redraw": {"id": p.0}}); + assert_eq!(s.execute("path.freehand", &redraw).unwrap()["id"], json!(p.0)); + s.execute("edit.undo", &json!({})).unwrap(); + select(&mut s, &[p]); + let extend = json!({"id": p.0, "end": "end"}); + for (what, mut params) in [ + ("continued", json!({"points": [[20, 90], [0, 120], [-10, 150]], "extend": extend})), + ("continued by one point", json!({"points": [[20, 90]], "extend": extend})), + ("redrawn", redraw.clone()), + ("new filled pencil", json!({"points": [[100, 100], [150, 120], [180, 180]], "style": "pencil", "fill": true})), + ] { + params["brush"] = json!("Charcoal"); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute("brush.freehand", ¶ms).expect_err(&format!("{what}: refused")); + assert_eq!(e.to_string(), "invalid parameters for `brush.freehand`: cannot apply brush `Charcoal`: path has no stroke", "{what}"); + assert!(*s.doc().unwrap().doc == *before, "{what}: the document stays"); + assert_eq!(selected(&s), [p], "{what}: the selection stays"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{what}: no undo step"); + } + let r = s.execute("brush.freehand", &json!({"points": [[20, 90], [0, 120], [-10, 150]], "extend": extend, "brush": null})).unwrap(); + assert_eq!(r["id"], json!(p.0)); + assert_eq!(brush_of(&s, p), None, "without a brush the path is continued as it is"); +} + +/// `brush.freehand` takes the keys of `path.freehand` and `brush`, and refuses any other key with +/// the nearest key it takes, before it draws. +#[test] +fn brush_freehand_refuses_unknown_keys() { + let spec = crate::cmd::find_command("brush.freehand").unwrap(); + let mut keys = crate::cmd::params::accepted_keys(spec.params).expect("the description names every key"); + keys.sort_unstable(); + assert_eq!(keys, ["brush", "closed", "connect", "extend", "fidelity", "fill", "fit", "keepSelected", "points", "redraw", "style"]); + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + select(&mut s, &[a]); + let points = json!([[10, 10], [60, 40], [120, 10]]); + let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let e = s.execute("brush.freehand", &json!({"points": points, "brsuh": "Charcoal"})).expect_err("a misspelled key is refused"); + assert!(e.to_string().contains("unknown parameter `brsuh`; did you mean `brush`?"), "{e}"); + assert!(*s.doc().unwrap().doc == *before, "the document stays"); + assert_eq!(selected(&s), [a], "the selection stays"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + let every = json!({ + "points": points, "fidelity": 2.0, "fit": true, "closed": false, "style": "brush", "fill": false, "keepSelected": true, + "extend": null, "connect": null, "redraw": null, "brush": "Charcoal" + }); + let id = NodeId(s.execute("brush.freehand", &every).unwrap()["id"].as_u64().unwrap()); + assert_eq!(brush_of(&s, id).as_deref(), Some("Charcoal")); +} + /// The Paintbrush skips a brush deleted after it was chosen: a deleted tool brush gives way to /// the document's current brush, and with neither the stroke is drawn without a brush. #[test] diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 243dce52e8..205f922078 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3576,9 +3576,9 @@ fn live_paint_bucket_scopes_follow_the_stroke_an_edge_draws() { assert_eq!(r["count"], json!(clear.len() + top.len() + hidden.len())); } -/// `livePaint.strokeEdge` paints an edge's topmost stroke that is shown, so the edge then draws -/// with the paint; an edge whose strokes are all hidden takes a new stroke above them. Painting -/// again changes nothing. +/// `livePaint.strokeEdge` leaves an edge one stroke, shown, with the paint, so the edge then draws +/// with it, whether its topmost shown stroke sat under a hidden one or every stroke was hidden. +/// Painting again changes nothing. #[test] fn live_paint_stroke_edge_paints_the_shown_stroke() { let mut s = session(); @@ -3613,8 +3613,107 @@ fn live_paint_stroke_edge_paints_the_shown_stroke() { paint(&mut s, at); assert_eq!(history_state(&s).0, depth, "painting {id} again"); } - assert_eq!(node(&s, m).appearance.items.len(), 2, "the hidden stroke stays as it was"); - assert_eq!(node(&s, l).appearance.items.len(), 2, "a new stroke above the hidden one"); + assert_eq!(node(&s, m).appearance.items.len(), 1, "one stroke in place of the hidden and the shown one"); + assert_eq!(node(&s, l).appearance.items.len(), 1, "one shown stroke in place of the hidden one"); +} + +/// A face with two fills that draw, under a hidden top fill: the Bucket replaces every fill with +/// one shown, fully opaque fill of the paint, keeps the face's strokes, opacity and their order, +/// and a click with the paint the face then has records nothing. A fill of none leaves no fill +/// that draws. The route that makes the objects into a group first paints the same way. +#[test] +fn live_paint_bucket_replaces_every_fill_row() { + use vectorcraft_doc::appearance::{FillLayer, StrokeLayer}; + let mut s = session(); + let g = live_squares(&mut s, &[0.0]); + let [f] = faces_at(&s, g, &[(25.0, 25.0)])[..] else { panic!("one face") }; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let stroke = StrokeLayer::new(blue.clone(), 3.0); + s.edit("t", |d, _| { + let n = d.node_mut(f).unwrap(); + n.opacity = 0.5; + let mut half = FillLayer::new(blue.clone()); + half.opacity = 0.5; + let mut hidden = FillLayer::new(green.clone()); + hidden.visible = false; + n.appearance.items = vec![ + AppearanceItem::Fill(FillLayer::new(red.clone())), + AppearanceItem::Stroke(stroke.clone()), + AppearanceItem::Fill(half), + AppearanceItem::Fill(hidden), + ]; + Ok(()) + }) + .unwrap(); + let depth = history_state(&s).0; + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00"})).unwrap(); + assert_eq!(history_state(&s).0, depth + 1, "a hidden top fill with the paint does not stop the fill"); + let n = node(&s, f); + assert_eq!(n.appearance.items, vec![AppearanceItem::Stroke(stroke.clone()), AppearanceItem::Fill(FillLayer::new(green.clone()))]); + assert_eq!(n.opacity, 0.5, "the face keeps its own opacity"); + s.recent_colors.clear(); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "color": "#00ff00"})).unwrap(); + assert_eq!((history_state(&s).0, s.recent_colors.len()), (depth + 1, 0), "painting again records nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 25], "none": true})).unwrap(); + let n = node(&s, f); + assert!(!n.appearance.has_drawn_fill(), "no fill draws after a fill of none"); + assert_eq!(n.appearance.items, vec![AppearanceItem::Stroke(stroke), AppearanceItem::Fill(FillLayer::new(Paint::None))]); + + // Objects made into a Live Paint group by the click: each object's two fills become one. + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + s.edit("t", |d, _| { + d.node_mut(id).unwrap().appearance.items.insert(0, AppearanceItem::Fill(FillLayer::new(red.clone()))); + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let r = s.execute("livePaint.fill", &json!({"point": [25, 25], "color": "#00ff00"})).unwrap(); + let face = NodeId(r["face"].as_u64().unwrap()); + let fills: Vec = node(&s, face).appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect(); + assert_eq!(fills, vec![green]); +} + +/// An edge with two strokes that draw: `livePaint.strokeEdge` replaces them with one stroke of the +/// paint and width, keeping the topmost one's dashes; a stroke of none leaves no stroke that +/// draws, and `livePaint.info` then lists that one row. +#[test] +fn live_paint_stroke_edge_replaces_every_stroke_row() { + use vectorcraft_doc::appearance::{Dash, StrokeLayer}; + let mut s = session(); + let g = live_squares(&mut s, &[0.0, 50.0]); + let middle = edges_in(&s, g, Rect::new(50.0, 0.0, 50.0, 50.0)); + let [m] = middle[..] else { panic!("one middle edge") }; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let dash = Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }; + s.edit("t", |d, _| { + let mut top = StrokeLayer::new(green.clone(), 2.0); + top.opacity = 0.5; + top.dash = Some(dash.clone()); + d.node_mut(m).unwrap().appearance.items = vec![AppearanceItem::Stroke(StrokeLayer::new(red.clone(), 6.0)), AppearanceItem::Stroke(top)]; + Ok(()) + }) + .unwrap(); + let depth = history_state(&s).0; + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "color": "#00ff00", "width": 2})).unwrap(); + assert_eq!(history_state(&s).0, depth + 1, "a half-transparent top stroke with the paint does not stop the stroke"); + let mut want = StrokeLayer::new(green.clone(), 2.0); + want.dash = Some(dash); + assert_eq!(node(&s, m).appearance.items, vec![AppearanceItem::Stroke(want)]); + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "color": "#00ff00", "width": 2})).unwrap(); + assert_eq!(history_state(&s).0, depth + 1, "painting again records nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 25], "none": true, "width": 2})).unwrap(); + assert!(!node(&s, m).appearance.has_drawn_stroke(), "no stroke draws after a stroke of none"); + let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap(); + let edge = info["edges"].as_array().unwrap().iter().find(|e| e["id"] == json!(m.0)).unwrap().clone(); + let rows = edge["appearance"].as_array().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!((&rows[0]["kind"], &rows[0]["drawn"], &edge["stroke"]), (&json!("stroke"), &json!(false), &json!(Paint::None.label()))); } /// With Cmd held, the Live Paint Bucket's presses go to the selection tool Cmd lends, and the @@ -5341,8 +5440,9 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node .unwrap() } +/// The traced paths of an Image Trace object: its children but the image and the seam fills. fn traced_paths(n: &Node) -> Vec> { - n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect() + n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_)) && c.name.as_deref() != Some("Seam Fills")).cloned().collect() } #[test] @@ -5588,6 +5688,57 @@ fn a_trace_run_applies_only_in_its_own_document() { } } +/// A trace prepared before File › Revert doesn't apply after it, even when the document keeps its +/// uid and the restored image, object and settings are the same: both trace commands drop the +/// trace handed in and trace the restored image again, as one undo step. Reverting another +/// document, or a revert that fails, leaves the run current, and a trace prepared after the revert +/// applies as usual. +#[test] +fn a_trace_run_prepared_before_a_revert_does_not_apply_after_it() { + let p = json!({"params": {"ignoreWhite": true}}); + for command in ["imageTrace.make", "imageTrace.makeAndExpand"] { + let mut direct = session(); + image_doc(&mut direct); + let want = direct.execute(command, &p).unwrap(); + let mut s = session(); + let img = image_doc(&mut s); + let a = s.active_index().unwrap(); + let copy = (*s.doc().unwrap().doc).clone(); + let run = cmd::TraceRun::prepare(&s, command, &p).unwrap(); + let traced = run.run(&|_, _| true).unwrap(); + let b = s.add_document(copy.clone(), None); + assert!(s.replace_document(b, copy.clone())); + assert!(s.set_active(a)); + assert!(run.current(&s) && !run.reverted(&s), "{command}: another document reverted"); + assert!(!s.replace_document(s.documents().len(), copy.clone())); + assert!(run.current(&s) && !run.reverted(&s), "{command}: a revert that failed"); + let (uid, reverts) = (s.doc().unwrap().uid, s.doc().unwrap().reverts); + assert!(s.replace_document(a, copy)); + assert_eq!(s.doc().unwrap().uid, uid, "{command}: the tab keeps its uid"); + assert_eq!(s.doc().unwrap().reverts, reverts + 1, "{command}"); + assert!(!run.current(&s), "{command}: reverted"); + assert!(run.reverted(&s), "{command}"); + traced.hand_in(&mut s); + let traces = cmd::TraceRun::traces_on_this_thread(); + let got = s.execute(run.command(), run.params()).unwrap(); + assert_eq!(cmd::TraceRun::traces_on_this_thread(), traces + 1, "{command}: traced again"); + assert!(s.trace_hand_off.is_none(), "{command}: dropped"); + assert_eq!(got, want, "{command}"); + let g = NodeId(got["id"].as_u64().unwrap()); + assert_eq!(node(&s, g), node(&direct, g), "{command}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(node(&s, img).kind, NodeKind::Image(_)), "{command}: one undo step"); + // A trace prepared after the revert is handed in and applied without tracing again. + let after = cmd::TraceRun::prepare(&s, command, run.params()).unwrap(); + let traced = after.run(&|_, _| true).unwrap(); + assert!(after.current(&s) && !after.reverted(&s), "{command}: prepared after the revert"); + traced.hand_in(&mut s); + let traces = cmd::TraceRun::traces_on_this_thread(); + s.execute(after.command(), after.params()).unwrap(); + assert_eq!(cmd::TraceRun::traces_on_this_thread(), traces, "{command}: the trace handed in applied"); + } +} + /// A plug-in run that calls Image Trace and then fails leaves a trace handed in for the command in /// place, as it leaves the document. #[test] @@ -5961,6 +6112,73 @@ fn image_trace_release_and_expand() { assert!(s.execute("imageTrace.release", &json!({})).is_err()); } +/// Abutting with Fills: the strokes along the edges the paths share sit in a "Seam Fills" group +/// under the paths, in the lower path's color, and stay under them through Expand by color. +#[test] +fn image_trace_abutting_seam_fills_sit_under_the_paths() { + // Red beside blue, the edge between them anti-aliased. + let art = vectorcraft_trace::Raster::from_fn(40, 20, |x, _| { + let share = (16.4 - f64::from(x)).clamp(0.0, 1.0); + let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8; + [mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255] + }); + let seams = |n: &Node| n.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Seam Fills")).cloned(); + let made = |method: &str, cmd: &str, by_color: bool| { + let mut s = session(); + let img = add_image(&mut s, &art, Affine::scale(2.0)); + let params = json!({"mode": "color", "palette": "limited", "colors": 2, "method": method}); + let mut p = json!({ "params": params }); + if by_color { + p["byColor"] = json!(true); + } + let r = s.execute(cmd, &p).unwrap(); + (s, img, r) + }; + let (mut s, _, r) = made("abutting", "imageTrace.make", false); + assert_eq!(r["paths"], 2, "{r}"); + let g = NodeId(r["id"].as_u64().unwrap()); + let n = node(&s, g); + let kids = n.children().unwrap(); + assert_eq!(kids.len(), 4, "the image, the seam fills and two paths"); + assert!(matches!(kids[0].kind, NodeKind::Image(_))); + let group = seams(&n).unwrap(); + assert!(Arc::ptr_eq(&group, &kids[1]), "the seam fills come under the paths"); + let strokes = group.children().unwrap(); + assert_eq!(strokes.len(), 1); + assert_eq!(strokes[0].appearance.fill_paint(), Paint::None); + let AppearanceItem::Stroke(layer) = &strokes[0].appearance.items[0] else { panic!("{:?}", strokes[0].appearance) }; + assert_eq!((layer.cap, layer.join, layer.width), (LineCap::Butt, LineJoin::Round, 3.0), "1.5 image pixels, the image scaled 2×"); + assert_eq!(layer.paint, kids[2].appearance.fill_paint(), "the lower path's color"); + // The stroke runs along the edge, from the image's top to its bottom. + let bb = outline(&strokes[0]).0.bounds().unwrap(); + assert!((bb.center().x - 32.8).abs() < 1.0 && bb.y0.abs() < 1e-9 && (bb.y1 - 40.0).abs() < 1e-9, "{bb:?}"); + // Expand by color keeps the seam fills under the color groups, renamed too; Release gives the + // image back. + s.execute("layer.setProps", &json!({"id": group.id.0, "name": "Edges"})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let n = node(&s, g); + let names: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect(); + assert_eq!(names.len(), 3, "{names:?}"); + assert_eq!(names[0], "Edges", "{names:?}"); + assert_eq!(n.children().unwrap()[0].id, group.id); + let (mut s, img, _) = made("abutting", "imageTrace.make", false); + assert_eq!(ids(&s.execute("imageTrace.release", &json!({})).unwrap()), vec![img]); + // Make and Expand by color too. + let (s, _, r) = made("abutting", "imageTrace.makeAndExpand", true); + let n = node(&s, NodeId(r["id"].as_u64().unwrap())); + assert_eq!(n.children().unwrap()[0].name.as_deref(), Some("Seam Fills")); + // Overlapping paths are stacked: no seam fills. + let (s, _, r) = made("overlapping", "imageTrace.make", false); + assert!(seams(&node(&s, NodeId(r["id"].as_u64().unwrap()))).is_none()); + // An image stretched 3× across and squeezed to half height: 1.5 pixels at the larger scale. + let mut s = session(); + add_image(&mut s, &art, Affine::scale_non_uniform(3.0, 0.5)); + let r = s.execute("imageTrace.make", &json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "method": "abutting"}})).unwrap(); + let group = seams(&node(&s, NodeId(r["id"].as_u64().unwrap()))).unwrap(); + let AppearanceItem::Stroke(layer) = &group.children().unwrap()[0].appearance.items[0] else { panic!("{group:?}") }; + assert!((layer.width - 4.5).abs() < 1e-9, "{}", layer.width); +} + /// Expand by color: each color's paths in a group of their own, named by the color, the groups /// stacked by the lowest path in each and the art drawn as before; Release to Layers then gives a /// sublayer for each color. @@ -5995,7 +6213,11 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { }; let (flat, fg) = expand(false); let (mut s, g) = expand(true); - let paths = node(&flat, fg).children().unwrap().clone(); + let kids = node(&flat, fg).children().unwrap().clone(); + // Abutting: the seam fills' group comes first, under every path, with byColor too. + let seams = usize::from(method == "abutting"); + assert!(kids.iter().take(seams).all(|c| c.name.as_deref() == Some("Seam Fills")), "{method}"); + let paths = kids[seams..].to_vec(); // Without byColor (the default) the paths sit in the trace's group, with no group of their own. assert!(paths.iter().all(|p| !matches!(p.kind, NodeKind::Group { .. })), "{method}"); let (default, dg) = { @@ -6004,7 +6226,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); (s, NodeId(r["id"].as_u64().unwrap())) }; - assert!(*node(&default, dg).children().unwrap() == paths, "{method}: byColor is false when not given"); + assert!(*node(&default, dg).children().unwrap() == kids, "{method}: byColor is false when not given"); // The colors in the order they first come in, bottom to top. let mut order: Vec = vec![]; for p in &paths { @@ -6019,10 +6241,11 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { // Overlapping fills the ring whole and draws the field inside it above the ring: that path starts a second // group of the field's color, above the black one. let want: Vec = if method == "overlapping" { order.iter().chain(order.first()).cloned().collect() } else { order.clone() }; - assert_eq!(names(&n), want, "{method}"); + assert_eq!(names(&n)[seams..], want, "{method}"); + assert!(n.children().unwrap().iter().take(seams).all(|c| *c == kids[0]), "{method}: the seam fills stay as they are"); // Each group holds its color's paths, and in turn they are the paths of the trace without // byColor, in their order. - let groups = n.children().unwrap(); + let groups = &n.children().unwrap()[seams..]; for c in groups { assert!(matches!(c.kind, NodeKind::Group { .. }), "{method}"); assert!(c.children().unwrap().iter().all(|m| Some(hex(m)) == c.name), "{method}: {:?}", c.name); @@ -6033,8 +6256,8 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { // Release to Layers: a sublayer for each color, in the same order. let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap(); let subs = ids(&r); - assert_eq!(subs.len(), want.len(), "{method}"); - for (sub, name) in subs.iter().zip(&want) { + assert_eq!(subs.len(), seams + want.len(), "{method}"); + for (sub, name) in subs[seams..].iter().zip(&want) { let l = node(&s, *sub); assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}"); assert_eq!(names(&l), std::slice::from_ref(name), "{method}"); @@ -6051,7 +6274,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); let mut got = names(&node(&s, g)); got.sort(); - assert_eq!(got, ["#000000", "#c82832", "#f0f0dc"]); + assert_eq!(got, ["#000000", "#c82832", "#f0f0dc", "Seam Fills"]); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); @@ -6086,7 +6309,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { }); add_image(&mut s, &stripes, Affine::IDENTITY); let g = NodeId(s.execute("imageTrace.make", &json!({"params": grouped})).unwrap()["id"].as_u64().unwrap()); - assert!(node(&s, g).children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Group { .. }))); + assert!(traced_paths(&node(&s, g)).iter().all(|c| !matches!(c.kind, NodeKind::Group { .. }))); let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string(); assert!(e.contains("Auto Grouping"), "{e}"); assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); @@ -6103,8 +6326,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { add_image(&mut s, &r, Affine::IDENTITY); let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); - let paths: Vec<(u64, String)> = - node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| (c.id.0, hex(c))).collect(); + let paths: Vec<(u64, String)> = traced_paths(&node(&s, g)).iter().map(|c| (c.id.0, hex(c))).collect(); let mut colors: Vec<&str> = paths.iter().map(|(_, h)| h.as_str()).collect(); colors.sort(); colors.dedup(); @@ -6116,14 +6338,14 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); let mut got = names(&node(&s, g)); got.sort(); - assert_eq!(got, ["#28a03c", "Ink", "Sky 50%"]); + assert_eq!(got, ["#28a03c", "Ink", "Seam Fills", "Sky 50%"]); // A swatch named as another color's #rrggbb still gets a group of its own. s.execute("edit.undo", &json!({})).unwrap(); s.execute("swatch.edit", &json!({"name": "Ink", "newName": "#28a03c"})).unwrap(); s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); let mut got = names(&node(&s, g)); got.sort(); - assert_eq!(got, ["#28a03c", "#28a03c", "Sky 50%"]); + assert_eq!(got, ["#28a03c", "#28a03c", "Seam Fills", "Sky 50%"]); // Gradient fills share one group, and a stroked line goes by its stroke. let ramp = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| { @@ -6192,7 +6414,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { add_image(&mut s, &stripes, Affine::IDENTITY); let limited = json!({"mode": "color", "palette": "limited", "colors": 3, "method": "abutting"}); let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); - let paths: Vec> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect(); + let paths = traced_paths(&node(&s, g)); assert_eq!(paths.len(), 3); let (first, middle, last) = (&paths[0], &paths[1], &paths[2]); s.execute("paint.setFill", &json!({"ids": [last.id.0], "color": hex(first)})).unwrap(); @@ -6220,7 +6442,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { for how in ["move", "transform", "shadow", "beside"] { let (flat, _) = edited(false, how); let (s, g) = edited(true, how); - assert_eq!(names(&node(&s, g)).len(), 3, "{how}"); + assert_eq!(names(&node(&s, g)).len(), 4, "{how}: the seam fills and three colors"); assert!(shot(&s) == shot(&flat), "{how}: the edited art changed"); } @@ -6230,14 +6452,14 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { add_image(&mut s, &stripes, Affine::IDENTITY); let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); - let paths: Vec = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect(); + let paths: Vec = traced_paths(&node(&s, g)).iter().map(|c| c.id.0).collect(); assert_eq!(paths.len(), 3); s.execute("paint.setFill", &json!({"ids": [paths[0]], "swatch": "Sky", "tint": 50.1})).unwrap(); s.execute("paint.setFill", &json!({"ids": [paths[1], paths[2]], "swatch": "Sky", "tint": 50.4})).unwrap(); s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); let groups = node(&s, g).children().unwrap().clone(); - assert_eq!(names(&node(&s, g)), ["Sky 50%", "Sky 50%"]); - assert_eq!(groups.iter().map(|c| c.children().unwrap().len()).collect::>(), [1, 2]); + assert_eq!(names(&node(&s, g)), ["Seam Fills", "Sky 50%", "Sky 50%"]); + assert_eq!(groups.iter().skip(1).map(|c| c.children().unwrap().len()).collect::>(), [1, 2]); } #[test] @@ -6289,7 +6511,7 @@ fn image_trace_make_and_expand_and_color_presets() { assert_eq!(res["paths"], 4); let g = node(&s, NodeId(res["id"].as_u64().unwrap())); assert!(g.name.is_none()); - assert_eq!(g.children().unwrap().len(), 4); + assert_eq!(traced_paths(&g).len(), 4); assert!(s.execute("imageTrace.make", &json!({"preset": "No Such Preset"})).is_err()); assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err()); } diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index be79004be2..368bc6de10 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -531,7 +531,726 @@ fn a_pasted_graph_brings_and_follows_its_label_styles_and_paints() { new_doc(&mut s); let v = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); assert!(doc(&s).char_styles.is_empty() && doc(&s).para_styles.is_empty(), "{v}"); - assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand, None, None)); + // The value labels' red characters stay behind too; the series fill and the legend rows keep Brand. + assert_eq!(graph_links(&s, ids_of(&v)[0]), (vec!["Brand".to_string(); 2], None, None)); +} + +/// Each label of graph `id` (the text of its Axes and Legend groups) with its size and its first paragraph's format. +fn graph_label_looks(s: &Session, id: NodeId) -> Vec<(String, f64, vectorcraft_doc::ParaStyle)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + out.push((t.plain_text(), t.first_style().size, t.para_at(0).clone())); + } + }); + out +} + +/// Paste without Formatting sets a pasted graph's labels in a new graph's label size and paragraph attributes, and the +/// graph keeps them when it is drawn again: the character and paragraph formats its labels keep stay behind with their +/// text formatting. +#[test] +fn a_graph_pasted_without_formatting_keeps_plain_labels_when_drawn_again() { + let mut s = session(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4"; + let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap()); + let fresh = graph_label_looks(&s, g); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + s.execute("text.setFormat", &json!({"spaceAfter": 6})).unwrap(); + assert!(graph_label_looks(&s, g).iter().all(|(_, size, para)| *size == 14.0 && para.space_after == 6.0)); + // The labels as drawn are also set in a character style. + s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 14.0}})).unwrap(); + let mut labels = vec![]; + node(&s, g).walk(&mut |n| { + if matches!(n.kind, NodeKind::Text(_)) { + labels.push(n.id); + } + }); + for id in labels { + if let Some(NodeKind::Text(t)) = doc_mut(&mut s).node_mut(id).map(|n| &mut n.kind) { + t.runs.iter_mut().for_each(|r| r.style.style_name = Some("Emphasis".into())); + } + } + copy(&mut s, &[g]); + assert_eq!(s.clipboard.char_styles.iter().map(|c| c.name.as_str()).collect::>(), ["Emphasis"]); + // Where a redraw of the copied graph puts its plot. + s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap(); + let plot = node(&s, g).extras().graph.as_deref().unwrap().rect; + + new_doc(&mut s); + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 10, "dy": 20})).unwrap(); + let p = ids_of(&v)[0]; + assert!(doc(&s).char_styles.is_empty(), "the labels' character style stays behind: {v}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Paste without Formatting")); + let l = node(&s, p).extras().graph.as_deref().unwrap().label_styles.clone(); + assert_eq!((l.values, l.categories, l.legend), (None, None, None)); + assert_eq!((l.values_para, l.categories_para, l.legend_para), (None, None, None)); + let pasted = graph_label_looks(&s, p); + assert_eq!(pasted, fresh, "the labels are set as a new graph's are"); + + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert_eq!(graph_label_looks(&s, p), pasted, "and stay so when the graph is drawn again"); + let r = node(&s, p).extras().graph.as_deref().unwrap().rect; + let off = [r.x0 - plot.x0 - 10.0, r.y0 - plot.y0 - 20.0, r.x1 - plot.x1 - 10.0, r.y1 - plot.y1 - 20.0]; + assert!(off.iter().all(|d| d.abs() < 1e-6), "the plot moves by the offset and keeps its size: {plot:?} → {r:?}"); + s.execute("graph.setType", &json!({"id": p.0, "type": "column"})).unwrap(); + assert_eq!(graph_label_looks(&s, p), pasted, "a change of type draws them the same way"); + + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(doc(&s).node(p).is_none(), "one undo takes the paste back"); +} + +/// Paste without Formatting leaves the rest of a pasted graph's art as it was copied: a column painted by hand keeps its +/// paint, and nothing is drawn again. +#[test] +fn a_graph_pasted_without_formatting_keeps_its_art() { + let mut s = session(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4"; + let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap()); + let red = Paint::solid(hex("ff0000")); + let mut column = None; + node(&s, g).walk(&mut |n| { + if column.is_none() && matches!(n.kind, NodeKind::Path { .. }) && n.appearance.fill_paint() != Paint::None { + column = Some(n.id); + } + }); + let column = column.unwrap(); + let n = doc_mut(&mut s).node_mut(column).unwrap(); + n.appearance = vectorcraft_doc::Appearance::basic(red.clone(), Paint::None, 0.0).into(); + /// Every object of graph `id` that is not type: its kind's name, its fill and its bounds. + fn art(s: &Session, id: NodeId) -> Vec<(String, Paint, Option)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if !matches!(n.kind, NodeKind::Text(_)) { + out.push((format!("{:?}", std::mem::discriminant(&n.kind)), n.appearance.fill_paint(), n.geometric_bounds())); + } + }); + out + } + let copied = art(&s, g); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({"dx": 15, "dy": 25})).unwrap())[0]; + let pasted = art(&s, p); + let dx = pasted[0].2.unwrap().x0 - copied[0].2.unwrap().x0; + let dy = pasted[0].2.unwrap().y0 - copied[0].2.unwrap().y0; + let moved: Vec<_> = copied.into_iter().map(|(k, f, b)| (k, f, b.map(|b| b + vectorcraft_geom::Vec2::new(dx, dy)))).collect(); + assert_eq!(pasted.len(), moved.len()); + for (a, b) in pasted.iter().zip(&moved) { + assert_eq!((&a.0, &a.1), (&b.0, &b.1)); + let (a, b) = (a.2.unwrap(), b.2.unwrap()); + assert!((a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-6, "{a:?} {b:?}"); + } + assert!(pasted.iter().any(|(_, f, _)| *f == red), "the column keeps the paint it was given"); +} + +/// The labels of a scaled graph pasted without formatting are set at a new graph's label size, unscaled, as a redraw +/// sets them. +#[test] +fn a_scaled_graph_pasted_without_formatting_gets_unscaled_labels() { + let mut s = session(); + let csv = ",2024\nQ1,3\nQ2,5"; + let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 50, "y": 50, "width": 120, "height": 80, "csv": csv})).unwrap()); + let fresh = graph_label_looks(&s, g)[0].1; + sel(&mut s, &[g]); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + /// Each label's size as drawn: its character size times the scale of its transform. + fn drawn_sizes(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + out.push(t.first_style().size * t.xf.determinant().abs().sqrt()); + } + }); + out + } + assert!(drawn_sizes(&s, g).iter().all(|z| (z - fresh * 2.0).abs() < 1e-6), "{:?}", drawn_sizes(&s, g)); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert!(drawn_sizes(&s, p).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, p)); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert!(drawn_sizes(&s, p).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, p)); + + // A rotated graph's labels keep their rotation, so they stay turned with the rest of its art. + /// Each label's rotation in degrees: the angle of its transform's x axis. + fn angles(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + let [a, b, ..] = t.xf.as_coeffs(); + out.push(b.atan2(a).to_degrees()); + } + }); + out + } + let r = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 300, "y": 50, "width": 120, "height": 80, "csv": csv})).unwrap()); + sel(&mut s, &[r]); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let turned = angles(&s, r); + assert!(turned.iter().all(|a| (a.abs() - 30.0).abs() < 1e-6), "{turned:?}"); + copy(&mut s, &[r]); + new_doc(&mut s); + let q = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(angles(&s, q).len(), turned.len()); + assert!(angles(&s, q).iter().zip(&turned).all(|(a, b)| (a - b).abs() < 1e-6), "{:?} against {turned:?}", angles(&s, q)); + assert!(drawn_sizes(&s, q).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, q)); +} + +/// A pie's legend labels in their wedges keep a redraw's contrast when pasted without formatting: white on a dark +/// wedge, black on a light one. +#[test] +fn pie_labels_in_wedges_pasted_without_formatting_stay_readable() { + let mut s = session(); + let csv = ",North,South\nP,1,1"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + /// Each text of graph `id` with its characters' fill. + fn fills(s: &Session, id: NodeId) -> Vec<(String, Paint)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + out.push((t.plain_text(), t.first_style().fill.clone())); + } + }); + out + } + let drawn = fills(&s, g); + assert!(drawn.contains(&("North".into(), Paint::solid(Color::WHITE))), "the first wedge is black: {drawn:?}"); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(fills(&s, p), drawn); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert_eq!(fills(&s, p), drawn); +} + +/// A pie whose legend labels were given a black fill of their own loses that mark when pasted without formatting, so a +/// later change to a legend label's size does not bring the black fill back on the dark wedge. +#[test] +fn a_legend_fill_of_its_own_stays_behind_when_pasted_without_formatting() { + let mut s = session(); + let csv = ",North,South\nP,1,1"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + /// The legend label of graph `id` that says `name`. + fn label(s: &Session, id: NodeId, name: &str) -> (NodeId, Paint) { + let mut out = None; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text() == name + { + out = Some((n.id, t.first_style().fill.clone())); + } + }); + out.unwrap() + } + let (north, fill) = label(&s, g, "North"); + assert_eq!(fill, Paint::solid(Color::WHITE), "white on the black wedge"); + s.execute("text.setStyle", &json!({"ids": [north.0], "fill": "#000000"})).unwrap(); + assert!(node(&s, g).extras().graph.as_deref().unwrap().label_styles.legend_fill_set); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert!(!node(&s, p).extras().graph.as_deref().unwrap().label_styles.legend_fill_set); + assert_eq!(label(&s, p, "North").1, Paint::solid(Color::WHITE), "pasted as a redraw sets it"); + let (north, _) = label(&s, p, "North"); + s.execute("text.setStyle", &json!({"ids": [north.0], "size": 11})).unwrap(); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert_eq!(label(&s, p, "North").1, Paint::solid(Color::WHITE), "still white when the pie is drawn again"); +} + +/// A two-line legend label in a narrow dark wedge, whose text sits off the wedge's middle line, stays white when its graph +/// is pasted without formatting. +#[test] +fn a_legend_label_in_a_narrow_wedge_pasted_without_formatting_stays_readable() { + let mut s = session(); + // The first wedge fills a quarter from the top; the dark fourth series ("Narrow", series 3) follows it for 0.2 degrees, + // narrower than the label's distance from the wedge's middle line. + let csv = ",A,B,C,Narrow|,E\nP,9,0,0,0.02,26.98"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + fn narrow(s: &Session, id: NodeId) -> Paint { + let mut out = None; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text().starts_with("Narrow") + { + out = Some(t.first_style().fill.clone()); + } + }); + out.unwrap() + } + assert_eq!(narrow(&s, g), Paint::solid(Color::WHITE)); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(narrow(&s, p), Paint::solid(Color::WHITE)); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert_eq!(narrow(&s, p), Paint::solid(Color::WHITE)); + // A standard legend beside the pie stays black. + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "standard"})).unwrap(); + assert_eq!(narrow(&s, g), Paint::solid(Color::BLACK)); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(narrow(&s, p), Paint::solid(Color::BLACK)); + // Two series with one name: each label keeps its own wedge's contrast, though the second label lies nearer the + // middle of the first (dark) wedge's bounds than of its own. + fn xs(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text() == "X" + { + out.push(t.first_style().fill.clone()); + } + }); + out + } + let csv = ",X,X,Y\nP,8,1,89"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 0, "y": 0, "width": 2000, "height": 2000, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + let drawn = xs(&s, g); + assert_eq!(drawn, vec![Paint::solid(Color::WHITE), Paint::solid(Color::BLACK)]); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(xs(&s, p), drawn); +} + +/// Two-line legend labels of a light and a dark series with one name keep a redraw's colors when pasted without +/// formatting, though the narrow light wedge's label reaches into the dark wedge after it. +#[test] +fn shared_name_labels_in_a_narrow_wedge_pasted_without_formatting_keep_their_colors() { + let mut s = session(); + let csv = ",A,B,Q|,Q|\nP,9,0,0.02,26.98"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + fn qs(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text().starts_with('Q') + { + out.push(t.first_style().fill.clone()); + } + }); + out + } + let drawn = qs(&s, g); + assert_eq!(drawn.len(), 2); + assert_ne!(drawn[0], drawn[1], "one light and one dark series"); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(qs(&s, p), drawn); + // Names that hold a line break of their own (a redraw sets them on lines centered on the point, as it sets `|`) keep + // them too. + for name in ["Q\nQ", "Q\nQ\nQ\nQ\nQ\nQ\nQ\nQ\nQ"] { + new_doc(&mut s); + let named = json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "series": ["A", "B", name, name], "categories": ["P"], "rows": [[9, 0, 0.02, 26.98]]}); + let g = id_of(&s.execute("graph.create", &named).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + let broken = qs(&s, g); + assert_eq!(broken.len(), 2); + assert_ne!(broken[0], broken[1], "names with line breaks"); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(qs(&s, p), broken, "names with {} line breaks", name.matches('\n').count()); + } + // So do labels whose names show the same text, one with a line break of its own and one with `|`. + new_doc(&mut s); + let named = json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "series": ["Q\n", "B", "Q|", "Q|"], "categories": ["P"], "rows": [[9, 0, 0.02, 26.98]]}); + let g = id_of(&s.execute("graph.create", &named).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + // The fills of the Q labels by their place from left to right and top to bottom. + fn placed(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text().starts_with('Q') + { + let o = t.xf.translation(); + out.push((o.x, o.y, t.first_style().fill.clone())); + } + }); + out.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))); + out.into_iter().map(|(_, _, f)| f).collect() + } + let alike = placed(&s, g); + assert_eq!(alike.len(), 3); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(placed(&s, p), alike, "names that show the same text"); + // The narrow light wedge's label (the second) brought to the front keeps them too. + new_doc(&mut s); + let g = id_of(&s.execute("graph.create", &named).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + let legend = node(&s, g).children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap().clone(); + let labels = legend.children().unwrap(); + assert_eq!(labels.len(), 3); + s.execute("object.arrange.bringToFront", &json!({"ids": [labels[1].id.0]})).unwrap(); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(placed(&s, p), alike, "names that show the same text, labels reordered"); + // Labels given another size, which leaves them where they were drawn, keep them too. + new_doc(&mut s); + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(qs(&s, p), drawn, "resized labels"); + // Large labels of four series of one name, drawn again at that size, keep the colors that drawing gave them. + new_doc(&mut s); + let four = ",Q|,Q|,Q|,Q|\nP,1,1,1,1"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": four})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 200})).unwrap(); + s.execute("graph.setData", &json!({"id": g.0, "csv": four})).unwrap(); + let large = placed(&s, g); + assert_eq!(large.len(), 4); + assert!(large.contains(&Paint::solid(Color::WHITE)) && large.contains(&Paint::solid(Color::BLACK)), "{large:?}"); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(placed(&s, p), large, "large labels drawn again"); + // A scaled and rotated pie keeps them too. + new_doc(&mut s); + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + sel(&mut s, &[g]); + s.execute("object.scale", &json!({"sx": 400, "sy": 400})).unwrap(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let drawn = qs(&s, g); + assert_ne!(drawn[0], drawn[1]); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(qs(&s, p), drawn); +} + +/// The legend labels of a pie that kept a fill of their own (red, or black on every wedge), scaled, with two series of +/// one name, are pasted without formatting in the colors a redraw gives them. +#[test] +fn recolored_wedge_labels_pasted_without_formatting_match_a_redraw() { + for fill in ["#ff0000", "#000000"] { + recolored_wedge_labels_match_a_redraw(fill); + } +} + +fn recolored_wedge_labels_match_a_redraw(fill: &str) { + let mut s = session(); + let csv = ",A,B,Q|,Q|\nP,9,0,0.02,26.98"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"fill": fill})).unwrap(); + s.execute("object.scale", &json!({"sx": 400, "sy": 400})).unwrap(); + fn legend(s: &Session, id: NodeId) -> Vec<(String, Paint)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && ["A", "Q"].iter().any(|p| t.plain_text().starts_with(p)) + { + out.push((t.plain_text(), t.first_style().fill.clone())); + } + }); + out + } + assert!(legend(&s, g).iter().all(|(_, f)| *f == Paint::solid(hex(fill))), "{fill}: {:?}", legend(&s, g)); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + let pasted = legend(&s, p); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + let redrawn = legend(&s, p); + assert_eq!(pasted, redrawn, "{fill}"); + assert!( + redrawn.iter().any(|(_, f)| *f == Paint::solid(Color::WHITE)) && redrawn.iter().any(|(_, f)| *f == Paint::solid(Color::BLACK)), + "{fill}: {redrawn:?}" + ); +} + +/// A wedge whose fill was changed after its label was drawn gives the label, pasted without formatting, the color a +/// redraw gives it. +#[test] +fn a_repainted_wedge_sets_its_label_color_when_pasted_without_formatting() { + let mut s = session(); + let csv = ",North,South\nP,1,1"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap(); + fn labels(s: &Session, id: NodeId) -> Vec<(String, Paint)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && ["North", "South"].contains(&t.plain_text().as_str()) + { + out.push((t.plain_text(), t.first_style().fill.clone())); + } + }); + out + } + // Each wedge takes the other's contrast: the dark one (its label white) white, the light one black. + let drawn = labels(&s, g); + let white = |name: &str| drawn.iter().any(|(t, f)| t == name && *f == Paint::solid(Color::WHITE)); + let mut wedges = vec![]; + for c in node(&s, g).children().unwrap().iter() { + let Some(series) = c.extras().series_index else { continue }; + let dark = white(["North", "South"][series as usize]); + c.walk(&mut |m| { + if let NodeKind::Path { .. } = m.kind { + wedges.push((m.id, dark)); + } + }); + } + assert!(wedges.iter().any(|w| w.1) && wedges.iter().any(|w| !w.1), "{drawn:?}"); + for (id, dark) in &wedges { + s.execute("paint.setFill", &json!({"ids": [id.0], "color": if *dark { "#ffffff" } else { "#000000" }})).unwrap(); + } + let before = labels(&s, g); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + let pasted = labels(&s, p); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert_eq!(pasted, labels(&s, p), "copied as {before:?}"); +} + +/// When one pie's wedge of a series is repainted and another pie's wedge of that series is not, each pasted legend label +/// takes the contrast of the wedge it is in. +#[test] +fn wedges_of_one_series_painted_apart_keep_their_label_colors_when_pasted_without_formatting() { + let mut s = session(); + fn north(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text() == "North" + { + out.push(t.first_style().fill.clone()); + } + }); + out + } + // Two pies side by side, and two stacked pies of one series (a disk on a larger one, and on one nearly its size). + for (csv, position) in [(",North,South\nP,1,1\nQ,1,1", "even"), (",North\nP,1\nQ,3", "stacked"), (",North\nP,1000\nQ,999", "stacked")] { + // The second time, the repainted wedge is brought to the front of its series first. + let mut pasted = vec![]; + for reorder in [false, true] { + pasted.push(paste_painted_apart(&mut s, csv, position, reorder, north)); + } + assert_eq!(pasted[0], pasted[1], "{csv}"); + } + // A series name of nine lines, from line breaks a quoted CSV cell keeps, on two stacked pies: each label is centered + // on its own ring, as a redraw centers it. + fn tall(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text() == ["N"; 9].join("\n") + { + out.push(t.first_style().fill.clone()); + } + }); + out + } + let csv = format!(",\"{}\"\nP,1\nQ,3", ["N"; 9].join("\n")); + paste_painted_apart(&mut s, &csv, "stacked", false, tall); +} + +/// Paste without Formatting a pie graph made from `csv` with pies at `position` and Legends in Wedges, after its first +/// wedge of the first series takes the other contrast and, with `reorder`, comes to the front of its series. Returns +/// what `north` reads from the pasted graph, after checking the two values differ. +fn paste_painted_apart(s: &mut Session, csv: &str, position: &str, reorder: bool, north: fn(&Session, NodeId) -> Vec) -> Vec { + new_doc(s); + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 400, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges", "piePosition": position})).unwrap(); + let drawn = north(s, g); + assert_eq!(drawn.len(), 2); + assert_eq!(drawn[0], drawn[1]); + // Repaint the first North wedge with the contrast its label shows, so it takes the other contrast. + let mut first = None; + for c in node(s, g).children().unwrap().iter() { + if c.extras().series_index == Some(0) && first.is_none() { + c.walk(&mut |m| { + if first.is_none() && matches!(m.kind, NodeKind::Path { .. }) { + first = Some(m.id); + } + }); + } + } + let first = first.unwrap(); + let color = if drawn[0] == Paint::solid(Color::WHITE) { "#ffffff" } else { "#000000" }; + s.execute("paint.setFill", &json!({"ids": [first.0], "color": color})).unwrap(); + if reorder { + s.execute("object.arrange.bringToFront", &json!({"ids": [first.0]})).unwrap(); + } + copy(s, &[g]); + new_doc(s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + let labels = north(s, p); + assert_ne!(labels[0], labels[1], "drawn as {drawn:?}, {position}, reorder {reorder}"); + labels +} + +/// Legend labels of series that share a name, brought into another order, each keep the contrast of the wedge they are +/// in when the pie is pasted without formatting. +#[test] +fn reordered_shared_name_labels_keep_their_colors_when_pasted_without_formatting() { + let mut s = session(); + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": ",X,X\nP,1,1"})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + // Each label's fill, by its place from left to right and top to bottom. + fn labels(s: &Session, id: NodeId) -> (Vec, Vec) { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text() == "X" + { + let o = t.xf.translation(); + out.push((o.x, o.y, n.id, t.first_style().fill.clone())); + } + }); + out.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))); + out.into_iter().map(|(_, _, id, f)| (id, f)).unzip() + } + let (ids, drawn) = labels(&s, g); + assert_eq!(drawn.len(), 2); + assert_ne!(drawn[0], drawn[1]); + let first = node(&s, g).children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap().children().unwrap()[0].id; + assert!(ids.contains(&first)); + s.execute("object.arrange.bringToFront", &json!({"ids": [first.0]})).unwrap(); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(labels(&s, p).1, drawn); +} + +/// A wedge's hidden fill does not set its legend label's color: the fill it shows does. +#[test] +fn a_hidden_wedge_fill_does_not_color_its_label() { + let mut s = session(); + let csv = ",North,South\nP,1,1"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + let mut n = node(&s, g).clone(); + // A hidden black fill above the light South wedge's fill. + for c in n.children_mut().unwrap().iter_mut().filter(|c| c.extras().series_index == Some(1)) { + for w in std::sync::Arc::make_mut(c).children_mut().unwrap().iter_mut() { + let w = std::sync::Arc::make_mut(w); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = w.appearance.items.iter().find(|it| it.is_fill()).cloned() { + let hidden = vectorcraft_doc::FillLayer { paint: Paint::solid(Color::BLACK), visible: false, ..f }; + w.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(hidden)); + } + } + } + crate::cmd::graph::plain_labels(&mut n, &|_| {}); + let mut south = None; + n.walk(&mut |m| { + if let NodeKind::Text(t) = &m.kind + && t.plain_text() == "South" + { + south = Some(t.first_style().fill.clone()); + } + }); + assert_eq!(south, Some(Paint::solid(Color::BLACK))); +} + +/// A graph inside a group, and a graph inside a graph, get plain labels too. +#[test] +fn graphs_in_groups_pasted_without_formatting_get_plain_labels() { + let mut s = session(); + let csv = ",2024\nQ1,3\nQ2,5"; + let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap()); + let fresh = graph_label_looks(&s, g); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + let r = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + sel(&mut s, &[g, r]); + let group = id_of(&s.execute("object.group", &json!({})).unwrap()); + // A copy of the graph placed inside it, as a file may hold one. + let inner = { + let d = doc_mut(&mut s); + let mut copy = d.node(g).unwrap().clone(); + copy = d.reid(©); + let id = copy.id; + d.node_mut(g).unwrap().children_mut().unwrap().push(std::sync::Arc::new(copy)); + id + }; + copy(&mut s, &[group]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + let mut graphs = vec![]; + node(&s, p).walk(&mut |n| { + if let Some(spec) = n.extras().graph.as_deref() { + graphs.push((n.id, spec.label_styles.clone())); + } + }); + assert_eq!(graphs.len(), 2, "{inner:?}"); + for (id, l) in graphs { + assert_eq!((l.values, l.categories, l.legend, l.values_para, l.categories_para, l.legend_para), (None, None, None, None, None, None)); + let looks = graph_label_looks(&s, id); + assert!(looks.iter().all(|(_, size, _)| *size == fresh[0].1), "{looks:?}"); + } +} + +/// A graph without its generated bounds (as a file may hold it) is drawn again where Paste without Formatting put it. +#[test] +fn a_graph_without_generated_bounds_pasted_without_formatting_follows_the_offset() { + let mut s = session(); + let csv = ",2024\nQ1,3\nQ2,5"; + let g = id_of(&s.execute("graph.create", &json!({"type": "bar", "x": 50, "y": 60, "width": 100, "height": 80, "csv": csv})).unwrap()); + let plot = { + let mut extras = doc_mut(&mut s).node_mut(g).unwrap().extras_mut(); + let spec = extras.graph.as_mut().unwrap(); + spec.placed = None; + spec.rect + }; + copy(&mut s, &[g]); + let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 30, "dy": -5})).unwrap(); + let p = ids_of(&v)[0]; + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + let r = node(&s, p).extras().graph.as_deref().unwrap().rect; + let off = [r.x0 - plot.x0 - 30.0, r.y0 - plot.y0 + 5.0, r.x1 - plot.x1 - 30.0, r.y1 - plot.y1 + 5.0]; + assert!(off.iter().all(|d| d.abs() < 1e-6), "{plot:?} → {r:?}"); + + // A graph saved with the bounds of all its art but not those of its art without type, drawn with 30 pt labels: the + // labels set at 9 pt do not read as a move or scale when it is drawn again. + let g = id_of(&s.execute("graph.create", &json!({"type": "bar", "x": 300, "y": 60, "width": 100, "height": 80, "csv": csv})).unwrap()); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 30})).unwrap(); + s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap(); + let plot = { + let mut extras = doc_mut(&mut s).node_mut(g).unwrap().extras_mut(); + let spec = extras.graph.as_mut().unwrap(); + spec.placed_art = None; + spec.rect + }; + copy(&mut s, &[g]); + let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 30, "dy": -5})).unwrap(); + let p = ids_of(&v)[0]; + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + let r = node(&s, p).extras().graph.as_deref().unwrap().rect; + let off = [r.x0 - plot.x0 - 30.0, r.y0 - plot.y0 + 5.0, r.x1 - plot.x1 - 30.0, r.y1 - plot.y1 + 5.0]; + assert!(off.iter().all(|d| d.abs() < 1e-6), "without the bounds of the art without type: {plot:?} → {r:?}"); } #[test] diff --git a/crates/engine/src/tests/clippaint.rs b/crates/engine/src/tests/clippaint.rs index de637870a3..1355c11b7d 100644 --- a/crates/engine/src/tests/clippaint.rs +++ b/crates/engine/src/tests/clippaint.rs @@ -84,6 +84,63 @@ fn release_and_ungroup_keep_the_clipping_paths_paint() { } } +/// Pixels of `doc` rendered on white that are mostly blue and mostly green. +fn blue_and_green(doc: &Document) -> (usize, usize) { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let r = Renderer::new().render(doc, 200, 200, Affine::IDENTITY, &opts); + let (mut blue, mut green) = (0, 0); + for y in 0..200 { + for x in 0..200 { + let p = r.pixel(x, y); + blue += usize::from(p[2] > 200 && p[0] < 60 && p[1] < 60); + green += usize::from(p[1] > 200 && p[0] < 60 && p[2] < 60); + } + } + (blue, green) +} + +#[test] +fn painted_type_clipping_inside_a_group_with_geometry_effects_keeps_its_paint() { + // The canvas and the SVG and PDF output paint the type's fill and stroke as they did before + // the effect. Type that stays type beside the clip group takes the second Transform case + // through baking that keeps type. + let cases = + [("distort.transform", json!({"moveH": 5}), false), ("distort.transform", json!({"moveH": 5}), true), ("distort.roughen", json!({}), false)]; + for (effect, params, other_type) in cases { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + rect(&mut s, 0.0, 30.0, "#ff0000"); + let t = NodeId(s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Clip", "size": 72})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.all", &json!({})).unwrap(); + let clip = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [t.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [t.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2, "ids": [t.0]})).unwrap(); + let before = blue_and_green(&s.doc().unwrap().doc); + let mut ids = vec![clip.0]; + if other_type { + ids.push(s.execute("text.create", &json!({"x": 10, "y": 190, "text": "x", "size": 10})).unwrap()["id"].as_u64().unwrap()); + } + let group = NodeId(s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": effect, "params": params, "ids": [group.0], "item": null})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let (blue, green) = blue_and_green(&doc); + assert!( + blue * 2 > before.0 && green * 2 > before.1, + "{effect} {other_type}: the canvas paints the type's fill and stroke: {blue} {green}, {before:?} before" + ); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + let pdf = vectorcraft_pdf::export(&doc, &Default::default()).unwrap(); + for (what, out) in [("SVG", vectorcraft_svg::import(&svg).unwrap()), ("PDF", vectorcraft_pdf::import(&pdf).unwrap())] { + let (b, g) = blue_and_green(&out); + assert!( + b.abs_diff(blue) * 10 < blue && g.abs_diff(green) * 5 < green, + "{effect} {other_type}, {what}: {b} {g} against the canvas's {blue} {green}" + ); + } + } +} + #[test] fn type_as_a_clipping_path_paints_its_characters() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/constrain_new.rs b/crates/engine/src/tests/constrain_new.rs index 382e3af31c..49fd29e94b 100644 --- a/crates/engine/src/tests/constrain_new.rs +++ b/crates/engine/src/tests/constrain_new.rs @@ -40,7 +40,7 @@ fn close(a: f64, b: f64) -> bool { fn segment_angles(p: &PathData) -> Vec { p.subpaths .iter() - .flat_map(|sp| sp.anchors.windows(2).map(|w| w[1].p - w[0].p)) + .flat_map(|sp| sp.anchors.iter().zip(sp.anchors.iter().skip(1)).map(|(a, b)| b.p - a.p)) .filter(|v| v.hypot() > 1e-9) .map(|v| (-v.y).atan2(v.x).to_degrees().rem_euclid(90.0)) .collect() diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index a0129c1cd1..45ff1f84c1 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -890,7 +890,7 @@ fn curvature_rubber_band_is_the_path_the_click_makes() { s.pointer(&PointerEvent::new(PointerKind::Up, 450.0, 400.0), v).unwrap(); let made = path(&s, id).subpaths[0].clone(); assert_eq!(made.anchors.len(), 4); - let tail = vectorcraft_geom::SubPath::new(made.anchors[1..].to_vec(), false); + let tail = vectorcraft_geom::SubPath::new(made.anchors.to_vec()[1..].to_vec(), false); assert_eq!(preview, bez(&tail)); } @@ -1218,7 +1218,7 @@ fn scissors_cut_ellipse_end_drags_alone() { s.pointer(&PointerEvent::new(PointerKind::Up, cut.x + 8.0, cut.y + 8.0).with_mods(m), v).unwrap(); assert_eq!(undo_steps(&s), undo + 1); let after = path(&s, id); - let (old, new) = (&sp.anchors, &after.subpaths[0].anchors); + let (old, new) = (&sp.anchors.to_vec(), &after.subpaths[0].anchors.to_vec()); assert_eq!(new.len(), old.len()); assert_eq!(new[1..], old[1..], "only the dragged end moves (at anchor: {at_anchor}, {m:?})"); let d = Point::new(cut.x + 8.0, cut.y + 8.0) - cut; @@ -1572,7 +1572,7 @@ fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() { drag(&mut s, "smooth", &[(195.0, 194.0), (205.0, 194.0)], Mods::default()); let p = path(&s, id); assert_ne!(p, before, "the bump is smoothed"); - let a = &p.subpaths[0].anchors; + let a = &p.subpaths[0].anchors.to_vec(); let (first, second, last) = (a[0], a[1], a[a.len() - 1]); assert!(first.p == Point::new(50.0, 200.0) && !first.has_out() && !second.has_in(), "the left side is a line still: {first:?} {second:?}"); assert!(last.p == Point::new(350.0, 200.0) && !last.has_in() && !a[a.len() - 2].has_out(), "and the right one: {last:?}"); @@ -1783,7 +1783,7 @@ fn smoothing_over_a_closed_paths_first_anchor_keeps_its_start() { assert_ne!(after, before, "the bump is smoothed"); assert_starts_nearest(&after, old, 12.0); let kept = &after.subpaths[0].anchors; - for a in &before.subpaths[0].anchors[2..=10] { + for a in &before.subpaths[0].anchors.to_vec()[2..=10] { assert!(kept.iter().any(|k| k.p == a.p), "{:?} is out of reach and stays: {kept:?}", a.p); } s.execute("edit.undo", &json!({})).unwrap(); @@ -3885,7 +3885,7 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { assert!(left.is_empty()); assert!(s.doc().unwrap().selection.objects.is_empty()); assert_eq!(s.clipboard.nodes.len(), 1); - assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors[1..3]); + assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors.to_vec()[1..3]); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(paths(&s), vec![(id, original.clone())]); assert_eq!(s.doc().unwrap().selection, picked); diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index 4d86a84b3c..8a152cd44e 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -81,6 +81,29 @@ fn junk_is_rejected() { assert_eq!(s.doc().unwrap().doc.metadata, DocMetadata { created: s.doc().unwrap().doc.metadata.created, ..Default::default() }); } +/// A control character inside a value is refused with the field's rule: Description and Copyright +/// Notice take line breaks and tabs, the other text fields and each keyword in a list take none. +#[test] +fn control_characters_are_refused_with_the_field_rule() { + let mut s = session(); + for (params, msg) in [ + (json!({"description": "a\u{7}b"}), "description has control characters"), + (json!({"copyrightNotice": "a\u{0}b"}), "copyrightNotice has control characters"), + (json!({"title": "a\nb"}), "title has control characters or line breaks"), + (json!({"author": "a\rb"}), "author has control characters or line breaks"), + (json!({"authorTitle": "a\tb"}), "authorTitle has control characters or line breaks"), + (json!({"copyrightUrl": "a\u{7}b"}), "copyrightUrl has control characters or line breaks"), + (json!({"keywords": ["ok", "a\nb"]}), "keywords has control characters or line breaks"), + ] { + match s.execute("file.info", ¶ms) { + Err(EngineError::BadParams { cmd, msg: got }) => assert_eq!((cmd.as_str(), got.as_str()), ("file.info", msg), "{params}"), + other => panic!("{params}: {other:?}"), + } + } + s.execute("file.info", &json!({"description": "two\nlines\tand a tab", "copyrightNotice": "a\r\nb"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.metadata.description, "two\nlines\tand a tab"); +} + #[test] fn file_info_round_trips_and_old_files_load() { let mut s = session(); diff --git a/crates/engine/src/tests/keyanchor.rs b/crates/engine/src/tests/keyanchor.rs new file mode 100644 index 0000000000..0dc6675f2a --- /dev/null +++ b/crates/engine/src/tests/keyanchor.rs @@ -0,0 +1,162 @@ +//! #1636: the key anchor. With Direct Selection, the point a Shift-click adds to two or more +//! selected anchor points is the key anchor; Align moves the other points to it, Distribute with a +//! spacing places them from it, and it stays in place. + +use serde_json::json; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn path(s: &mut Session, d: &str) -> NodeId { + NodeId(s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap()) +} + +fn pts(s: &Session, id: NodeId) -> Vec<(f64, f64)> { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() +} + +fn key_anchor(s: &Session) -> Option<(NodeId, (usize, usize))> { + s.doc().unwrap().selection.key_anchor +} + +/// A Direct Selection click at (`x`, `y`), with Shift when `shift`. +fn click(s: &mut Session, x: f64, y: f64, shift: bool) { + let (v, mods) = (ViewInfo::default(), Mods { shift, ..Default::default() }); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap(); + } +} + +#[test] +fn a_shift_click_makes_the_key_anchor_and_a_plain_click_narrows_the_selection() { + let mut s = session(); + let id = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + click(&mut s, 100.0, 40.0, false); + assert_eq!(key_anchor(&s), None, "one point: no key anchor"); + click(&mut s, 150.0, 160.0, true); + assert_eq!(key_anchor(&s), Some((id, (0, 2))), "the point the Shift-click added"); + click(&mut s, 200.0, 60.0, true); + assert_eq!(key_anchor(&s), Some((id, (0, 1))), "the last point added"); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["keyAnchor"], json!({"id": id.0, "anchor": [0, 1]})); + // A Shift-click on the key anchor deselects it, and there is no key anchor. + click(&mut s, 200.0, 60.0, true); + assert_eq!(key_anchor(&s), None); + click(&mut s, 200.0, 60.0, true); + // A plain click on one of the selected points selects that point alone. + click(&mut s, 100.0, 40.0, false); + let sel = &s.doc().unwrap().selection; + assert_eq!(sel.anchors.get(&id).map(|a| a.iter().copied().collect::>()), Some(vec![(0, 0)])); + assert_eq!(sel.key_anchor, None); +} + +#[test] +fn align_moves_the_other_points_to_the_key_anchor() { + let mut s = session(); + let id = path(&mut s, "M100 40 L200 60 L150 160 L60 120 Z"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + assert_eq!(key_anchor(&s), None, "a set selects no key anchor"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((id, (0, 2)))); + s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); + assert_eq!(pts(&s, id), [(150.0, 40.0), (150.0, 60.0), (150.0, 160.0), (60.0, 120.0)], "to the key anchor's x"); + s.execute("object.align", &json!({"vertical": "bottom"})).unwrap(); + assert_eq!(pts(&s, id), [(150.0, 160.0), (150.0, 160.0), (150.0, 160.0), (60.0, 120.0)], "the key anchor stays"); + let steps = super::history(&s); + s.execute("object.align", &json!({"vertical": "top"})).unwrap(); + assert_eq!(super::history(&s), steps, "nothing moved: no undo step"); + // `to: "selection"` aligns to the points' bounds instead. + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 3]], "mode": "add"})).unwrap(); + s.execute("object.align", &json!({"horizontal": "left", "to": "selection"})).unwrap(); + assert_eq!(pts(&s, id).iter().map(|p| p.0).collect::>(), [60.0; 4]); +} + +#[test] +fn distribute_with_a_spacing_places_the_points_from_the_key_anchor() { + let mut s = session(); + let id = path(&mut s, "M10 10 L20 50 L100 30 L60 90"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "toggle"})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 20})).unwrap(); + assert_eq!(pts(&s, id), [(60.0, 10.0), (80.0, 50.0), (100.0, 30.0), (60.0, 90.0)], "20 apart, outward from the key anchor"); + s.execute("object.distribute", &json!({"vertical": "center", "spacing": 5})).unwrap(); + assert_eq!(pts(&s, id), [(60.0, 25.0), (80.0, 35.0), (100.0, 30.0), (60.0, 90.0)], "in their order along y, from y 30"); + // Without a spacing, Distribute spaces the points between the first and the last. + s.execute("object.distribute", &json!({"horizontal": "center"})).unwrap(); + assert_eq!(pts(&s, id)[1].0, 80.0); +} + +#[test] +fn the_key_anchor_goes_when_it_can_no_longer_be_one() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + let b = path(&mut s, "M300 300 L400 300"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((b, (0, 1))), "a point of another path"); + assert!(s.doc().unwrap().selection.has_key()); + let r = s.execute("select.key", &json!({})).unwrap(); + assert_eq!((r["key"].clone(), r.get("keyAnchor")), (json!(null), None), "select.key {{}} cancels it"); + assert_eq!(key_anchor(&s), None); + let r = s.execute("select.key", &json!({"id": b.0, "anchor": [0, 1]})).unwrap(); + assert_eq!(r["keyAnchor"], json!({"id": b.0, "anchor": [0, 1]})); + // Deleting the key anchor's path leaves no key anchor. + s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(key_anchor(&s), None); + // A key object replaces a key anchor. + let c = path(&mut s, "M500 500 L600 500"); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((a, (0, 1))), "beside a path selected whole"); + s.execute("select.key", &json!({"id": c.0})).unwrap(); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.key, sel.key_anchor), (Some(c), None)); + // Type selected whole has no points to align, so one anchor beside it is no key anchor. + let t = NodeId(s.execute("text.create", &json!({"x": 40, "y": 400, "size": 12, "text": "Key"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), None); +} + +#[test] +fn select_key_rejects_an_anchor_that_cannot_be_the_key() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + let err = |s: &mut Session, p: serde_json::Value| s.execute("select.key", &p).expect_err(&p.to_string()).to_string(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("select another anchor point"), "the only point"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 9]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), None, "a point the path does not have"); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("select another anchor point"), "the only point on the path"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 2]})).contains("is not a selected anchor point")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 9]})).contains("is not a selected anchor point")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0]})).contains("expected [subpath, anchor]")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, -1]})).contains("non-negative integer")); + assert!(err(&mut s, json!({"anchor": [0, 1]})).contains("needs `id`")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 1], "guide": 0})).contains("not both")); + // A selected segment's ends are not selected anchor points. + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 0]], "mode": "set"})).unwrap(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("is not a selected anchor point")); + assert_eq!(key_anchor(&s), None, "the key stays as it was"); +} + +#[test] +fn a_rejected_select_key_keeps_the_key_anchor() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0], [0, 9]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((a, (0, 1)))); + // [0, 9] is in the selection but not on the path. + let err = s.execute("select.key", &json!({"id": a.0, "anchor": [0, 9]})).expect_err("a point the path does not have").to_string(); + assert!(err.contains("is not a selected anchor point"), "{err}"); + assert_eq!(key_anchor(&s), Some((a, (0, 1))), "the key stays as it was"); +} diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 62955e6065..a0f001afe6 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -231,6 +231,75 @@ fn deleting_a_spot_swatch_takes_the_process_plates_of_the_stops_it_unlinks() { assert_eq!(gradient(&s, id).gradient.stops[1], linked, "undo brings back the link and the plates"); } +/// The stops of the gradients the merge test puts in place: the art's fill, gradient swatch "Glow", +/// graphic style "Style", the art of brush "Brush" and the fill new art takes. +fn merge_test_stops(s: &Session, id: NodeId) -> Vec<(&'static str, Vec)> { + let d = doc(s); + let stops = |p: Paint| match p { + Paint::Gradient(g) => g.gradient.stops, + p => panic!("not a gradient: {p:?}"), + }; + let style = d.graphic_style("Style").unwrap().appearance.items.iter().find_map(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => Some(f.paint.clone()), + vectorcraft_doc::AppearanceItem::Stroke(_) => None, + }); + let mut brush = None; + for n in vectorcraft_brush::find(d, "Brush").unwrap().art() { + n.walk(&mut |m| { + if brush.is_none() && matches!(m.appearance.fill_paint(), Paint::Gradient(_)) { + brush = Some(m.appearance.fill_paint()); + } + }); + } + vec![ + ("art", stops(d.node(id).unwrap().appearance.fill_paint())), + ("gradient swatch", stops(d.swatch("Glow").unwrap().paint.clone())), + ("graphic style", stops(style.unwrap())), + ("brush art", stops(brush.unwrap())), + ("new art", stops(s.paint.fill.clone())), + ] +} + +#[test] +fn merging_swatches_takes_the_process_plates_of_the_stops_it_relinks() { + // Kept swatch "Keep": spot, global process, and process that isn't global. + for (spot, global) in [(true, true), (false, true), (false, false)] { + let (mut s, id) = setup(); + run(&mut s, "swatch.new", json!({"name": "Keep", "color": "#00ff00", "spot": spot, "global": global})); + run(&mut s, "swatch.new", json!({"name": "Other", "color": {"c": 1, "m": 0, "y": 0, "k": 0}, "spot": true})); + // Ink at 50 % over a 30 % cyan plate, and Other over its own 20 % magenta plate, which the + // merge leaves alone. + let plated = json!({"stops": [ + {"offset": 0, "swatch": "Ink", "tint": 50, "plates": [0.3, 0, 0, 0]}, + {"offset": 1, "swatch": "Other", "plates": [0, 0.2, 0, 0]}, + ]}); + run(&mut s, "paint.editGradient", plated.clone()); + run(&mut s, "swatch.new", json!({"name": "Glow", "gradient": plated})); + run(&mut s, "graphicStyle.new", json!({"name": "Style"})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Brush"})); + s.paint.fill = doc(&s).node(id).unwrap().appearance.fill_paint(); + let before = merge_test_stops(&s, id); + let kept = doc(&s).linked_color(doc(&s).swatch("Keep").unwrap().paint.color().unwrap(), spot); + assert!(before.iter().all(|(_, st)| st[0].plates == Some([0.3, 0.0, 0.0, 0.0])), "{before:?}"); + + run(&mut s, "swatch.merge", json!({"names": ["Keep", "Ink"]})); + let after = merge_test_stops(&s, id); + // The stop shows its tint of the kept color, unlinked when the kept swatch isn't global. + let (link, tint, color) = if global { (Some("Keep"), 0.5, kept.tinted(0.5)) } else { (None, 1.0, kept.tinted(0.5)) }; + for (place, st) in &after { + let what = format!("spot {spot}, global {global}, {place}: {st:?}"); + assert_eq!((st[0].swatch.as_deref(), st[0].tint, st[0].plates), (link, tint, None), "{what}"); + assert_eq!(st[0].color, color, "{what}"); + assert_eq!((st[1].swatch.as_deref(), st[1].plates), (Some("Other"), Some([0.0, 0.2, 0.0, 0.0])), "{what}"); + } + + run(&mut s, "edit.undo", json!({})); + assert_eq!(merge_test_stops(&s, id), before, "undo brings back the links and the plates"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(merge_test_stops(&s, id), after, "redo merges again"); + } +} + #[test] fn radial_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() { let (mut s, _) = setup(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 4afab29a91..1db3ffa34e 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3299,6 +3299,32 @@ fn select_all_on_artboard_refuses_an_artboard_past_u32() { assert!(selected(&s).is_empty()); } +/// All on Active Artboard refuses an `artboard` that is not a whole number, naming the parameter, +/// and leaves the selection as it was; a whole number past the last artboard is `no such +/// artboard`. Left out or null, it takes the first artboard. +#[test] +fn select_all_on_artboard_refuses_an_artboard_that_is_not_a_whole_number() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2, "artboardLayout": {"layout": "row", "spacing": 0}})).unwrap(); + let (first, second) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 110.0, 10.0, 20.0, 20.0)); + for bad in [json!("1"), json!(-1), json!(1.5), json!(true), json!([1]), json!({})] { + s.execute("select.none", &json!({})).unwrap(); + let err = s.execute("select.allOnArtboard", &json!({"artboard": bad})).unwrap_err().to_string(); + assert_eq!(err, "invalid parameters for `select.allOnArtboard`: `artboard` must be a whole number", "{bad}"); + assert!(selected(&s).is_empty(), "{bad}"); + } + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute("select.allOnArtboard", &json!({"artboard": 9})).unwrap_err().to_string(), "no such artboard"); + assert!(selected(&s).is_empty()); + for p in [json!({}), json!({"artboard": null}), json!({"artboard": 0})] { + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.allOnArtboard", &p).unwrap(); + assert_eq!(selected(&s), vec![first], "{p}"); + } + s.execute("select.allOnArtboard", &json!({"artboard": 1})).unwrap(); + assert_eq!(selected(&s), vec![second]); +} + #[test] fn save_and_recall_selection() { let mut s = session(); @@ -4605,6 +4631,27 @@ fn artboard_guides_go_with_their_artboard() { assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1])); } +/// `guide.add` refuses an `artboard` that is not a whole number, naming the parameter, and adds no +/// guide; a whole number past the last artboard is `no such artboard`. Null makes a canvas guide, +/// and a whole number an artboard guide. +#[test] +fn guide_add_refuses_an_artboard_that_is_not_a_whole_number() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 400, "height": 300})).unwrap(); + let add = |s: &mut Session, artboard: Value| s.execute("guide.add", &json!({"vertical": true, "pos": 10, "artboard": artboard})); + for bad in [json!("1"), json!(-1), json!(1.5), json!(true), json!([1]), json!({})] { + let err = add(&mut s, bad.clone()).unwrap_err().to_string(); + assert_eq!(err, "invalid parameters for `guide.add`: `artboard` must be a whole number", "{bad}"); + } + assert_eq!(add(&mut s, json!(9)).unwrap_err().to_string(), "no such artboard"); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); + add(&mut s, Value::Null).unwrap(); + add(&mut s, json!(1)).unwrap(); + let d = &s.doc().unwrap().doc; + let boards: Vec<_> = d.ruler_guides().iter().map(|g| g.artboard).collect(); + assert_eq!(boards, [None, Some(d.artboards[1].id)]); +} + /// #451: a guide dragged out of a ruler snaps (to the art's corners, side midpoints and anchors, /// the artboards' edges and centres) and is made in one undo step where it is released over the /// canvas; with the Artboard tool it is an artboard guide of the active artboard. diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 3788185ae6..494c070a66 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -775,6 +775,44 @@ fn create_outlines_draws_the_type_objects_own_paint() { assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something"); } +/// Create Outlines draws what the type drew with effects of its own: a drop shadow of the whole +/// type and a Transform effect that scales and turns it, over fills and strokes of the object's +/// own around its Characters row. +#[test] +fn create_outlines_draws_the_type_objects_own_effects() { + let mut s = session(); + type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1); + let plain = render(&s); + let shadow = json!({"x": 6, "y": 6, "blur": 0, "opacity": 100, "mode": "normal", "color": "#00ff00"}); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": shadow, "item": null})); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 80, "scaleV": 80, "rotate": 10}, "item": null})); + let before = render(&s); + assert!(differing(&plain, &before, 24) > 500, "the effects change what the type draws"); + run(&mut s, "type.createOutlines", json!({})); + let after = render(&s); + let n = differing(&before, &after, 24); + assert!(n < 40, "{n} pixels differ"); +} + +/// A Warp effect of the type object's own bends one gradient across the whole type on the canvas, +/// and the group Create Outlines makes keeps it across the whole outline rather than one per +/// glyph, so the outlines draw what the type drew. +#[test] +fn create_outlines_keeps_a_warped_character_gradient_across_the_type() { + let mut s = session(); + let t = run(&mut s, "text.create", json!({"x": 20, "y": 110, "text": "HIH", "size": 60}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids": [t]})); + let down = json!({"kind": "linear", "angle": 90, "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + run(&mut s, "paint.setFill", json!({"gradient": down})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"bend": 50}, "item": null})); + let before = render(&s); + run(&mut s, "type.createOutlines", json!({})); + let after = render(&s); + let n = differing(&before, &after, 24); + assert!(n < 40, "{n} pixels differ"); +} + /// Selected type "HIH" at 40 pt with ruby, underlined and struck through, its characters filled /// with `fill` (a `paint.setFill` param) and stroked 2 pt wide, then scaled to 150% and rotated 20 /// degrees (the stroke keeps its 2 pt, which is 4/3 pt in text space). diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 715bf2b8ce..7d2675e938 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -1722,6 +1722,35 @@ fn text_set_format() { assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine); } +/// `text.setFormat {composer}` takes the two Middle Eastern & South Asian and the two Standard +/// composers, each per paragraph and one undo step; any other value is refused and names them all. +#[test] +fn text_set_format_takes_the_four_composers() { + use vectorcraft_doc::Composer; + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "One\nTwo"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let composers = |s: &Session| { + let NodeKind::Text(t) = node(s, id).kind else { panic!() }; + [t.para_at(0).composer, t.para_at(1).composer] + }; + assert_eq!(composers(&s), [Composer::EveryLine; 2], "new type is ME&SA Every-line"); + for (value, want) in [ + ("singleLine", Composer::SingleLine), + ("standardSingleLine", Composer::StandardSingleLine), + ("standardEveryLine", Composer::StandardEveryLine), + ("everyLine", Composer::EveryLine), + ] { + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": value, "start": 4, "end": 4})).unwrap(); + assert_eq!(composers(&s), [Composer::EveryLine, want], "{value}: only the second paragraph"); + } + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "standardEveryLine"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(composers(&s), [Composer::EveryLine; 2], "one undo step"); + let e = s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "standard"})).unwrap_err().to_string(); + assert!(e.contains("standardSingleLine") && e.contains("standardEveryLine"), "{e}"); +} + #[test] fn stroke_advanced_scale_swap_flip() { let mut s = session(); diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 52cb314de0..708b602d1f 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -704,10 +704,11 @@ fn effect_params_are_the_effects_own() { 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 + // Read by checks of their own: a stored texture or a texture to load, an SVG filter and its axes, a Warp's // map and the style of Crop Marks. let own_check = |effect: &str, key: &str| match key { "textureImage" | "name" | "xml" => true, + "axes" => effect == "svg.filter", "version" => effect.starts_with("warp."), "style" => effect == "cropMarks", _ => false, diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs index 0564589d2d..e7da630b81 100644 --- a/crates/engine/src/tests/pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -1088,6 +1088,62 @@ fn create_outlines_keeps_the_objects_own_fills_and_strokes() { assert_eq!(node(&s, t).appearance.items, own, "one undo step brings the type back"); } +/// The type object's own effects go on the group once, in their order, with their settings and a +/// hidden one still hidden; a fill's own effect stays on its path of the whole outline, and no +/// glyph or whole-outline path takes the object's effects. Undo brings the type back with them. +#[test] +fn create_outlines_keeps_the_objects_own_effects_on_the_group() { + let mut s = session(); + let t = text(&mut s, "Hi", 64.0); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + for (cmd, p) in [ + ("appearance.addFill", json!({"target": "object"})), + ("appearance.setItem", json!({"index": 0, "color": "#ff0000"})), + ("effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 2}, "item": 0})), + ("effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 7, "y": 3, "blur": 0, "opacity": 60}, "item": null})), + ("effect.apply", json!({"effect": "distort.transform", "params": {"rotate": 15}, "item": null})), + ("effect.setParams", json!({"index": 1, "visible": false, "item": null})), + ] { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + } + let own: Appearance = (*node(&s, t).appearance).clone(); + let ids_of = |fx: &[vectorcraft_doc::Effect]| fx.iter().map(|e| (e.id.clone(), e.visible)).collect::>(); + assert_eq!(ids_of(&own.effects), [("stylize.dropShadow".to_string(), true), ("distort.transform".to_string(), false)]); + assert_eq!(ids_of(own.items[0].effects()), [("path.offsetPath".to_string(), true)]); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let g = node(&s, r[0]); + assert_eq!(g.appearance.effects, own.effects, "the group holds the object's effects as they were"); + let ch = g.children().unwrap(); + assert!(ch.iter().all(|c| c.appearance.effects.is_empty()), "no piece takes the object's effects"); + let rows: Vec<_> = ch.iter().filter(|c| c.appearance.items == own.items).collect(); + assert_eq!(rows.len(), 1, "one path of the whole outline paints the object's fill"); + assert_eq!(rows[0].appearance.items[0].effects(), own.items[0].effects(), "the fill keeps its own effect"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*node(&s, t).appearance, own, "one undo step brings the type back with its effects"); +} + +/// The group takes the type object's opacity, blend mode, Isolate Blending, Opacity & Mask Define +/// Knockout Shape and opacity mask. +#[test] +fn create_outlines_keeps_the_objects_transparency_and_opacity_mask() { + let mut s = session(); + let t = text(&mut s, "Hi", 64.0); + let m = rect(&mut s, 0.0, 0.0, 40.0, 80.0); + s.execute("select.set", &json!({"ids": [t.0, m.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let p = json!({"ids": [t.0], "opacity": 70, "blend": "multiply", "isolate": true, "knockoutShape": true}); + s.execute("transparency.set", &p).unwrap(); + let before = node(&s, t); + assert!(before.extras().mask.is_some(), "the type has a mask"); + let look = |n: &vectorcraft_doc::Node| (n.opacity, n.blend, n.isolate, n.knockout_shape); + assert_eq!(look(&before), (0.7, vectorcraft_color::BlendMode::Multiply, true, true)); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let g = node(&s, r[0]); + assert_eq!(look(&g), look(&before)); + assert_eq!(g.extras().mask, before.extras().mask, "the group keeps the type's opacity mask"); +} + /// Colour bitmap glyphs (emoji) become embedded images where they sat; glyphs showing one image /// share its pixels. #[test] diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 662e301caa..64926b7cfb 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1023,6 +1023,78 @@ fn saved_for_older_apps_with_a_precision_the_placed_art_is_rounded_too() { assert_ne!(preview, crate::cmd::fileio::preview_png(&s.doc().unwrap().doc).unwrap()); } +/// Saved for an older version, a placed document held only in stored brushes' art (a scatter +/// brush's art and a pattern brush's tile) is saved as the art it shows, with the resources that art +/// uses, and a precision save's preview shows that art as the file holds it, rounded. The current +/// version keeps it placed. +#[test] +fn saved_for_older_apps_placed_documents_in_brush_art_become_their_art() { + let dir = Folder::new("placed-brush-older"); + let src = dir.file("badge.vectorcraft"); + // A symbol `Star` and a triangle off the whole-point grid. + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let star = Node::path(vectorcraft_doc::NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look(BLUE)); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Star".into(), art: std::sync::Arc::new(star) }); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Star".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let points = [(3.4876474632797176, 10.5), (96.5, 2.4876), (40.4876, 97.5)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::path::SubPath::polyline(&points, true)); + let id = d.alloc_id(); + d.insert(Some(layer), 1, Node::path(id, path, look(RED))).unwrap(); + write(&src, &vectorcraft_format::save_file(&d)); + let mut s = session(); + let id = place(&mut s, &src); + let art = s.doc().unwrap().doc.node(id).unwrap().clone(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let scatter = vectorcraft_brush::Scatter { art: art.clone(), ..Default::default() }; + let pattern = vectorcraft_brush::PatternBrush { side: art, ..Default::default() }; + vectorcraft_brush::store( + d, + &[ + vectorcraft_brush::Brush { name: "Badge Dots".into(), kind: vectorcraft_brush::BrushKind::Scatter(scatter) }, + vectorcraft_brush::Brush { name: "Badge Band".into(), kind: vectorcraft_brush::BrushKind::Pattern(pattern) }, + ], + ); + assert!(!d.has_placed(), "only the brushes hold it"); + } + let line = s.execute("shape.line", &json!({"x1": 20, "y1": 150, "x2": 380, "y2": 150})).unwrap()["id"].clone(); + s.execute("brush.apply", &json!({"name": "Badge Dots", "ids": [line]})).unwrap(); + // The placed documents in each brush's art. + let placed_in_brushes = |d: &Document| { + let mut n = 0; + for b in vectorcraft_brush::library(d) { + for art in b.art() { + art.walk(&mut |c| n += usize::from(matches!(c.kind, NodeKind::PlacedDocument(_)))); + } + } + n + }; + let prefix = vectorcraft_doc::placed_document::RESOURCE_PREFIX; + let older = vectorcraft_format::VERSION - 1; + let old = + vectorcraft_format::load(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "version": older})).unwrap())).unwrap(); + assert_eq!(placed_in_brushes(&old), 0, "{}", old.unknown[vectorcraft_brush::DOC_KEY]); + assert_eq!(vectorcraft_brush::library(&old).len(), 2); + for b in vectorcraft_brush::library(&old) { + assert!(matches!(b.art()[0].kind, NodeKind::Group { .. }), "{}: {}", b.name, b.art()[0].kind_label()); + } + assert!(old.symbols.iter().any(|s| s.name.starts_with(prefix)), "the art's symbol is saved with it"); + let current = vectorcraft_format::load(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap())).unwrap(); + assert_eq!(placed_in_brushes(¤t), 2, "the current version keeps them placed"); + assert!(current.symbols.iter().all(|s| !s.name.starts_with(prefix))); + // With a precision, the preview shows the brushes' art as the file holds it, rounded. + let file = decode(&s.execute("file.saveCopy", &json!({"version": older, "precision": 0, "preview": true})).unwrap()); + let saved = vectorcraft_format::load(&file).unwrap(); + assert_eq!(placed_in_brushes(&saved), 0); + let preview = vectorcraft_format::preview(&file); + assert!(preview.is_some()); + assert_eq!(preview, crate::cmd::fileio::preview_png(&saved).unwrap()); + assert_ne!(preview, crate::cmd::fileio::preview_png(&s.doc().unwrap().doc).unwrap()); +} + #[test] fn break_link_gives_the_editable_copy_placing_without_link_gives() { let dir = Folder::new("placed-break"); diff --git a/crates/engine/src/tests/rastersettings.rs b/crates/engine/src/tests/rastersettings.rs index 75659faf32..320ecb6317 100644 --- a/crates/engine/src/tests/rastersettings.rs +++ b/crates/engine/src/tests/rastersettings.rs @@ -292,6 +292,58 @@ fn rasterize_measures_the_art_its_geometry_effects_draw() { assert_eq!((xf.translation().x, width), (150.0, 41)); } +/// A Transform effect after Rasterize moves the image the Rasterize effect makes: Expand Appearance +/// and PDF export make the image of the art before the Transform effect, as Object › Rasterize +/// makes it, and place it through the effect's map, as the canvas draws it. +#[test] +fn expand_rasterize_then_transform_matches_canvas() { + let mut o = session(); + red_rectangle(&mut o); + let made = id(&o.execute("object.rasterize", &json!({"ppi": 72, "background": "white", "addAround": 2})).unwrap()); + let od = &o.doc().unwrap().doc; + let (w, h, xf, png, _) = rasterized(od, od.node(made).unwrap()); + // About the center of the rectangle's path. + let about = + |m: vectorcraft_geom::Affine| vectorcraft_geom::Affine::translate((70.5, 65.25)) * m * vectorcraft_geom::Affine::translate((-70.5, -65.25)); + let cases = [ + (json!({"scaleH": 200, "scaleV": 200}), about(vectorcraft_geom::Affine::scale(2.0))), + (json!({"rotate": 30}), about(vectorcraft_geom::Affine::rotate(-30f64.to_radians()))), + ( + json!({"rotate": 30, "moveH": 10, "strokes": true}), + vectorcraft_geom::Affine::translate((10.0, 0.0)) * about(vectorcraft_geom::Affine::rotate(-30f64.to_radians())), + ), + ]; + for (params, map) in cases { + let mut s = session(); + let r = red_rectangle(&mut s); + let raster = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 2}); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": raster, "ids": [r.0]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": params.clone(), "ids": [r.0]})).unwrap(); + let check = |d: &Document, n: &vectorcraft_doc::Node, what: &str| { + let (w2, h2, xf2, png2, _) = rasterized(d, n); + assert_eq!((w2, h2), (w, h), "{what} {params}"); + assert!(png2 == png, "{what} {params}: the same pixels"); + let want = map * xf; + assert!(xf2.as_coeffs().iter().zip(want.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{what} {params}: {xf2:?} {want:?}"); + }; + let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap(); + check(&flat, &flat.layers[0].children().unwrap()[0], "PDF export"); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + check(d, d.node(r).unwrap(), "Expand Appearance"); + } + // A Transform effect that scales the image to nothing leaves nothing in PDF export, as the + // canvas draws nothing. + let mut s = session(); + let r = red_rectangle(&mut s); + let raster = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 2}); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": raster, "ids": [r.0]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"scaleH": 0}, "ids": [r.0]})).unwrap(); + let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap(); + let n = &flat.layers[0].children().unwrap()[0]; + assert!(matches!(&n.kind, vectorcraft_doc::NodeKind::Group { children, .. } if children.is_empty()), "{:?}", n.kind); +} + #[test] fn the_clipping_mask_follows_the_art_its_geometry_effects_draw() { let clip = |s: &Session, n: NodeId| match &s.doc().unwrap().doc.node(n).unwrap().kind { diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 90e7822ef8..390248f2a9 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -303,6 +303,62 @@ fn methods_and_bad_params() { assert!(s.execute("recolor.apply", &json!({"map": {}})).is_err(), "needs art or a group"); } +/// A row with no current colors (Recolor Artwork's New Row) recolors nothing, and records no +/// undo step on its own; beside it the other rows recolor as before, and the weights and +/// randomize keep it. A row without `from`, or with a bad current color, is still refused. +#[test] +fn an_empty_row_recolors_nothing() { + let mut s = session(); + let red = rect(&mut s, json!("#ff0000")); + let blue = rect(&mut s, json!("#0000ff")); + select_all(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + let empty = json!({"from": [], "to": "rgb 0 255 0"}); + let r = run(&mut s, "recolor.apply", json!({"map": [empty]})); + assert_eq!(r["changed"], json!(0)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + let map = json!([empty, {"from": ["rgb 0 0 255"], "to": "rgb 255 255 0"}]); + let w = run(&mut s, "recolor.weigh", json!({"map": map})); + assert_eq!(w["map"], map, "the rows as given"); + assert_eq!(w["shares"], json!([0.0, 100.0])); + let shuffled = run(&mut s, "recolor.randomize", json!({"map": map, "seed": 1})); + assert_eq!(shuffled["map"][0]["from"], json!([])); + run(&mut s, "recolor.apply", json!({"map": map})); + assert_eq!((fill(&s, red).to_hex(), fill(&s, blue).to_hex()), ("#ff0000".into(), "#ffff00".into())); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + for bad in [json!([{"to": "rgb 0 255 0"}]), json!([{"from": ["nonsense"], "to": "rgb 0 255 0"}])] { + assert!(s.execute("recolor.apply", &json!({"map": bad})).is_err(), "{bad}"); + } +} + +/// An empty row adds no library swatch with `limitTo`, and gives a rewritten group no color: the +/// group keeps one color per row that holds current colors. +#[test] +fn an_empty_row_adds_no_swatch_and_no_group_color() { + let mut s = session(); + rect(&mut s, json!("#ff0000")); + rect(&mut s, json!("#0000ff")); + select_all(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + let swatches = s.doc().unwrap().doc.swatches.len(); + let r = run(&mut s, "recolor.apply", json!({"map": [{"from": [], "to": "#ff0033"}], "limitTo": "web-safe-216"})); + assert_eq!(r["swatches"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc.swatches.len(), swatches); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + run(&mut s, "swatch.newGroup", json!({"name": "Pair", "colors": ["#ff0000", "#0000ff"]})); + select_all(&mut s); + let map = json!([ + {"from": [], "to": "rgb 0 255 0"}, + {"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"}, + {"from": ["rgb 0 0 255"], "to": "rgb 0 0 255"}, + ]); + run(&mut s, "recolor.apply", json!({"map": map, "group": "Pair"})); + let d = &s.doc().unwrap().doc; + let g = d.swatch_groups.iter().find(|g| g.name == "Pair").unwrap(); + let hexes: Vec = g.swatches.iter().map(|w| w.paint.color().unwrap().to_hex()).collect(); + assert_eq!(hexes, ["#ff0000", "#0000ff"]); +} + #[test] fn randomize_shuffles_new_colours_and_skips_excluded_rows() { let mut s = session(); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index a51f73b222..eed8801efc 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1532,6 +1532,47 @@ fn images_sharing_an_entry_are_written_once() { assert_eq!(store.images_written().len(), 1, "restored into this session's area once"); } +/// A linked image shown only in a stored brush's art is kept as its preview, as a save writes it; +/// an image key that a brush's art embeds keeps its full bytes though the layers show it linked. +#[test] +fn recovery_brush_links_keep_proxy_and_embedded_full_bytes() { + let (full, proxy) = (vec![1u8; 40], vec![2u8; 10]); + for brush_linked in [true, false] { + let store = Arc::new(MemoryStore::default()); + let mut s = session_in(&store); + { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let blob = + vectorcraft_doc::ImageBlob { proxy: Some(Arc::new(proxy.clone())), ..vectorcraft_doc::ImageBlob::new("image/jpeg", full.clone()) }; + d.images.insert("k".into(), blob); + let image = |link: Option| { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::IDENTITY, + link, + placement: Default::default(), + pixels: None, + }; + vectorcraft_doc::Node::new(NodeId(0), vectorcraft_doc::NodeKind::Image(Box::new(im))) + }; + let link = Some(vectorcraft_doc::LinkInfo::new("/work/k.jpg")); + if !brush_linked { + let (mut n, layer) = (image(link.clone()), d.layers[0].id); + n.id = d.alloc_id(); + d.insert(Some(layer), 0, n).unwrap(); + } + let art = image(if brush_linked { link } else { None }); + let scatter = vectorcraft_brush::Scatter { art, ..Default::default() }; + vectorcraft_brush::store(d, &[vectorcraft_brush::Brush { name: "Dots".into(), kind: vectorcraft_brush::BrushKind::Scatter(scatter) }]); + } + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let want = if brush_linked { proxy.len() } else { full.len() }; + assert_eq!(r["saved"][0]["imageBytes"], want, "linked in the brush: {brush_linked}: {r}"); + } +} + #[test] fn copies_from_before_images_were_kept_apart_still_restore() { let store = Arc::new(MemoryStore::default()); diff --git a/crates/engine/src/tests/simplify_part.rs b/crates/engine/src/tests/simplify_part.rs index 12f3330595..f3ab93f274 100644 --- a/crates/engine/src/tests/simplify_part.rs +++ b/crates/engine/src/tests/simplify_part.rs @@ -73,8 +73,8 @@ fn simplify_refits_only_the_run_between_direct_selected_anchors() { assert_eq!(out["after"].as_u64(), Some(len as u64)); assert!(len < 101 - 30, "{len}"); let end = len - 41; - assert_eq!(sp.anchors[..20], original.anchors[..20], "the anchors before the run"); - assert_eq!(sp.anchors[end + 1..], original.anchors[61..], "the anchors after the run"); + assert_eq!(sp.anchors.to_vec()[..20], original.anchors.to_vec()[..20], "the anchors before the run"); + assert_eq!(sp.anchors.to_vec()[end + 1..], original.anchors.to_vec()[61..], "the anchors after the run"); assert_eq!((sp.anchors[20].p, sp.anchors[20].h_in), (original.anchors[20].p, original.anchors[20].h_in)); assert_eq!((sp.anchors[end].p, sp.anchors[end].h_out), (original.anchors[60].p, original.anchors[60].h_out)); for i in 0..20 { @@ -100,7 +100,7 @@ fn separate_runs_on_one_path_simplify_independently_in_one_step() { let id = wave(&mut s); select_anchors(&mut s, id, ranges); s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); - path_of(&s, id).subpaths[0].anchors.clone() + path_of(&s, id).subpaths[0].anchors.to_vec() }; let (first, second) = (alone(&[(10, 30)]), alone(&[(60, 80)])); let mut s = session(); @@ -110,13 +110,13 @@ fn separate_runs_on_one_path_simplify_independently_in_one_step() { let undo = undo_len(&s); s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); assert_eq!(undo_len(&s), undo + 1, "one undo step"); - let both = path_of(&s, id).subpaths[0].anchors.clone(); + let both = path_of(&s, id).subpaths[0].anchors.to_vec(); // Where old anchor 30 is in the first result, and old anchor 80 in the second. let (a30, a80) = (first.len() - 71, second.len() - 21); assert!(a30 < 30 && a80 < 80, "both runs lose anchors"); let mid = a30 + 30; assert_eq!(both[..=a30], first[..=a30]); - assert_eq!(both[a30 + 1..mid], original.anchors[31..60], "the anchors between the runs"); + assert_eq!(both[a30 + 1..mid], original.anchors.to_vec()[31..60], "the anchors between the runs"); assert_eq!(both[mid..], second[60..]); let sel: BTreeSet<_> = (10..=a30).chain(mid..=mid + a80 - 60).map(|i| (0, i)).collect(); assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&sel)); @@ -138,7 +138,7 @@ fn a_run_across_the_start_of_a_closed_path_simplifies_as_one() { assert!(sp.closed); assert!(sp.anchors.len() < 80 - 10, "{}", sp.anchors.len()); assert!(!sp.anchors.iter().any(|a| a.p == original.anchors[0].p), "anchor 0 was inside the run"); - assert_eq!(sp.anchors[1..60], original.anchors[11..70]); + assert_eq!(sp.anchors.to_vec()[1..60], original.anchors.to_vec()[11..70]); assert_eq!((sp.anchors[0].p, sp.anchors[0].h_out), (original.anchors[10].p, original.anchors[10].h_out)); assert_eq!((sp.anchors[60].p, sp.anchors[60].h_in), (original.anchors[70].p, original.anchors[70].h_in)); for i in 0..60 { @@ -162,8 +162,8 @@ fn picked_segments_simplify_and_stay_picked() { s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); let sp = path_of(&s, id).subpaths[0].clone(); let end = sp.anchors.len() - 41; - assert_eq!(sp.anchors[..20], original.anchors[..20]); - assert_eq!(sp.anchors[end + 1..], original.anchors[61..]); + assert_eq!(sp.anchors.to_vec()[..20], original.anchors.to_vec()[..20]); + assert_eq!(sp.anchors.to_vec()[end + 1..], original.anchors.to_vec()[61..]); let sel = &s.doc().unwrap().selection; assert_eq!(sel.segments.get(&id), Some(&(20..end).map(|i| (0, i)).collect())); assert_eq!(sel.anchors.get(&id), Some(&(20..=end).map(|i| (0, i)).collect())); @@ -200,8 +200,8 @@ fn slider_preview_and_auto_simplify_only_the_run() { let previewed = path_of(&s, id); let sp = &previewed.subpaths[0]; assert!(sp.anchors.len() < 101, "{p}"); - assert_eq!(sp.anchors[..20], original.anchors[..20], "{p}"); - assert_eq!(sp.anchors[sp.anchors.len() - 40..], original.anchors[61..], "{p}"); + assert_eq!(sp.anchors.to_vec()[..20], original.anchors.to_vec()[..20], "{p}"); + assert_eq!(sp.anchors.to_vec()[sp.anchors.len() - 40..], original.anchors.to_vec()[61..], "{p}"); s.execute("edit.undo", &json!({})).unwrap(); s.execute("object.path.simplify", &p).unwrap(); assert_eq!(path_of(&s, id), previewed, "{p}"); diff --git a/crates/engine/src/tests/smooth_path.rs b/crates/engine/src/tests/smooth_path.rs index 9ce6a21044..1ca7a6d5e0 100644 --- a/crates/engine/src/tests/smooth_path.rs +++ b/crates/engine/src/tests/smooth_path.rs @@ -93,7 +93,7 @@ fn more_smoothing_removes_the_jags_and_keeps_the_shape() { // Within the tolerance (2.5 % of 300 pt at most) plus the jag the refit smooths away. assert!(farthest(&before, &after) <= 7.5 + 1.5, "level {level}: {}", farthest(&before, &after)); // The end points of an open path stay put. - let (a, b) = (&before.subpaths[0].anchors, &after.subpaths[0].anchors); + let (a, b) = (&before.subpaths[0].anchors.to_vec(), &after.subpaths[0].anchors.to_vec()); assert_eq!((a[0].p, a[a.len() - 1].p), (b[0].p, b[b.len() - 1].p)); assert!(!after.subpaths[0].closed); s.execute("edit.undo", &json!({})).unwrap(); @@ -206,7 +206,7 @@ fn direct_selected_anchors_smooth_only_their_section() { s.execute("select.anchors", &json!({"id": id.0, "anchors": anchors})).unwrap(); s.execute("object.path.smooth", &json!({"level": 100})).unwrap(); let after = path_of(&s, id); - let (a, b) = (&before.subpaths[0].anchors, &after.subpaths[0].anchors); + let (a, b) = (&before.subpaths[0].anchors.to_vec(), &after.subpaths[0].anchors.to_vec()); assert!(b.len() < a.len(), "{} → {}", a.len(), b.len()); // Anchors 0..=10 and 30..=40 are untouched, the section between them refitted. assert_eq!(a[..=10].iter().map(|x| x.p).collect::>(), b[..=10].iter().map(|x| x.p).collect::>()); diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index fd8f2c7e24..e8623900fd 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -45,6 +45,7 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "textures" && l["name"] == "Textures" && l["count"] == 8), "{libs:?}"); + assert!(libs.iter().any(|l| l["id"] == "neon-glow" && l["name"] == "Neon & Glow" && l["count"] == 6), "{libs:?}"); for l in libs { let id = l["id"].as_str().unwrap(); let g = run(&mut s, "graphicStyle.library", json!({"library": id})); @@ -69,6 +70,9 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() let texture_names: Vec<&str> = textures["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); let expected = ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"]; assert_eq!(texture_names, expected); + let neon = run(&mut s, "graphicStyle.library", json!({"library": "neon-glow"})); + let neon_names: Vec<&str> = neon["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(neon_names, ["Cyan Neon", "Magenta Laser", "Amber Tube", "Electric Lime", "Pulse Violet", "Cobalt Phosphor"]); let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]})); assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow"))); @@ -419,3 +423,53 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() { } } } + +#[test] +fn neon_and_glow_library_adds_and_applies_multi_stroke_styles() { + let mut s = session(); + let list = run(&mut s, "graphicStyle.libraries", json!({})); + let libs = list["libraries"].as_array().unwrap(); + let at = libs.iter().position(|l| l["id"] == "neon-glow").expect("neon-glow listed"); + assert_eq!(libs[at]["name"], "Neon & Glow"); + assert_eq!(libs[at]["count"], 6); + + let (_, lib) = cmd::stylelib::library(&s, "neon-glow").unwrap(); + assert_eq!(lib.len(), 6); + + let want = [ + ("Cyan Neon", 2, "stylize.dropShadow"), + ("Magenta Laser", 2, "stylize.outerGlow"), + ("Amber Tube", 2, "stylize.outerGlow"), + ("Electric Lime", 2, "stylize.outerGlow"), + ("Pulse Violet", 2, "stylize.innerGlow"), + ("Cobalt Phosphor", 2, "blur.gaussian"), + ]; + + for (i, (name, stroke_count, first_effect)) in want.iter().enumerate() { + let id = rect(&mut s, 10.0 + 30.0 * i as f64, 15.0, 25.0, 25.0); + let before_undo = undo_len(&s); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "neon-glow", "name": name, "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![*name], Some(*name))); + assert_eq!(undo_len(&s), before_undo + 1, "{name}"); + + let node = doc(&s).node(id).unwrap(); + let strokes = node.appearance.items.iter().filter(|it| matches!(it, vectorcraft_doc::AppearanceItem::Stroke(_))).count(); + assert_eq!(strokes, *stroke_count, "{name}"); + assert_eq!(node.appearance.effects[0].id, *first_effect, "{name}"); + } + + // Adding existing style dedupes and creates no new undo step. + let before = (undo_len(&s), doc(&s).graphic_styles.len()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "neon-glow", "name": "Cyan Neon"})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Cyan Neon"])); + assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before); + + // Round-trip through serialize / open + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "neon.vectorcraft", "dataBase64": b64})); + for (name, _, _) in want { + let d = doc(&s); + let st = d.graphic_style(name).unwrap(); + assert!(st.same_look(lib.style(name).unwrap()), "{name}"); + } +} diff --git a/crates/engine/src/tests/styles.rs b/crates/engine/src/tests/styles.rs index 6f77b58971..e460aeb890 100644 --- a/crates/engine/src/tests/styles.rs +++ b/crates/engine/src/tests/styles.rs @@ -325,6 +325,26 @@ fn select_same_appearance_attribute() { run(&mut s, "select.set", json!({"ids": [a.0]})); run(&mut s, "select.same.appearanceAttribute", json!({"item": (1u64 << 32) + 1})); assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + // Null is the default: the shared effect. + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "select.same.appearanceAttribute", json!({"item": null})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); +} + +/// Select > Same > Appearance Attribute refuses an `item` that is not a whole number and leaves +/// the selection as it was. +#[test] +fn select_same_appearance_attribute_refuses_an_item_that_is_not_a_whole_number() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + rect(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [a.0]})); + for item in [json!("1"), json!(-1), json!(1.5), json!(true), json!([1])] { + let e = s.execute("select.same.appearanceAttribute", &json!({"item": item})).unwrap_err().to_string(); + assert_eq!(e, "invalid parameters for `select.same.appearanceAttribute`: `item` must be a whole number", "{item}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{item}"); + } } /// With a member path of a compound path selected first, Select > Same > Graphic Style and diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index e42d7f9736..611c9bfd1f 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -551,6 +551,26 @@ fn svg_export_keeps_a_measured_region_only_on_the_object_alone() { assert!(after.contains(""#; + for (scale, axes, live) in [(2.0, json!([2, 0, 0, 2]), true), (1000.0, json!([2, 0, 0, 2]), false)] { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": hue, "scale": scale, "axes": axes}})); + let out = svg(&mut s); + assert_eq!((out.contains(" std::collections::BTreeSet { + pdf.split(" scn").filter_map(|s| s.rsplit("cs ").next().filter(|t| t.parse::().is_ok()).map(str::to_string)).collect() + }; + let kept = pdf(true); + assert!(kept.contains("/Separation/Ink/DeviceCMYK"), "the faces print on the Ink plate"); + assert!(tints(&kept).len() > 1, "the highlight prints lighter tints of the ink: {:?}", tints(&kept)); + assert!(kept.contains("/Multiply"), "the shading is black multiplied over the ink"); + // Off, only a face left unshaded keeps the swatch, at its full tint. + assert!(tints(&pdf(false)).len() <= 1, "without the option shaded faces are process mixes"); +} + #[test] fn global_mode_shifts_tints_of_linked_colours_only() { let mut s = inks(); diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index 2ec7667bde..a9d9e3f5bd 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -286,7 +286,7 @@ fn legacy_spiral_decay_preferences_report_the_ratio_drawn_after_reopening() { assert_eq!(d.selection.objects.len(), 1); let id = d.selection.objects[0]; let path = d.doc.node(id).unwrap().path_data().unwrap().clone(); - let anchors = &path.subpaths[0].anchors; + let anchors = &path.subpaths[0].anchors.to_vec(); assert_eq!(anchors.len(), 5); assert!(!path.is_closed()); let center = Point::new(200.0, 200.0); diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index bb5b73b2f2..9119c3e151 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -1063,6 +1063,110 @@ fn renaming_a_missing_name_onto_a_taken_one_reports_the_missing_name() { assert!(e.contains("no dataset named `Nope`"), "{e}"); } +/// Variable Options changes a variable's kind: the objects that can't hold the new kind are +/// unbound, the ones that can (locked ones too) stay bound, every value of the old kind goes, and +/// the change is one undo step. +#[test] +fn variable_options_changes_the_kind_and_unbinds_what_cant_hold_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + let other = text(&mut s, "Held"); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [t.0, r.0, other.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Title", "ids": [other.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A", "values": {"Show": false, "Title": "Bob"}})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Show": true}})).unwrap(); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).unwrap(); + // The rectangle isn't type, and the other type object already has the text variable Title. + assert_eq!(v["unbound"], json!([r.0, other.0])); + assert_eq!((v["name"].clone(), v["kind"].clone(), v["droppedValues"].clone()), (json!("Show"), json!("text"), json!(2))); + assert_eq!(steps(&s), before + 1, "one undo step"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.objects_of("Show"), [t], "the locked type object stays bound"); + assert_eq!(vars.objects_of("Title"), [other], "the other variable keeps its binding"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!((sets["datasets"][0]["values"].clone(), sets["datasets"][1]["values"].clone()), (json!({"Title": {"text": "Bob"}}), json!({}))); + + s.execute("edit.undo", &json!({})).unwrap(); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.variable("Show").map(|v| v.kind), Some(vectorcraft_doc::VariableKind::Visibility)); + assert_eq!(vars.objects_of("Show"), [t, r, other]); + assert_eq!(vars.dataset("A").and_then(|d| d.values.get("Show")), Some(&vectorcraft_doc::DataValue::Visible(false))); + + // A new name and a new kind together are one undo step, and every visibility binding stays. + s.execute("variable.rename", &json!({"name": "Title", "newName": "Heading", "kind": "visibility"})).unwrap(); + assert_eq!(steps(&s), before + 1, "one step after the undo"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!( + (vars.variable("Heading").map(|v| v.kind), vars.objects_of("Heading")), + (Some(vectorcraft_doc::VariableKind::Visibility), vec![other]) + ); + // An image variable keeps only image objects. + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "image"})).unwrap(); + assert_eq!(v["unbound"], json!([t.0, r.0, other.0])); +} + +/// Variable Options with the variable's own name and kind changes nothing and adds no undo step; +/// a bad kind, a taken name, no change asked for and locked variables are refused. +#[test] +fn variable_options_refuses_bad_params_and_skips_a_change_that_changes_nothing() { + let mut s = session(); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + for p in [ + json!({"name": "Show", "newName": "Show"}), + json!({"name": "Show", "kind": "visibility"}), + json!({"name": "Show", "newName": "Show", "kind": "visible"}), + ] { + let v = s.execute("variable.rename", &p).unwrap(); + assert_eq!((v["unbound"].clone(), v["droppedValues"].clone()), (json!([]), json!(0)), "{p}"); + assert_eq!(steps(&s), before, "{p}: no undo step"); + } + for (p, want) in [ + (json!({"name": "Show", "kind": "unknown"}), "`kind` must be"), + (json!({"name": "Show", "kind": "number"}), "`kind` must be"), + (json!({"name": "Show", "kind": 1}), "`kind` must be"), + (json!({"name": "Show", "newName": "New", "kind": null}), "`kind` must be"), + (json!({"name": "Show", "newName": null, "kind": "text"}), "missing `newName`"), + (json!({"name": "Show", "newName": " ", "kind": "text"}), "missing `newName`"), + (json!({"name": "Show"}), "missing `newName` or `kind`"), + (json!({"name": "Show", "newName": "Other", "kind": "text"}), "already exists"), + (json!({"name": "Ghost", "kind": "text"}), "no variable named `Ghost`"), + ] { + let e = s.execute("variable.rename", &p).unwrap_err().to_string(); + assert!(e.contains(want), "{p}: {e}"); + } + // A rename alone carries a value that doesn't fit the kind, as a file may hold one. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc) + .variables + .datasets + .push(vectorcraft_doc::DataSet { name: "Odd".into(), values: [("Show".to_string(), vectorcraft_doc::DataValue::Text("x".into()))].into() }); + s.execute("variable.rename", &json!({"name": "Show", "newName": "Shown"})).unwrap(); + let odd = s.doc().unwrap().doc.variables.dataset("Odd").and_then(|d| d.values.get("Shown")).cloned(); + assert_eq!(odd, Some(vectorcraft_doc::DataValue::Text("x".into()))); + s.execute("edit.undo", &json!({})).unwrap(); + // A kind change drops it, though it fits the new kind: nobody gave it for that kind. + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).unwrap(); + assert_eq!(v["droppedValues"], 1); + assert!(s.doc().unwrap().doc.variables.dataset("Odd").is_some_and(|d| d.values.is_empty())); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("variable.lock", &json!({"locked": true})).unwrap(); + assert!(s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).is_err(), "refused while the variables are locked"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!((vars.variable("Show").map(|v| v.kind), vars.objects_of("Show")), (Some(vectorcraft_doc::VariableKind::Visibility), vec![r])); +} + /// `dataset.set` is how a value changes after the row exists: replacing it must not need the /// dataset to be deleted and rebuilt (which loses its place in the list and the active one). #[test] @@ -3382,3 +3486,93 @@ fn an_svg_without_readable_variable_data_opens_its_art() { assert!(d.variables.variables.is_empty()); assert_eq!(vectorcraft_testkit::svg_shapes(&vectorcraft_svg::export(d, &Default::default())), 1, "the art opens"); } + +/// Five variables of three kinds, defined out of every order: `beta` (text, on type named Mike), +/// `Alpha` (visibility, on a rectangle named apple), `gamma` (image, bound to nothing), `Delta` +/// (visibility, on two rectangles) and `Echo` (text, on type named Bravo). +fn sortable_variables() -> Session { + let mut s = session(); + let mike = text(&mut s, "m"); + let bravo = text(&mut s, "b"); + let apple = rect(&mut s); + let (r1, r2) = (rect(&mut s), rect(&mut s)); + for (id, name) in [(mike, "Mike"), (bravo, "Bravo"), (apple, "apple")] { + s.execute("layer.setProps", &json!({"id": id.0, "name": name})).unwrap(); + } + for (name, kind, ids) in [ + ("beta", "text", vec![mike]), + ("Alpha", "visibility", vec![apple]), + ("gamma", "image", vec![]), + ("Delta", "visibility", vec![r1, r2]), + ("Echo", "text", vec![bravo]), + ] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + if !ids.is_empty() { + s.execute("variable.bind", &json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + } + } + s +} + +fn listed_names(s: &mut Session) -> Vec { + let list = s.execute("variable.list", &json!({})).unwrap(); + list["variables"].as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect() +} + +/// `variable.sort` orders the Variables panel's list, which `variable.list` follows, by each +/// column: names without regard to case; by object, unbound first and several objects last; by +/// type, text, image, graph, visibility. `descending` reverses each order, and `none` goes back +/// to the order the variables were defined in. +#[test] +fn variable_sort_orders_the_list_by_each_column() { + let mut s = sortable_variables(); + let defined = ["beta", "Alpha", "gamma", "Delta", "Echo"]; + assert_eq!(listed_names(&mut s), defined); + assert_eq!(s.execute("variable.list", &json!({})).unwrap()["sort"], json!({"by": "none", "descending": false})); + for (by, order) in [ + ("name", ["Alpha", "beta", "Delta", "Echo", "gamma"]), + ("object", ["gamma", "Alpha", "Echo", "beta", "Delta"]), + ("type", ["beta", "Echo", "gamma", "Alpha", "Delta"]), + ("none", defined), + ] { + let r = s.execute("variable.sort", &json!({"by": by})).unwrap(); + assert_eq!(r, json!({"by": by, "descending": false})); + assert_eq!(listed_names(&mut s), order, "{by}"); + s.execute("variable.sort", &json!({"by": by, "descending": true})).unwrap(); + let reversed: Vec<&str> = order.iter().rev().copied().collect(); + assert_eq!(listed_names(&mut s), reversed, "{by}, descending"); + assert_eq!(s.execute("variable.list", &json!({})).unwrap()["sort"], json!({"by": by, "descending": true})); + } +} + +/// The sort is panel state: it is not an undo step and does not count as a change to save, and +/// setting the order the list already has does not redraw anything. A bad `by` or `descending` +/// is an error that leaves the order as it was. +#[test] +fn variable_sort_refuses_bad_params_and_is_not_an_undo_step() { + let mut s = sortable_variables(); + let steps = s.doc().unwrap().history.undo.len(); + let dirty = s.doc().unwrap().is_dirty(); + s.execute("variable.sort", &json!({"by": "name", "descending": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step"); + assert_eq!(s.doc().unwrap().is_dirty(), dirty); + let revision = s.doc().unwrap().revision; + s.execute("variable.sort", &json!({"by": "name", "descending": true})).unwrap(); + assert_eq!(s.doc().unwrap().revision, revision, "the same order changes nothing"); + + for (p, error) in [ + (json!({}), "missing `by`"), + (json!({"by": null}), "`by` must be one of name, object, type, none"), + (json!({"by": "name", "descending": null}), "`descending` must be true or false"), + (json!({"by": "size"}), "`by` must be one of name, object, type, none"), + (json!({"by": "Name"}), "`by` must be one of name, object, type, none"), + (json!({"by": 1}), "`by` must be one of name, object, type, none"), + (json!({"by": "type", "descending": "yes"}), "`descending` must be true or false"), + ] { + let e = s.execute("variable.sort", &p).unwrap_err().to_string(); + assert!(e.contains(error), "{p}: {e}"); + } + let sort = s.doc().unwrap().variables_sort; + assert_eq!((sort.by, sort.descending), (crate::cmd::VariableSortKey::Name, true), "errors leave the order alone"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index f6bc163e65..66ed0a41e3 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1103,6 +1103,12 @@ impl Session { self.pointer_held } + /// What the Info panel shows for the active tool as it is now + /// ([`vectorcraft_tools::Tool::readout`]), in the view of the last pointer event. + pub fn tool_readout(&mut self) -> Option { + self.with_tool_cx(self.last_view, |t, cx| t.readout(cx)) + } + /// Is the active tool moving, scaling or rotating the selection with a drag? pub fn tool_transforming(&self) -> bool { self.tool.transforming() @@ -1256,6 +1262,14 @@ impl Session { /// is placed under ([`Session::place_queue_roots`]). fn note_place_queue_roots(&mut self) { self.place_queue_roots = crate::file_access::current(); + self.place_queue_loads = self.place_queue_loads.wrapping_add(1); + } + + /// How many times the place cursor has been loaded (`file.place.queue`, or a `queue` set on + /// the Place tool): a frontend that keeps its own state about the loaded cursor compares it to + /// tell a newer load. + pub fn place_queue_loads(&self) -> u64 { + self.place_queue_loads } /// The automation roots the place cursor's files are placed under: those in force when it diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index b02e34690b..8ea057cd12 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -823,7 +823,13 @@ fn color_settings_come_back_in_the_next_session() { a.execute("edit.colorSettings", &json!({})).unwrap(); assert!(!file.exists(), "nothing to keep before the settings change"); a.execute("edit.colorSettings", &json!({"rgb": loaded, "cmyk": cms::DEVICE_CMYK, "intent": "perceptual", "bpc": false})).unwrap(); - let chosen = cms::ColorSettings { rgb: loaded.into(), cmyk: cms::DEVICE_CMYK.into(), intent: cms::Intent::Perceptual, bpc: false }; + let chosen = cms::ColorSettings { + rgb: loaded.into(), + cmyk: cms::DEVICE_CMYK.into(), + intent: cms::Intent::Perceptual, + bpc: false, + ..cms::ColorSettings::default() + }; assert_eq!(cms::active_settings(), chosen); // The next start begins with the defaults and without the loaded profile, which the profile @@ -865,3 +871,163 @@ fn saved_color_settings_that_cannot_be_used_leave_the_defaults() { assert!(!saved.bpc && saved.cmyk == cms::GENERIC_CMYK, "written over by the next change: {saved:?}"); } } + +/// Edit › Color Settings' color management policies decide what opening a document tagged with +/// profiles other than the working spaces does with them: Preserve Embedded Profiles keeps them, +/// Off and Preserve Numbers take them off and keep the color numbers, and Convert to Working Space +/// converts the colors into the working spaces, keeping how they look, and takes them off. A +/// document a policy changed opens with unsaved changes, and the open reply names each profile. +/// A profile that is the working space stays, and File › Revert and File › New from Template apply +/// the policies too. +#[test] +fn color_management_policies_keep_take_off_or_convert_a_tagged_profile() { + let _g = settings(); + let dir = fresh_dir("policies"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + std::fs::create_dir_all(&dir).unwrap(); + let tagged = dir.join("tagged.vectorcraft"); + let mut s = session(); + let (blue, ink) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 0.0, 10.0, 10.0)); + exec(&mut s, "paint.setFill", json!({"ids": [blue.0], "color": "#3366cc"})); + exec(&mut s, "paint.setFill", json!({"ids": [ink.0], "color": {"c": 20, "m": 40, "y": 60, "k": 10}})); + exec(&mut s, "edit.assignProfile", json!({"rgb": cms::WIDE_GAMUT_RGB, "cmyk": cms::DEVICE_CMYK})); + // An artboard background, a character style's fill and a freeform gradient swatch, colors + // outside the art. + let paper = vectorcraft_color::Color::Rgb { r: 0.9, g: 0.8, b: 0.6 }; + let free = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + select(&mut s, &[free]); + exec(&mut s, "paint.setFill", json!({"gradient": {"kind": "freeform"}})); + exec(&mut s, "paint.freeform.setPoint", json!({"index": 0, "color": "#e6cc99", "stroke": false})); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.artboards[0].color = Some(paper); + let mut style = vectorcraft_doc::TextStyleDef { name: "Inked".into(), attrs: Default::default() }; + style.attrs.insert("fill".into(), serde_json::to_value(vectorcraft_color::Paint::solid(paper)).unwrap()); + d.char_styles.push(style); + let paint = d.node(free).unwrap().appearance.fill().unwrap().paint.clone(); + d.swatches.push(vectorcraft_color::Swatch { name: "Free".into(), paint, global: false, spot: false }); + } + exec(&mut s, "file.saveAs", json!({"path": tagged.to_str().unwrap()})); + let settings_colors = |s: &Session| { + let d = &s.doc().unwrap().doc; + let style = serde_json::from_value::(d.char_styles[0].attrs["fill"].clone()).unwrap(); + (d.artboards[0].color.unwrap(), style.color().unwrap()) + }; + // The first point of the freeform swatch and of the freeform fill. + let free_points = |s: &Session| { + let d = &s.doc().unwrap().doc; + let first = |p: &vectorcraft_color::Paint| match p { + vectorcraft_color::Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points[0].color), + _ => None, + }; + let swatch = d.swatches.iter().find(|w| w.name == "Free").and_then(|w| first(&w.paint)).unwrap(); + (swatch, first(&d.node(free).unwrap().appearance.fill().unwrap().paint).unwrap()) + }; + let (free0, _) = free_points(&s); + let fill = |s: &Session, id| s.doc().unwrap().doc.node(id).and_then(|n| n.appearance.fill()).and_then(|f| f.paint.color()).unwrap(); + let (blue0, ink0) = (fill(&s, blue), fill(&s, ink)); + let source = cms::for_document(Some(cms::WIDE_GAMUT_RGB), Some(cms::DEVICE_CMYK)); + let notes = |r: &serde_json::Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("profile")).count(); + // A session that opened the file under the policies, and the open reply. + let open = |rgb: &str, cmyk: &str| { + let mut s = session(); + exec(&mut s, "edit.colorSettings", json!({"rgbPolicy": rgb, "cmykPolicy": cmyk})); + let r = exec(&mut s, "document.open", json!({"path": tagged.to_str().unwrap()})); + (r, s) + }; + + let (r, kept) = open("preserveEmbedded", "preserveEmbedded"); + assert_eq!(doc_profiles(&kept.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), Some(cms::DEVICE_CMYK.into()))); + assert_eq!((fill(&kept, blue), fill(&kept, ink), notes(&r)), (blue0, ink0, 0)); + assert!(!kept.doc().unwrap().is_dirty()); + + for (rgb, cmyk) in [("off", "off"), ("off", "preserveNumbers")] { + let (r, off) = open(rgb, cmyk); + assert_eq!(doc_profiles(&off.doc().unwrap().doc), (None, None), "{cmyk}"); + assert_eq!((fill(&off, blue), fill(&off, ink), notes(&r)), (blue0, ink0, 2), "{cmyk}: the numbers stay"); + assert!(off.doc().unwrap().is_dirty(), "{cmyk}: the document changed"); + } + + let (r, converted) = open("convert", "convert"); + assert_eq!(doc_profiles(&converted.doc().unwrap().doc), (None, None)); + assert_eq!(notes(&r), 2); + let working = cms::working(); + let (blue1, ink1) = (fill(&converted, blue), fill(&converted, ink)); + assert!(blue1 != blue0 && ink1 != ink0, "the numbers change: {blue1:?} {ink1:?}"); + assert!(cms::delta_e2000(source.lab(&blue0), working.lab(&blue1)) < 1.0, "{blue0:?} → {blue1:?}"); + assert!(cms::delta_e2000(source.lab(&ink0), working.lab(&ink1)) < 3.0, "{ink0:?} → {ink1:?}"); + assert!(converted.doc().unwrap().is_dirty()); + let (board, styled) = settings_colors(&converted); + assert_eq!(board, styled, "the artboard and the style convert alike"); + assert!(board != paper && cms::delta_e2000(source.lab(&paper), working.lab(&board)) < 1.0, "{paper:?} → {board:?}"); + assert_eq!(settings_colors(&kept), (paper, paper), "kept with the profile"); + let (swatch_point, fill_point) = free_points(&converted); + assert_eq!(swatch_point, fill_point, "the freeform swatch and fill convert alike"); + assert!(swatch_point != free0 && cms::delta_e2000(source.lab(&free0), working.lab(&swatch_point)) < 1.0, "{free0:?} → {swatch_point:?}"); + + // A color mode asked for on open converts the colors the policy converted, through the working + // spaces, also when the document in front draws through another profile. + let mut s2 = session(); + exec(&mut s2, "edit.assignProfile", json!({"rgb": cms::DISPLAY_P3})); + exec(&mut s2, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "convert"})); + exec(&mut s2, "document.open", json!({"path": tagged.to_str().unwrap(), "colorMode": "cmyk"})); + let intent = working.settings().intent; + let want = working.convert(&blue1, cms::Model::Cmyk, intent); + let got = fill(&s2, blue); + assert!(cms::delta_e2000(working.lab(&want), working.lab(&got)) < 0.01, "{want:?} vs {got:?}"); + + // A profile that is the working space is kept whatever the policy. + exec(&mut s, "edit.colorSettings", json!({"rgb": cms::WIDE_GAMUT_RGB})); + let (r, same) = open("off", "off"); + assert_eq!(doc_profiles(&same.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), None)); + assert_eq!(notes(&r), 1); + exec(&mut s, "edit.colorSettings", json!({"rgb": cms::SRGB})); + + // File › Revert reads the file again under the policies. + let (_, mut reverted) = open("preserveEmbedded", "preserveEmbedded"); + exec(&mut reverted, "paint.setFill", json!({"ids": [blue.0], "color": "#ff0000"})); + exec(&mut reverted, "edit.colorSettings", json!({"cmykPolicy": "preserveNumbers"})); + let r = exec(&mut reverted, "file.revert", json!({"confirmed": true})); + assert_eq!(doc_profiles(&reverted.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), None)); + assert_eq!((fill(&reverted, blue), notes(&r)), (blue0, 1)); + + // File › New from Template applies the policies as opening does. + let mut from_template = session(); + exec(&mut from_template, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "off"})); + let r = exec(&mut from_template, "file.newFromTemplate", json!({"path": tagged.to_str().unwrap()})); + assert_eq!(doc_profiles(&from_template.doc().unwrap().doc), (None, None)); + assert_eq!(notes(&r), 2); + assert_eq!(fill(&from_template, ink), ink0, "Off keeps the CMYK numbers"); + let blue2 = fill(&from_template, blue); + assert!(blue2 != blue0 && cms::delta_e2000(source.lab(&blue0), working.lab(&blue2)) < 1.0, "{blue0:?} → {blue2:?}"); + assert!(from_template.doc().unwrap().is_dirty()); +} + +/// The policies are checked, come back in the reply and in `Color Settings.json`, and a saved file +/// that gives the RGB policy Preserve Numbers, a CMYK policy only, leaves the defaults. +#[test] +fn color_management_policies_are_checked_and_kept() { + let _g = settings(); + let dir = fresh_dir("policies-kept"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + let file = dir.join("Color Settings.json"); + let mut s = session(); + s.attach_color_settings(file.clone()); + for bad in [json!({"rgbPolicy": "preserveNumbers"}), json!({"cmykPolicy": "keep"}), json!({"rgbPolicy": 1})] { + assert!(s.execute("edit.colorSettings", &bad).is_err(), "{bad}"); + } + assert!(!file.exists(), "a refused change keeps nothing"); + let r = exec(&mut s, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "preserveNumbers"})); + assert_eq!((r["rgbPolicy"].as_str(), r["cmykPolicy"].as_str()), (Some("convert"), Some("preserveNumbers"))); + let saved: cms::ColorSettings = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!((saved.rgb_policy, saved.cmyk_policy), (cms::Policy::Convert, cms::Policy::PreserveNumbers)); + cms::set_active(&cms::ColorSettings::default()).unwrap(); + std::fs::write( + &file, + json!({"rgb": cms::SRGB, "cmyk": cms::GENERIC_CMYK, "intent": "relative", "bpc": true, "rgbPolicy": "preserveNumbers"}).to_string(), + ) + .unwrap(); + let mut next = Session::new(); + next.attach_color_settings(file); + assert_eq!(cms::active_settings(), cms::ColorSettings::default()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 46e8561f4c..8de519fa4e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1672,6 +1672,9 @@ fn action_set(bytes: &[u8]) -> Result<(), TestCaseError> { prop_assert!(actions.iter().all(|a| a["steps"].as_array().map_or(0, Vec::len) <= 10_000)); prop_assert!(actions.iter().map(|a| a["steps"].as_array().map_or(0, Vec::len)).sum::() <= 100_000); prop_assert!(r["unread"].as_array().map_or(0, Vec::len) <= 1000); + let steps = || actions.iter().flat_map(|a| a["steps"].as_array().cloned().unwrap_or_default()); + prop_assert!(steps().all(|s| s["values"].as_array().map_or(0, Vec::len) as u64 <= s["params"].as_u64().unwrap_or(0))); + prop_assert!(steps().map(|s| s["values"].as_array().map_or(0, Vec::len)).sum::() <= 100_000); } Ok(()) } @@ -1690,6 +1693,8 @@ fn arb_aia_token() -> impl Strategy> { "/localizedName", "/isOn", "/value", + "/key", + "/type", "/x", "/version", ]) @@ -1699,6 +1704,7 @@ fn arb_aia_token() -> impl Strategy> { "[a-z ()\\\\]{0,12}".prop_map(|t| format!("({t})").into_bytes()), prop::sample::select(vec!["(", ")", "\\", "[ 2 4142 ]", "[ 3 41 4", "[ 1 zz ]", "[", "]", "< 2 0a0b >", "<", ">", "2.5"]) .prop_map(|t| t.as_bytes().to_vec()), + prop::sample::select(vec!["[ 2 c328 ]", "(ustring)", "(boolean)"]).prop_map(|t| t.as_bytes().to_vec()), prop::sample::select(vec!["{", "}", "\r\n", "\r", "\n"]).prop_map(|t| t.as_bytes().to_vec()), prop::sample::select(vec![vec![0u8], vec![0xFF, 0xFE], vec![0xC3]]), ] @@ -1835,6 +1841,7 @@ proptest! { /// Mutated copies of the PSD and PSB samples never crash opening them with their layers, hidden /// ones too: the PSD's layer records, channel lengths and data, and the PSB's layer count. /// (Masks, clipping and groups are covered by `tests_psdlayers::damaged_layers_never_panic`.) + /// Neither does `document.psdInfo`, nor placing them with their layers as objects. #[test] fn mutated_layered_psds_open_without_panics(which in 6usize..8, cut in 0usize..1_000_000, edits in prop::collection::vec((0usize..1_000_000, any::()), 0..12)) { let (name, mut bytes) = raster_samples().swap_remove(which); @@ -1849,6 +1856,10 @@ proptest! { let mut s = vectorcraft_engine::Session::new(); let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "hiddenLayers": true}); let _ = no_panic(s.execute("document.open", &p)); + let _ = no_panic(s.execute("document.psdInfo", &p)); + let _ = no_panic(s.execute("file.new", &json!({}))); + let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "layers": true, "hiddenLayers": true}); + let _ = no_panic(s.execute("file.place", &p)); }); prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); } diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 609e0aedee..f27e646791 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -274,6 +274,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re // The objects that may keep their effects in an art style, as they are before baking (not on // a large canvas, whose scale the styles' strokes and effects would need). let styled = writer::styled_objects(doc); + let (symbol_styled, pattern_styled) = writer::styled_definitions(doc); let own_styles = doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles; let links = writer::style_links(doc); // Live geometry effects as their result, as the PDF part draws them. @@ -311,16 +312,14 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re w.reserve_swatch_gradients(); if scale == 1.0 { w.styled = styled; + (w.symbol_styled, w.pattern_styled) = (symbol_styled, pattern_styled); w.panel_styles(); - } else if !styled.is_empty() || own_styles || links > 0 { + } else if !styled.is_empty() || !symbol_styled.is_empty() || !pattern_styled.is_empty() || own_styles || links > 0 { w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string()); } for layer in &doc.layers { w.layer(layer); } - if scale == 1.0 { - w.lost_links(links); - } // The symbols no instance uses too; their art adds definitions. w.symbols(); // The brushes, once the layers have said whether an object is written with one. @@ -328,6 +327,10 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re // The palette first: its gradient and pattern swatches add definitions. let mut palette = String::new(); w.palette(&mut palette); + // Once every definition is written: their opacity masks' art keeps links too. + if scale == 1.0 { + w.lost_links(links); + } let library = w.variable_library(); let first = to_art.transform_rect_bbox(board); let art = doc diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index e91e12a4bd..671f639327 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1038,7 +1038,8 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { /// The documents [`the_writer_counts_objects_as_the_reader_reads_them`] writes: groups, compound /// paths, guides, sublayers, clipping groups and layers, painted clipping paths, opacity masks, /// gradients, pattern definitions and tiles, images, type, symbol definitions and instances, -/// objects with art styles, objects bound to variables and live envelopes. +/// objects with art styles, objects bound to variables, live envelopes, and the rectangles of the +/// bounds of wrapped objects written as their art. fn counted() -> Vec<(&'static str, Document)> { vec![ ("sample", sample()), @@ -1054,9 +1055,28 @@ fn counted() -> Vec<(&'static str, Document)> { ("blend", blend_doc()), ("brushes", brushes_document()), ("envelopes", envelopes_document()), + ("wrap bounds", wrap_bounds_doc()), ] } +/// Area type below a wrapped symbol instance written as its art and wrapped type written as its +/// outlines, each with the rectangle of its bounds. +fn wrap_bounds_doc() -> Document { + let mut doc = over_area_type(|doc| { + two_leaves(doc); + let mut n = instance(doc, "Leaf", Affine::new([3.0, 0.0, 0.0, 3.0, 150.0, 100.0])); + n.isolate = true; + wrapped(n, 4.5, false) + }); + let mut label = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Ill", style(60.0, Color::BLACK)); + // Mirrored, so written as its outlines. + label.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 150.0, 120.0]); + let label = wrapped(text_node(&mut doc, label), -2.0, true); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, label).unwrap(); + doc +} + /// A blend of two squares 20 pt apart with a step every 10 pt, written as its steps. fn blend_doc() -> Document { let mut doc = Document::new(200.0, 100.0); @@ -6761,6 +6781,159 @@ fn hidden_and_nested_mask_art_keep_live_offset_path() { } } +/// An image 60 × 40 points at (10, 10) whose opacity mask, of `art`, doesn't clip. +fn masked_image_node(doc: &mut Document, art: Node) -> Node { + let im = image_node(doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([60.0, 0.0, 0.0, 40.0, 10.0, 10.0]), None); + with_mask(im, art) +} + +/// A red square from (10, 10) to (70, 50) whose opacity mask, of `art`, doesn't clip. +fn masked_square(doc: &mut Document, art: Node) -> Node { + let sq = square(doc, Rect::new(10.0, 10.0, 70.0, 50.0), Color::rgb(1.0, 0.0, 0.0)); + with_mask(sq, art) +} + +/// `n` with an opacity mask of `art` that doesn't clip. +fn with_mask(mut n: Node, art: Node) -> Node { + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true }.into()); + n +} + +/// The opacity mask art of the first object with one in `n`, its masks' art included. +fn mask_art_in(n: &Node) -> Option> { + let mut art = None; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + if art.is_none() + && let Some(mask) = m.extras().mask.as_ref() + { + art = Some(mask.art.clone()); + } + }); + art +} + +/// The Offset Path distance of `n`'s one live effect, when that is all it has. +fn offset_path_of(n: &Node) -> Option { + match n.appearance.effects.as_slice() { + [e] if e.id == "path.offsetPath" => e.params.get("offset").and_then(serde_json::Value::as_f64), + _ => None, + } +} + +/// The opacity mask art of an image in a symbol's art keeps its live effects as an art style: a +/// black path with Offset Path reads back with the effect live on the definition's mask art, when +/// the mask's art root is hidden and when the symbol is used inside another symbol's art too, and +/// both instances draw as they did. +#[test] +fn a_symbols_mask_art_keeps_live_offset_path() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0})); + let made = |effects: Vec, case: &str| { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, effects); + grown.visible = case != "hidden"; + let im = masked_image_node(&mut doc, grown); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(im) }); + let mut used = "Picture"; + if case == "nested" { + let inner = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Picture".into(), xf: Affine::translate((5.0, 0.0)) }); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::rgb(1.0, 0.0, 0.0)); + let frame = group(&mut doc, vec![inner, dot], false); + doc.symbols.push(Symbol { dynamic: false, name: "Frame".into(), art: Arc::new(frame) }); + used = "Frame"; + } + for x in [0.0, 100.0] { + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: used.into(), xf: Affine::translate((x, 20.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + } + doc + }; + for case in ["shown", "hidden", "nested"] { + let doc = made(vec![offset.clone()], case); + assert!(pixels_apart(&doc, &made(vec![], case)) > 0, "{case}: the effect draws nothing"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{case}: {:?}", out.warnings); + let back = read_back(&doc); + let picture = back.symbols.iter().find(|s| s.name == "Picture").unwrap_or_else(|| panic!("{case}: {:?}", back.symbols)); + let art = mask_art_in(&picture.art).unwrap_or_else(|| panic!("{case}: no masked image in {:?}", picture.art.kind)); + assert_eq!(offset_path_of(&art), Some(6.0), "{case}: {:?}", art.appearance.effects); + assert_eq!(pixels_apart(&doc, &back), 0, "{case}"); + } +} + +/// The opacity mask art of a square in a pattern's tile keeps its live effects as an art style: the +/// tile's black path with Offset Path reads back with the effect live, and the pattern fill draws as +/// it did. +#[test] +fn a_pattern_tiles_mask_art_keeps_live_offset_path() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0})); + let made = |effects: Vec| { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, effects); + let sq = masked_square(&mut doc, grown); + let tile = Rect::new(0.0, 0.0, 80.0, 60.0); + doc.patterns.push(vectorcraft_doc::PatternDef { tile, ..vectorcraft_doc::PatternDef::new("Masked", vec![Arc::new(sq)]) }); + let fill = Paint::Pattern { pattern: "Masked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + doc + }; + let doc = made(vec![offset]); + assert!(pixels_apart(&doc, &made(vec![])) > 0, "the effect draws nothing"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + let def = back.pattern("Masked").unwrap_or_else(|| panic!("{:?}", back.patterns.iter().map(|p| &p.name).collect::>())); + let art = def.art.iter().find_map(|a| mask_art_in(a)).expect("a masked image in the tile"); + assert_eq!(offset_path_of(&art), Some(6.0), "{:?}", art.appearance.effects); + assert_eq!(pixels_apart(&doc, &back), 0); +} + +/// Each opacity mask's art keeps its own art style: masks on the layers, in a symbol's art and in a +/// pattern's tile with different Offset Path distances read back with their own distances, and a +/// symbol's mask art with an effect no art style holds (Roughen) reads back without a live effect. +#[test] +fn mask_art_styles_stay_with_their_own_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let masked = |doc: &mut Document, effect: vectorcraft_doc::Effect| { + let grown = styled_rect(doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, vec![effect]); + masked_image_node(doc, grown) + }; + let by = |d: f64| fx("path.offsetPath", serde_json::json!({"offset": d})); + let on_layer = masked(&mut doc, by(3.0)); + doc.insert(Some(layer), usize::MAX, on_layer).unwrap(); + let picture = masked(&mut doc, by(6.0)); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(picture) }); + let rough = masked(&mut doc, fx("distort.roughen", serde_json::json!({"size": 6.0, "relative": false, "points": "corner"}))); + doc.symbols.push(Symbol { dynamic: false, name: "Rough".into(), art: Arc::new(rough) }); + for (symbol, x) in [("Picture", 100.0), ("Rough", 120.0)] { + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf: Affine::translate((x, 40.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + } + let grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, vec![by(9.0)]); + let tile = masked_square(&mut doc, grown); + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Masked", vec![Arc::new(tile)]) + }); + let fill = Paint::Pattern { pattern: "Masked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 60.0, 80.0, 100.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let back = read_back(&doc); + let symbol_art = |name: &str| { + let s = back.symbols.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name}: {:?}", back.symbols)); + mask_art_in(&s.art).unwrap_or_else(|| panic!("{name}: no masked image")) + }; + assert_eq!(offset_path_of(&image_mask_art(&back)), Some(3.0)); + assert_eq!(offset_path_of(&symbol_art("Picture")), Some(6.0)); + let tile = back.pattern("Masked").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("a masked image in the tile"); + assert_eq!(offset_path_of(&tile), Some(9.0)); + let rough = symbol_art("Rough"); + assert!(rough.appearance.effects.is_empty(), "{:?}", rough.appearance.effects); +} + /// A geometry effect on an opacity mask's art that no art style holds (Roughen) is applied to the /// art written, so the mask reads back drawing what it drew, but for pixels on its edge that the /// written points' rounding moves. @@ -6900,6 +7073,142 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() { assert_eq!(n.extras().graphic_style, None); } +/// Links to graphic styles in pattern tiles count toward the warning about lost links: a tile's +/// object linked to a style, which is written without its link, warns; and so does an object on the +/// layers that loses its link while a tile's opacity mask art keeps its own. +#[test] +fn pattern_tile_style_links_count_toward_lost_links() { + let made = |linked_tile: bool| { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap.clone()); + linked.extras_mut().graphic_style = Some(7); + let art = if linked_tile { + linked + } else { + // An even-odd path is written without its link (see the test above). + let mut lost = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 40.0)), ap); + lost.extras_mut().graphic_style = Some(7); + if let NodeKind::Path { rule, .. } = &mut lost.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, lost); + masked_square(&mut doc, linked) + }; + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Linked", vec![Arc::new(art)]) + }); + let fill = Paint::Pattern { pattern: "Linked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 80.0, 100.0)), Appearance::basic(fill, Paint::None, 0.0)); + put(&mut doc, filled); + doc + }; + for linked_tile in [true, false] { + let doc = made(linked_tile); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{linked_tile}: {:?}", out.warnings); + } + let back = read_back(&made(false)); + let tile = back.pattern("Linked").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("a masked square in the tile"); + assert!(tile.extras().graphic_style.is_some(), "the tile's mask art keeps its link"); +} + +/// An opacity mask's art linked to a graphic style reads back linked to it. +#[test] +fn opacity_mask_art_keeps_its_graphic_style_link() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap); + linked.extras_mut().graphic_style = Some(7); + let sq = masked_square(&mut doc, linked); + put(&mut doc, sq); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let art = mask_art_in(&back.layers[0]).expect("a masked square"); + let id = art.extras().graphic_style.expect("the mask's art keeps its link"); + assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Solid")); +} + +/// A link written in a pattern tile's opacity mask art that the object limit then leaves out with +/// the rest of the tile is not counted as kept: at every limit where the style isn't written, the +/// export warns that the link is lost. +#[test] +fn mask_art_links_left_out_at_the_object_limit_are_reported_lost() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap); + linked.extras_mut().graphic_style = Some(7); + let mut art = vec![Arc::new(masked_square(&mut doc, linked))]; + for i in 0..6 { + let x = 2.0 + 12.0 * f64::from(i); + art.push(Arc::new(square(&mut doc, Rect::new(x, 52.0, x + 8.0, 58.0), Color::rgb(0.0, 0.0, 1.0)))); + } + doc.patterns.push(vectorcraft_doc::PatternDef { tile: Rect::new(0.0, 0.0, 80.0, 60.0), ..vectorcraft_doc::PatternDef::new("Linked", art) }); + let fill = Paint::Pattern { pattern: "Linked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let mut left_out = 0; + for max in 1..40 { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).into_owned(); + if !text.contains("(Solid) XW") { + left_out += 1; + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{max}: {:?}", out.warnings); + } + } + assert!(left_out > 0 && left_out < 39, "{left_out}"); +} + +/// The links of opacity masks' art in a symbol no instance uses and in a pattern only the Swatches +/// panel holds are written with those definitions, after the layers, and count as kept: no warning +/// about lost links. +#[test] +fn mask_art_links_in_unused_definitions_are_not_reported_lost() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let linked = |doc: &mut Document| { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap.clone()); + n.extras_mut().graphic_style = Some(7); + masked_square(doc, n) + }; + let unused = linked(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "Unused".into(), art: Arc::new(unused) }); + let tile = linked(&mut doc); + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Swatch only", vec![Arc::new(tile)]) + }); + let swatch = Swatch { + name: "Swatch only".into(), + paint: Paint::Pattern { pattern: "Swatch only".into(), xf: Affine::IDENTITY }, + global: false, + spot: false, + }; + swatches_plus(&mut doc, vec![swatch]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let symbol = back.symbols.iter().find(|s| s.name == "Unused").and_then(|s| mask_art_in(&s.art)).expect("the unused symbol's mask art"); + assert!(symbol.extras().graphic_style.is_some(), "the symbol's mask art keeps its link"); + let tile = back.pattern("Swatch only").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("the swatch's mask art"); + assert!(tile.extras().graphic_style.is_some(), "the pattern's mask art keeps its link"); +} + /// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to /// a graphic style. #[test] @@ -10455,7 +10764,8 @@ fn an_envelope_is_written_in_the_order_the_reader_reads_it() { } /// Envelopes the reader would open in another shape, or not at all, are written as their distorted -/// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn +/// art with a warning, and open as that art: an envelope of type with text wrap options, one of +/// mirrored type (written as its outlines), one in a turned frame, a warp drawn /// without its style's net, a mesh the reader would lay out the other way round, a top object /// with a side of more segments than the grid has columns, a Fidelity that isn't a whole number and bounds margins kept with /// Distort Appearance off. Other live objects keep their own warning. @@ -10470,10 +10780,24 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { f(&mut doc.node_mut(id).unwrap().kind); doc }; - let typed = change(&|k| { + let type_node = |id: u64| { + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "Type", vectorcraft_doc::CharStyle::default()); + Node::new(vectorcraft_doc::NodeId(id), NodeKind::Text(Box::new(t))) + }; + let wrapped_type = change(&|k| { if let NodeKind::Envelope { content, .. } = k { - let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "Type", vectorcraft_doc::CharStyle::default()); - content.push(Arc::new(Node::new(vectorcraft_doc::NodeId(9_000), NodeKind::Text(Box::new(t))))); + let mut t = type_node(9_000); + t.extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset: 6.0, invert: false }); + content.push(Arc::new(t)); + } + }); + let mirrored_type = change(&|k| { + if let NodeKind::Envelope { content, .. } = k { + let mut t = type_node(9_001); + if let NodeKind::Text(t) = &mut t.kind { + t.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 50.0, 50.0]); + } + content.push(Arc::new(t)); } }); let turned = change(&|k| { @@ -10512,7 +10836,8 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { } }); let cases = [ - ("type", typed), + ("type with text wrap options", wrapped_type), + ("mirrored type, written as its outlines", mirrored_type), ("turned", turned), ("old warp", old_warp), ("sideways mesh", sideways), @@ -10537,6 +10862,91 @@ fn transpose(points: Vec, rows: usize, cols: usize) -> Vec Node { + let mut envelope = envelope_node(doc, kind, 50.0, editing, vectorcraft_doc::live::EnvelopeOptions::NEW); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(104.0, 62.0), "Type", style(24.0, Color::rgb(0.0, 0.0, 1.0))); + let text = text_node(doc, t); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(text)); + envelope +} + +/// A 200 × 100 document of `envelope` alone. +fn alone(envelope: Node) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + doc +} + +/// `doc` written, then read back through its editing copy beside a page that draws as `doc` does, +/// its live objects as their art: the objects on its first layer, the writer's warnings and the +/// reader's notes. +fn read_with_page(doc: &Document) -> (Vec>, Vec, Vec) { + let (p, warnings) = private_data(doc, &opts()).unwrap(); + let mut page = doc.clone(); + let ids: Vec = page.layers[0].children().into_iter().flatten().map(|n| n.id).collect(); + for id in ids { + if let Some(n) = page.node_mut(id) { + *n = vectorcraft_effects::expand_live_deep(Some(doc), n); + } + } + let (read, notes, layers) = crate::layered_ai(&p.data, page, vec![], false, false, 1.0); + assert!(layers, "{notes:?}"); + (read.layers[0].children().cloned().unwrap_or_default(), warnings, notes) +} + +/// The type in `nodes` and their groups, with whether it shows. +fn type_in(nodes: &[Arc]) -> Vec<(String, bool)> { + let mut out = vec![]; + for n in nodes { + match &n.kind { + NodeKind::Text(t) => out.push((t.plain_text(), n.visible)), + NodeKind::Group { children, .. } => out.extend(type_in(children)), + _ => {} + } + } + out +} + +/// An envelope whose contents hold type keeps the type as type: a warp, a mesh and a top object +/// envelope, with Edit Contents off and on, open as the same live envelope with the type shown +/// and editable in its contents beside the rest of them, no copy of the type left on the layer, +/// and again when saved as they opened. +#[test] +fn an_envelope_of_type_is_read_back_live_with_its_type() { + for (name, kind) in live_envelope_kinds() { + for editing in [false, true] { + let what = format!("{name}, editing {editing}"); + let mut doc = Document::new(200.0, 100.0); + let mut envelope = typed_envelope_node(&mut doc, kind.clone(), editing); + for round in ["saved", "saved again"] { + doc = alone(envelope); + let (top, warnings, notes) = read_with_page(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES || w == writer::TYPE), "{what}, {round}: {warnings:?}"); + assert!( + !notes.iter().any(|n| n == crate::import::ENVELOPE_TYPE_DRAWN || n == crate::import::ENVELOPE_DRAWN), + "{what}, {round}: {notes:?}" + ); + let [back] = top.as_slice() else { panic!("{what}, {round}: {top:#?} {notes:?}") }; + let NodeKind::Envelope { content, kind: got, editing: e, fidelity, .. } = &back.kind else { + panic!("{what}, {round}: {back:#?} {notes:?}") + }; + assert_eq!((*e, *fidelity), (editing, 50.0), "{what}, {round}"); + assert!(same_envelope_kind(&kind, got), "{what}, {round}: {got:?}"); + assert_eq!(back.name.as_deref(), Some("Bent"), "{what}, {round}"); + assert_eq!(type_in(content), [("Type".to_string(), true)], "{what}, {round}"); + let colors: Vec> = + content.iter().filter(|c| !matches!(c.kind, NodeKind::Text(_))).map(|c| c.appearance.fill_paint().color()).collect(); + assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}, {round}"); + envelope = (**back).clone(); + } + } + } +} + /// A top object over (30, 10)-(170, 90) whose top side is `top` straight segments, falling and /// rising by 10 pt between its anchors, and whose other sides are one straight segment each. fn uneven_top_object(top: usize) -> kurbo::BezPath { @@ -10610,6 +11020,86 @@ fn a_top_object_with_uneven_sides_is_read_back_live() { } } +/// An envelope of type is written live with room for every object the reader reads of it, the +/// copy of its type it reads in the envelope's dictionary included. Written as its art, it takes +/// its type's story out of the text document with it: where its record doesn't fit within the +/// object limit, and where other type in its contents is written as its outlines (mirrored type). +/// The file then has no text document. +#[test] +fn an_envelope_of_type_written_as_its_art_leaves_no_story() { + use vectorcraft_doc::live::EnvelopeKind; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + for editing in [false, true] { + let mut doc = Document::new(200.0, 100.0); + let envelope = typed_envelope_node(&mut doc, kind.clone(), editing); + let doc = alone(envelope); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let all = String::from_utf8_lossy(&program).replace("\r\n", "\n"); + assert!(all.contains("AIDeformOptionsKey") && all.contains("%AI11_BeginTextDocument"), "editing {editing}: {all}"); + let read = crate::import::objects_read(&program).unwrap(); + let out = export_within(&doc, &opts(), read).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "editing {editing}: {:?}", out.warnings); + let out = export_within(&doc, &opts(), read - 1).unwrap(); + let short = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "editing {editing}: {:?}", out.warnings); + assert!(!short.contains("AIDeformOptionsKey") && !short.contains("%AI11_BeginTextDocument"), "editing {editing}: {short}"); + } + + let mut doc = Document::new(200.0, 100.0); + let mut envelope = typed_envelope_node(&mut doc, kind, false); + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 40.0), "Back", style(12.0, Color::BLACK)); + t.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 50.0, 40.0]); + let mirrored = text_node(&mut doc, t); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(mirrored)); + let out = export_ai_editing(&alone(envelope), &opts()).unwrap(); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(out.warnings.iter().any(|w| w == writer::ENVELOPES) && !out.warnings.iter().any(|w| w == writer::TYPE), "{:?}", out.warnings); + assert!(!text.contains("AIDeformOptionsKey") && !text.contains("%AI11_BeginTextDocument"), "{text}"); +} + +/// Type in an envelope that is a symbol's art, or a frame of a thread, keeps the envelope written +/// as its art: the reader would open its type as drawn art. +#[test] +fn an_envelope_of_type_in_a_symbol_or_a_thread_is_written_as_its_art() { + use vectorcraft_doc::live::EnvelopeKind; + use vectorcraft_doc::{TextKind, TextObject}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let mut doc = Document::new(200.0, 100.0); + let art = typed_envelope_node(&mut doc, kind.clone(), false); + doc.symbols.push(Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); + let placed = instance(&mut doc, "Bent", Affine::translate((10.0, 5.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, placed).unwrap(); + let (p, warnings) = private_data(&doc, &opts()).unwrap(); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let (back, notes) = crate::ai_alone(&p.data).unwrap(); + let symbol = back.symbols.iter().find(|s| s.name == "Bent").unwrap_or_else(|| panic!("{:?} {notes:?}", back.symbols)); + let mut live = 0; + symbol.art.walk(&mut |n| live += usize::from(matches!(n.kind, NodeKind::Envelope { .. }))); + assert_eq!(live, 0, "{notes:?}"); + + let mut doc = Document::new(200.0, 100.0); + let mut envelope = envelope_node(&mut doc, kind, 50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW); + let area = |r: Rect, s: &str| { + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, s, style(10.0, Color::BLACK)); + (t.kind, t.xf) = (TextKind::Area { frame: shapes::rectangle(r) }, Affine::IDENTITY); + t + }; + let first = text_node(&mut doc, area(Rect::new(50.0, 30.0, 150.0, 70.0), "One two three four")); + let second = text_node(&mut doc, area(Rect::new(10.0, 85.0, 190.0, 95.0), "")); + doc.text_threads.push(vec![first.id, second.id]); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(first)); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, envelope).unwrap(); + doc.insert(Some(layer), usize::MAX, second).unwrap(); + let text = program(&doc); + assert!(!text.contains("AIDeformOptionsKey"), "{text}"); + let warnings = export_ai_editing(&doc, &opts()).unwrap().warnings; + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); +} + /// A top object with a top side of 50 segments over a bottom side of one is written live with a grid /// of 50 columns, the most the reader reads. #[test] @@ -10904,6 +11394,42 @@ fn text_wrap_options_of_type_and_envelopes_are_read_back() { same_wraps(&wraps(top), &[(Some("Bent".into()), 2.5, true)]); } +/// The wrap shapes that `doc`'s first area type on its first layer takes from the objects above it, +/// in the type's own space: the bounds of each one's subpaths, and its options. +fn area_wrap_region(doc: &Document) -> Vec<(Vec<[f64; 4]>, f64, bool)> { + use vectorcraft_doc::TextKind; + let mut doc = doc.clone(); + vectorcraft_doc::text::resolve_wrap(None, &mut doc); + let top = doc.layers[0].children().unwrap(); + let Some(NodeKind::Text(t)) = top.iter().map(|n| &n.kind).find(|k| matches!(k, NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }))) + else { + panic!("no area type"); + }; + t.wrap + .iter() + .map(|s| { + let parts = s + .path + .subpaths + .iter() + .map(|sp| { + let b = PathData::new(vec![sp.clone()]).bounds().unwrap_or_default(); + [b.x0, b.y0, b.x1, b.y1] + }) + .collect(); + (parts, s.wrap.offset, s.wrap.invert) + }) + .collect() +} + +/// `before` and `after` hold as many wrap shapes, each with as many subpaths, with bounds within +/// `1e-3`, and the same options. +fn same_region(before: &[(Vec<[f64; 4]>, f64, bool)], after: &[(Vec<[f64; 4]>, f64, bool)]) -> bool { + let close = + |b: &[[f64; 4]], a: &[[f64; 4]]| b.len() == a.len() && b.iter().zip(a).all(|(b, a)| b.iter().zip(a).all(|(x, y)| (x - y).abs() < 1e-3)); + before.len() == after.len() && before.iter().zip(after).all(|(b, a)| close(&b.0, &a.0) && (b.1 - a.1).abs() < 1e-6 && b.2 == a.2) +} + /// Type written as its outlines wraps area type below it by its bounds, as it did before saving, /// not by its glyphs: its text wrap options go on an unpainted rectangle of its bounds, the last /// member of the outlines' group, which keeps the type's name and has no options of its own. @@ -10994,6 +11520,276 @@ fn type_written_as_outlines_keeps_wrapping_by_its_bounds() { assert_eq!(top[1].name.as_deref(), Some("Label"), "{text_out}"); } +/// A 300 × 200 document of area type "Body" filling the page, with `top` above it. +fn over_area_type(make: impl FnOnce(&mut Document) -> Node) -> Document { + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut body = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK)); + body.runs = vec![TextRun::new("one two three four five six seven eight nine ten eleven twelve", style(10.0, Color::BLACK))]; + body.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 290.0, 190.0)) }; + body.xf = Affine::IDENTITY; + let body = named(text_node(&mut doc, body), "Body"); + let top = make(&mut doc); + for n in [body, top] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + doc +} + +/// Symbol "Leaf": two squares in opposite corners of the 20 × 20 square around the origin, apart. +fn two_leaves(doc: &mut Document) { + let a = square(doc, Rect::new(-10.0, -10.0, -6.0, -6.0), LEAF); + let b = square(doc, Rect::new(6.0, 6.0, 10.0, 10.0), LEAF); + let art = group(doc, vec![a, b], false); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); +} + +/// A symbol instance written as its art (its isolation is a group's), a blend and an intertwine, +/// each written as a group of its art, wrap area type below them by their bounds after reopening, +/// as they did before saving, not by the members of that art: the text wrap options go on an +/// unpainted rectangle of the bounds, the group's first member, and the group, which keeps the +/// object's name, isolation, lock and visibility, has none. A positive offset, a negative offset +/// with Invert Wrap, and hidden and locked. +#[test] +fn live_art_written_as_a_group_keeps_wrapping_by_its_bounds() { + let instance_of = |offset: f64, invert: bool, visible: bool, locked: bool| { + move |doc: &mut Document| { + two_leaves(doc); + let mut n = instance(doc, "Leaf", Affine::new([3.0, 0.0, 0.0, 3.0, 150.0, 100.0])); + n.isolate = true; + (n.visible, n.locked) = (visible, locked); + wrapped(named(n, "Thing"), offset, invert) + } + }; + let blend = |doc: &mut Document| { + let keys = vec![ + Arc::new(square(doc, Rect::new(60.0, 80.0, 70.0, 90.0), Color::gray(0.5))), + Arc::new(square(doc, Rect::new(220.0, 110.0, 230.0, 120.0), Color::gray(0.5))), + ]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(60.0), ..Default::default() }; + wrapped(named(Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }), "Thing"), 4.5, false) + }; + let intertwine = |doc: &mut Document| { + let a = Arc::new(square(doc, Rect::new(60.0, 60.0, 80.0, 80.0), Color::gray(0.5))); + let b = Arc::new(square(doc, Rect::new(200.0, 120.0, 220.0, 140.0), Color::gray(0.5))); + wrapped(named(Node::new(doc.alloc_id(), NodeKind::Intertwine { children: vec![a, b] }), "Thing"), 2.0, true) + }; + let repeat = |doc: &mut Document| { + let a = Arc::new(square(doc, Rect::new(100.0, 80.0, 110.0, 90.0), Color::gray(0.5))); + let spec = vectorcraft_doc::RepeatSpec::grid(vec![a], 8.0, 12.0); + wrapped(named(Node::new(doc.alloc_id(), NodeKind::Repeat(spec)), "Thing"), 1.5, false) + }; + // A Fidelity that isn't a whole number: written as its distorted art. + let envelope = |doc: &mut Document| { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + wrapped(named(envelope_node(doc, kind, 50.5, false, EnvelopeOptions::NEW), "Thing"), -3.0, true) + }; + let docs = [ + ("instance", over_area_type(instance_of(4.5, false, true, false)), 4.5, false, true, false), + ("instance, inverted", over_area_type(instance_of(-2.0, true, true, false)), -2.0, true, true, false), + ("instance, hidden and locked", over_area_type(instance_of(3.0, false, false, true)), 3.0, false, false, true), + ("blend", over_area_type(blend), 4.5, false, true, false), + ("intertwine", over_area_type(intertwine), 2.0, true, true, false), + ("repeat", over_area_type(repeat), 1.5, false, true, false), + ("envelope", over_area_type(envelope), -3.0, true, true, false), + ]; + for (name, doc, offset, invert, visible, locked) in docs { + let thing = &doc.layers[0].children().unwrap()[1]; + let want = thing.geometric_bounds().unwrap(); + let text_out = program(&doc); + let back = read_back(&doc); + let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back)); + assert_eq!(before.len(), usize::from(visible), "{name}"); + assert!(same_region(&before, &after), "{name}\n{before:?}\n{after:?}\n{text_out}"); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{name}: {text_out}"); + let top = back.layers[0].children().unwrap(); + same_wraps(&wraps(top), &[(None, offset, invert)]); + let g = &top[1]; + assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{name}: {text_out}"); + assert_eq!( + (g.name.as_deref(), g.extras().wrap, g.visible, g.locked, g.isolate), + (Some("Thing"), None, visible, locked, thing.isolate), + "{name}" + ); + let first = &g.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Path { guide: false, .. }) && first.appearance.items.is_empty(), "{name}: {text_out}"); + assert_eq!((first.visible, first.locked), (visible, locked), "{name}"); + let b = first.geometric_bounds().unwrap(); + assert!([b.x0 - want.x0, b.y0 - want.y0, b.x1 - want.x1, b.y1 - want.y1].iter().all(|d| d.abs() < 1e-3), "{name}: {b:?} {want:?}"); + } + + // An envelope written live opens live and wraps by its bounds, its options its own. + let live = |doc: &mut Document| { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + wrapped(named(envelope_node(doc, kind, 50.0, false, EnvelopeOptions::NEW), "Thing"), -3.0, true) + }; + let doc = over_area_type(live); + let text_out = program(&doc); + let back = read_back(&doc); + let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back)); + assert_eq!(before.len(), 1); + assert!(same_region(&before, &after), "{before:?}\n{after:?}\n{text_out}"); + let top = back.layers[0].children().unwrap(); + assert!(matches!(top[1].kind, NodeKind::Envelope { .. }), "{text_out}"); + same_wraps(&wraps(top), &[(Some("Thing".into()), -3.0, true)]); +} + +/// The rectangle that keeps a blend written as its art wrapping by its bounds counts toward the +/// object limit, ahead of the art: one object short, the last step is left out with the limit's +/// warning; without room for the rectangle, the options are left out with the warning, and the +/// group does not take them. In an instance whose art holds area type, shown, hidden, in a hidden +/// group or written as its outlines, the rectangle is the group's first member, below that type: +/// area type below the instance wraps by its bounds after reopening, and the type in its art is not +/// wrapped by them. +#[test] +fn wrap_bounds_of_live_art_fit_the_object_limit_and_leave_area_type_in_the_art_unwrapped() { + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + // A blend alone, without a symbol definition, so the objects are the group, the rectangle and + // the steps. + let mut doc = Document::new(300.0, 200.0); + let keys = vec![ + Arc::new(square(&mut doc, Rect::new(60.0, 80.0, 70.0, 90.0), Color::gray(0.5))), + Arc::new(square(&mut doc, Rect::new(220.0, 110.0, 230.0, 120.0), Color::gray(0.5))), + ]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(60.0), ..Default::default() }; + let thing = wrapped(named(Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }), "Thing"), 4.5, false); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, thing).unwrap(); + let steps = export_ai_editing(&doc, &opts()).unwrap(); + let steps = crate::ai_alone(&steps.program).unwrap().0.layers[0].children().unwrap()[0].children().unwrap().len() - 1; + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let all = export_within(&doc, &opts(), read).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings); + let out = export_within(&doc, &opts(), read - 1).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + let text_out = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}"); + let (back, _) = crate::ai_alone(&out.program).unwrap(); + let top = back.layers[0].children().unwrap(); + same_wraps(&wraps(top), &[(None, 4.5, false)]); + assert_eq!((top[0].name.as_deref(), top[0].extras().wrap), (Some("Thing"), None), "{text_out}"); + let members = top[0].children().unwrap(); + assert_eq!(members.len(), steps, "the rectangle and all but the last step: {text_out}"); + assert!(matches!(members[0].kind, NodeKind::Path { guide: false, .. }) && members[0].appearance.items.is_empty(), "{text_out}"); + let out = export_within(&doc, &opts(), read - steps - 1).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + let text_out = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(!text_out.contains("(AI11TextWrapOffset)"), "{text_out}"); + let (back, _) = crate::ai_alone(&out.program).unwrap(); + assert!(wraps(back.layers[0].children().unwrap()).is_empty(), "{text_out}"); + + // Area type in the symbol's art, shown or hidden, of `words`, in a hidden group with `in_hidden`. + let make = |shown: bool, words: &'static str, in_hidden: bool| { + move |doc: &mut Document| { + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(2.0, Color::BLACK)); + t.runs = vec![TextRun::new(words, style(2.0, Color::BLACK))]; + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(-10.0, -10.0, -6.0, -6.0)) }; + t.xf = Affine::IDENTITY; + let mut t = text_node(doc, t); + t.visible = shown; + if in_hidden { + t = group(doc, vec![t], false); + t.visible = false; + } + let s = square(doc, Rect::new(6.0, 6.0, 10.0, 10.0), LEAF); + let art = group(doc, vec![t, s], false); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + let mut n = instance(doc, "Leaf", Affine::new([3.0, 0.0, 0.0, 3.0, 150.0, 100.0])); + n.isolate = true; + wrapped(named(n, "Thing"), 4.5, false) + } + }; + // Type of spaces alone is written as its outlines, which are not type. + for (what, shown, words, in_hidden) in + [("shown", true, "leaf", false), ("hidden", false, "leaf", false), ("in a hidden group", true, "leaf", true), ("spaces", true, " ", false)] + { + let doc = over_area_type(make(shown, words, in_hidden)); + let text_out = program(&doc); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{what}: {text_out}"); + let back = read_back(&doc); + let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back)); + assert_eq!(before.len(), 1, "{what}"); + assert!(same_region(&before, &after), "{what}\n{before:?}\n{after:?}\n{text_out}"); + let g = &back.layers[0].children().unwrap()[1]; + assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{what}: {text_out}"); + assert_eq!(g.extras().wrap, None, "{what}: {text_out}"); + let first = &g.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Path { guide: false, .. }) && first.appearance.items.is_empty(), "{what}: {text_out}"); + let mut resolved = back.clone(); + vectorcraft_doc::text::resolve_wrap(None, &mut resolved); + let mut inner = vec![]; + resolved.layers[0].children().unwrap()[1].walk(&mut |m| { + if let NodeKind::Text(t) = &m.kind { + inner.push((matches!(t.kind, TextKind::Area { .. }), m.visible, t.wrap.len())); + } + }); + let want: &[(bool, bool, usize)] = match what { + "shown" | "in a hidden group" => &[(true, true, 0)], + "hidden" => &[(true, false, 0)], + _ => &[], + }; + assert_eq!(inner, want, "{what}: {text_out}"); + } +} + +/// A blend with no keys, written as an empty group, has no bounds for a rectangle: its group keeps +/// the text wrap options, and they read back. +#[test] +fn live_art_without_bounds_keeps_its_text_wrap_on_the_group() { + let mut doc = Document::new(300.0, 200.0); + let spec = vectorcraft_doc::BlendSpec::default(); + let thing = wrapped(named(Node::new(doc.alloc_id(), NodeKind::Blend { children: vec![], spec: Box::new(spec) }), "Thing"), 4.5, true); + assert_eq!(thing.geometric_bounds(), None); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, thing).unwrap(); + let text_out = program(&doc); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}"); + let back = read_back(&doc); + same_wraps(&wraps(back.layers[0].children().unwrap()), &[(Some("Thing".into()), 4.5, true)]); +} + +/// A symbol instance written as its art, whose art holds an envelope of area type beside a square, +/// wraps area type below it by its bounds after reopening. The envelope there is written as its art, +/// after the type written for it as a live envelope is taken back: the group has no text wrap +/// options, and its first member is the rectangle of the bounds. +#[test] +fn an_envelope_of_area_type_in_written_art_leaves_the_wrap_on_the_bounds() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + let doc = over_area_type(|doc| { + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let mut envelope = envelope_node(doc, kind, 50.0, false, EnvelopeOptions::NEW); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK)); + t.runs = vec![TextRun::new("bent", style(10.0, Color::BLACK))]; + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(60.0, 30.0, 140.0, 70.0)) }; + t.xf = Affine::IDENTITY; + let t = text_node(doc, t); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(t)); + let s = square(doc, Rect::new(250.0, 150.0, 260.0, 160.0), LEAF); + let art = group(doc, vec![envelope, s], false); + doc.symbols.push(Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); + let mut n = instance(doc, "Bent", Affine::IDENTITY); + n.isolate = true; + wrapped(named(n, "Thing"), 4.5, false) + }); + let text_out = program(&doc); + let back = read_back(&doc); + let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back)); + assert_eq!(before.len(), 1); + assert!(same_region(&before, &after), "{before:?}\n{after:?}\n{text_out}"); + let g = &back.layers[0].children().unwrap()[1]; + assert_eq!(g.extras().wrap, None, "{text_out}"); + let mut live = 0; + g.walk(&mut |m| live += usize::from(matches!(m.kind, NodeKind::Envelope { .. }))); + assert_eq!(live, 0, "{text_out}"); + let first = &g.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Path { guide: false, .. }) && first.appearance.items.is_empty(), "{text_out}"); +} + /// A clipping path inside art cut to a clipping object is written as its fills and its strokes, /// two objects: its text wrap options are written once, whether it has both or only strokes, and /// with its strokes when the region outside leaves nothing of its fills. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 15bd97d3b5..1b48697e32 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -28,7 +28,7 @@ mod variables; pub(super) use brushes::BRUSHES; #[cfg(test)] pub(super) use envelopes::ENVELOPES; -pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; +pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_definitions, styled_objects}; #[cfg(test)] pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, WARPED_IMAGES, same_appearance}; @@ -133,6 +133,10 @@ struct Charges { kept_objects: usize, kept_symbol_nodes: u64, left_out: usize, + /// The objects written with the graphic style they are linked to, and those of them in the + /// definitions written. + links: usize, + kept_links: usize, } pub(super) struct Writer<'a> { @@ -171,6 +175,8 @@ pub(super) struct Writer<'a> { definition_objects: usize, /// The objects of symbol art in those definitions ([`Self::symbol_nodes`]). definition_symbol_nodes: u64, + /// The objects in those definitions written with the graphic style they are linked to. + definition_links: usize, /// The most objects written ([`Self::take_objects`]): the most the reader reads, /// [`crate::import::MAX_EDITING_OBJECTS`]. pub(super) max_objects: usize, @@ -204,6 +210,12 @@ pub(super) struct Writer<'a> { /// The `hidden` and `nest` values at which the objects of the opacity mask's art being written /// are written: objects there may be written with their art styles. mask_level: Option<(u32, u32)>, + /// The `hidden` and `nest` values at which the objects of the envelope contents being written + /// are written ([`Self::envelope`]): type there may be written as type. With them, whether the + /// contents are in the envelope's dictionary, where the reader reads a copy of each text object. + contents_level: Option<(u32, u32, bool)>, + /// How many text objects were written as their outlines ([`Self::text`]). + outlined: usize, /// The image just written was written as a placed file. placed: bool, /// The image being written is the copy of a styled object on the layers, on `%_` lines: linked, @@ -262,6 +274,12 @@ pub(super) struct Writer<'a> { /// The objects that may be written with an art style, as they were before baking, by id /// ([`styles::styled_objects`]). pub(super) styled: HashMap>, + /// The objects of the opacity masks' art in each symbol's art and each pattern's tile art that + /// may be written with an art style, by the symbol's or pattern's name + /// ([`styles::styled_definitions`]). [`Self::define_symbol`] and [`Self::define_pattern`] write + /// a definition's art with its own as [`Self::styled`]. + pub(super) symbol_styled: HashMap, + pub(super) pattern_styled: HashMap, /// The art styles written ([`styles`]). styles: styles::Styles, /// An object's drawn look is being written: no object in it is written with a style, and its @@ -271,7 +289,8 @@ pub(super) struct Writer<'a> { /// brushed stroke's pieces) the art being written is in. Text wrap options are written only /// outside them, since only the document's own objects wrap type. drawn: u32, - /// The type just written as outlines has its text wrap options on the rectangle of its bounds + /// The type just written as outlines, or the symbol instance or live object just written as a + /// group of its art, has its text wrap options on the rectangle of its bounds /// ([`Self::wrap_bounds`]), not in the group's dictionary. wrap_moved: bool, /// The brushes written ([`brushes`]). @@ -322,6 +341,8 @@ impl<'a> Writer<'a> { payloads: vec![], hidden: 0, mask_level: None, + contents_level: None, + outlined: 0, placed: false, styled_copy: false, typed: false, @@ -357,6 +378,8 @@ impl<'a> Writer<'a> { symbol_scales: HashMap::new(), root_entries: None, styled: HashMap::new(), + symbol_styled: HashMap::new(), + pattern_styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, drawn: 0, @@ -368,6 +391,7 @@ impl<'a> Writer<'a> { objects: 0, left_out: 0, definition_objects: 0, + definition_links: 0, definition_symbol_nodes: 0, max_objects: crate::import::MAX_EDITING_OBJECTS, last_samples: None, @@ -426,6 +450,8 @@ impl<'a> Writer<'a> { kept_objects: self.definition_objects, kept_symbol_nodes: self.definition_symbol_nodes, left_out: self.left_out, + links: self.styles.links_written, + kept_links: self.definition_links, } } @@ -439,15 +465,18 @@ impl<'a> Writer<'a> { fn keep_since(&mut self, c: Charges) { self.definition_objects = c.kept_objects + (self.objects - c.objects); self.definition_symbol_nodes = c.kept_symbol_nodes + (self.symbol_nodes - c.symbol_nodes); + self.definition_links = c.kept_links + (self.styles.links_written - c.links); } - /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are - /// given back, except those of the definitions written since, and the images `lines` marks + /// Art written since `c` as `lines` is not used: the objects and symbol art charged since, and + /// the objects counted as written with their graphic style, are given back, except those of the + /// definitions written since, and the images `lines` marks /// are emptied (the others keep their indices). An image whose whole copy is among them is no /// longer written whole ([`Self::whole_images`]). fn give_back(&mut self, c: Charges, lines: &str) { self.objects = c.objects + (self.definition_objects - c.kept_objects); self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + self.styles.links_written = c.links + (self.definition_links - c.kept_links); let mut emptied = HashSet::new(); for index in lines.split(PAYLOAD).skip(1).step_by(2) { if let Some((i, p)) = index.parse::().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) { @@ -667,12 +696,11 @@ impl<'a> Writer<'a> { if n.extras().graph.is_some() && !self.in_look && graph.is_none() { self.warn(GRAPHS); } - // On the layers or in an opacity mask's art, outside art cut to a clipping object: with its - // art style, when it has one. + // On the layers or in an opacity mask's art (a symbol's and a pattern tile's included), + // outside art cut to a clipping object: with its art style, when it has one. if !self.in_look - && ((self.hidden == 0 && self.nest == 0) || self.mask_level == Some((self.hidden, self.nest))) + && ((self.hidden == 0 && self.nest == 0 && !self.in_symbol) || self.mask_level == Some((self.hidden, self.nest))) && self.clip.is_none() - && !self.in_symbol && let Some(orig) = self.styled.get(&n.id).cloned() && self.check_left(&orig) && let Some(name) = self.object_style(n, &orig) @@ -746,6 +774,8 @@ impl<'a> Writer<'a> { if !self.open_group("u") { return; } + let state = self.state; + let moved = self.wrap_on_bounds(n, state); // The canvas places an instance inside a symbol's art where the outer instance puts // it (9-slice guides, stains and patterns included), and this art doesn't move with it. self.wrote_fixed |= self.in_symbol; @@ -790,12 +820,13 @@ impl<'a> Writer<'a> { self.warn(SYMBOL_LIMIT); } } + self.wrap_moved = moved; self.end_group("U", xy); } NodeKind::Envelope { .. } if self.envelope(n, xy) => {} - NodeKind::Envelope { .. } => self.drawn_art(n, xy, Some(envelopes::ENVELOPES)), + NodeKind::Envelope { .. } => self.drawn_art(n, xy, Some(envelopes::ENVELOPES), true), NodeKind::Blend { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { - self.drawn_art(n, xy, Some(LIVE)); + self.drawn_art(n, xy, Some(LIVE), true); } // Ruler guides are kept in the document's native copy only. NodeKind::RulerGuide { .. } => {} @@ -862,13 +893,17 @@ impl<'a> Writer<'a> { } /// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given. - fn drawn_art(&mut self, n: &Node, xy: Xy, warning: Option<&str>) { + /// With `bounds`, `n`'s text wrap options go on the rectangle of its bounds, the group's first + /// member ([`Self::wrap_on_bounds`]); without it, in the group's dictionary. + fn drawn_art(&mut self, n: &Node, xy: Xy, warning: Option<&str>, bounds: bool) { if !self.open_group("u") { return; } + let state = self.state; if let Some(w) = warning { self.warn(w); } + let moved = bounds && self.wrap_on_bounds(n, state); if self.nest < MAX_NEST { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); self.nest += 1; @@ -879,9 +914,29 @@ impl<'a> Writer<'a> { self.drawn -= 1; self.nest -= 1; } + self.wrap_moved = moved; self.end_group("U", xy); } + /// In the group that object `n` is written as, when the group's dictionary would hold `n`'s text + /// wrap options: the rectangle of `n`'s bounds with them ([`Self::wrap_bounds`]), then lock and + /// hidden state `state` again. True when the group's dictionary is to leave the options out + /// ([`Self::wrap_moved`]), so the group wraps type by `n`'s bounds, as `n` does + /// ([`vectorcraft_doc::text::resolve_wrap`]), not by its members. Written before the group's + /// members, the rectangle is below any area type among them and does not wrap it. False, + /// writing nothing, when `n` has no finite bounds, and the group's dictionary keeps the options. + fn wrap_on_bounds(&mut self, n: &Node, state: Option<(bool, bool)>) -> bool { + let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) else { return false }; + if finite_bounds(n).is_none() { + return false; + } + self.wrap_bounds(n, w); + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } + true + } + /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), /// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an /// empty `AIArtName`), has text wrap options ([`Self::text_wrap`]), has an opacity mask @@ -1414,9 +1469,10 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the - /// text document can hold it, outside opacity masks, pattern tiles, symbols' art and art cut to - /// a clipping object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type - /// names the story of its thread, which its first frame holds. + /// text document can hold it, on the layers or in an envelope's contents, outside opacity + /// masks, pattern tiles, symbols' art and art cut to a clipping object; else its outlines + /// ([`Self::outlined_text`]). A frame of threaded area type names the story of its thread, + /// which its first frame holds. fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; // Dynamic Text is written as the sizes its layout draws the type at. @@ -1424,13 +1480,16 @@ impl<'a> Writer<'a> { let t = fitted.as_deref().unwrap_or(&**t); let doc = self.doc; let thread = doc.text_threads.iter().find(|th| th.contains(&n.id)); + let contents = self.contents_level.filter(|(hidden, nest, _)| (*hidden, *nest) == (self.hidden, self.nest)); // Type that can't be a stub is written as its outlines, which count their own objects. - if self.hidden > 0 || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) { + if (self.hidden > 0 && contents.is_none()) || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) { + self.outlined += 1; return self.outlined_text(n, xy); } - // The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]); without room for - // them, the type is left out before its story goes into the text document. - let stub = texts::stub_objects(t); + // The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]), and the copy the + // reader reads of it in an envelope's dictionary; without room for them, the type is left + // out before its story goes into the text document. + let stub = texts::stub_objects(t) + usize::from(contents.is_some_and(|(.., copied)| copied)); if !self.take_objects(stub) { return; } @@ -1444,6 +1503,7 @@ impl<'a> Writer<'a> { let Some(((story, frame), lost)) = placed else { // The text document can't hold it: the outlines count their own objects. self.objects -= stub; + self.outlined += 1; return self.outlined_text(n, xy); }; self.typed = true; @@ -1523,23 +1583,20 @@ impl<'a> Writer<'a> { for c in over { self.node(c); } - // Type wraps by its bounds: its text wrap options go on a rectangle of its bounds, the - // group's last member, and the group's dictionary leaves them out. - if let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) { - self.wrap_bounds(n, w); - self.wrap_moved = true; - } + // Type wraps by its bounds. + self.wrap_moved = self.wrap_on_bounds(n, state); if let Some((locked, hidden)) = state { self.state(locked, hidden); } self.end_group("U", xy); } - /// An unpainted closed rectangle at the geometric bounds of type `n`, the region its text wraps - /// around, with `n`'s lock and hidden state and text wrap options `w` in its dictionary. Nothing + /// An unpainted closed rectangle at the geometric bounds of type, a symbol instance or a live + /// object `n`, the region its text wraps around ([`vectorcraft_doc::text::resolve_wrap`]), with + /// `n`'s lock and hidden state and text wrap options `w` in its dictionary. Nothing /// when `n` has no finite bounds, or without room for it ([`Self::take_objects`]). fn wrap_bounds(&mut self, n: &Node, w: TextWrap) { - let Some(b) = n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return }; + let Some(b) = finite_bounds(n) else { return }; self.state(n.locked, !n.visible); let objects = self.objects; self.plain_object(&shapes::rectangle(b), FillRule::NonZero, None, None, Xy::PLAIN); @@ -1728,6 +1785,11 @@ impl<'a> Writer<'a> { } } +/// `n`'s geometric bounds, when they are finite. +fn finite_bounds(n: &Node) -> Option { + n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) +} + /// The gradient of paint `p`, when it has one. fn gradient(p: Option<&Paint>) -> Option<&vectorcraft_color::GradientPaint> { match p { diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index cae8993827..09c6a33550 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -20,14 +20,14 @@ use vectorcraft_doc::live::{ Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes, envelope_grid, envelope_stroke_margin, warp_grid, }; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::{Affine, PathData}; use super::{MAX_NEST, Writer, Xy, hide}; use crate::import::{DEFORM_PLUGIN, EnvelopeGridPoint as GridPoint, envelope_outline, envelope_upright, warp_number}; use crate::ps; -pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes of type, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256 envelopes and images with a Warp, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; +pub(in crate::ai_editing) const ENVELOPES: &str = "envelopes whose type is threaded, has text wrap options or is written as its outlines, envelopes without contents, envelopes in opacity masks, pattern tiles, art cut to a clipping object or other envelopes' contents, envelopes on the layers of a large canvas or in a turned or mirrored frame, warps drawn without their style's net, meshes and top objects that the data would open in another shape, envelopes with a Fidelity that isn't a whole number, envelopes that map from a box other than their contents' bounds with or without strokes or keep bounds margins with Distort Appearance off, envelopes after the first 256 envelopes and images with a Warp, and envelopes nested more than 8 deep are written as their art in the editing data for other apps"; /// The most rows or columns of an envelope's grid written (Make with Mesh's own limit, and the /// reader's). @@ -58,6 +58,15 @@ fn holds_type(n: &Node) -> bool { matches!(n.kind, NodeKind::Text(_)) || n.children().is_some_and(|c| c.iter().any(|k| holds_type(k))) } +/// Is all the type in `n`, and in anything in it, type the reader can make an envelope's contents +/// of: none of it is a frame of threaded type or has text wrap options. +fn type_reads_back(n: &Node, doc: &Document) -> bool { + match &n.kind { + NodeKind::Text(_) => n.extras().wrap.is_none() && !doc.text_threads.iter().any(|th| th.contains(&n.id)), + _ => n.children().is_none_or(|c| c.iter().all(|k| type_reads_back(k, doc))), + } +} + /// The record of envelope `n` (its parts given), or `None` where the reader would open it in /// another shape than it has, or not as an envelope. fn record( @@ -73,7 +82,7 @@ fn record( // Distort Appearance on. let whole = (0.0..=100.0).contains(&fidelity) && fidelity.fract() == 0.0; let margin_kept = !options.distort_appearance && options.bounds_margin != [0.0; 4]; - if content.is_empty() || frame != Affine::IDENTITY || !whole || margin_kept || content.iter().any(|c| holds_type(c)) { + if content.is_empty() || frame != Affine::IDENTITY || !whole || margin_kept { return None; } let mut keys = String::new(); @@ -347,17 +356,24 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, impl Writer<'_> { /// Envelope `n`, of transparency `xy`, as a live envelope (see the module's form), outside opacity - /// masks' art, pattern tiles, art cut to a clipping object and the layers of a large canvas. False, - /// writing nothing, where it is written as its art instead: where the reader would open it in - /// another shape, or some of it is left out for the object limit ([`Self::take_objects`]). + /// masks' art, pattern tiles, art cut to a clipping object and the layers of a large canvas. Type + /// in its contents is written as type, which the reader makes the envelope's contents again. + /// False, writing nothing, where it is written as its art instead: where the reader would open it + /// in another shape, or with its type as drawn art (threaded type, type with text wrap options, + /// type written as its outlines), or some of it is left out for the object limit + /// ([`Self::take_objects`]). pub(super) fn envelope(&mut self, n: &Node, xy: Xy) -> bool { let NodeKind::Envelope { content, kind, fidelity, editing, options, frame } = &n.kind else { return false }; if self.hidden > 0 || self.clip.is_some() || self.nest >= MAX_NEST || !self.symbols_live || self.checks_left == 0 { return false; } + if content.iter().any(|c| holds_type(c)) && !content.iter().all(|c| type_reads_back(c, self.doc)) { + return false; + } let Some(record) = record(n, content, kind, *fidelity, *editing, options, **frame) else { return false }; let start = self.body.len(); let (state, gs, charges, bound) = (self.state, self.gs, self.charges(), self.bound.len()); + let (outlined, texts, warnings) = (self.outlined, self.texts.written(), self.warnings.len()); self.nest += 1; self.hidden += 1; // The group before the dictionary: the grid, or the contents while they are edited. @@ -365,7 +381,7 @@ impl Writer<'_> { let opened = self.open_group("u"); if opened { if *editing { - self.contents(content); + self.contents(content, false); } else { self.grid(record.grid.as_ref()); } @@ -383,23 +399,32 @@ impl Writer<'_> { if *editing { self.grid(record.grid.as_ref()); } else if self.open_group("u") { - self.contents(content); + self.contents(content, true); self.end_group("U", Xy::PLAIN); } let block = std::mem::replace(&mut self.body, outer); hide(&block, &mut self.body); (self.state, self.gs, self.block_end) = (at_block, None, usize::MAX); let _ = writeln!(self.body, "%_X+\n%_ (AIDeformArtKey) ,\n%_{} /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW", record.style); + // Type in the contents written as its outlines would open as drawn art, not as type. + let outlines = self.outlined != outlined; self.hidden -= 1; self.nest -= 1; // The distorted art, which takes the envelope's lock, visibility, transparency, name and - // opacity mask. + // opacity mask. The reader opens the envelope live, wrapping type by its bounds, so its text + // wrap options stay in the art's dictionary. self.state(n.locked, !n.visible); - self.drawn_art(n, xy, None); - if !opened || self.left_out_since(charges) { + self.drawn_art(n, xy, None, false); + if !opened || outlines || self.left_out_since(charges) { let lines = self.body.split_off(start); self.give_back(charges, &lines); self.bound.truncate(bound); + // The stories of the type in the lines taken out go with them. + self.texts.take_back(texts); + // The warnings of art written as its art again are that art's. + if !self.left_out_since(charges) { + self.warnings.truncate(warnings); + } (self.state, self.gs, self.block_end) = (state, gs, usize::MAX); return false; } @@ -408,11 +433,14 @@ impl Writer<'_> { true } - /// An envelope's contents. - fn contents(&mut self, content: &[Arc]) { + /// An envelope's contents, where type may be written as type ([`Self::text`]): `copied` when + /// they are in its dictionary, where the reader reads a copy of each text object. + fn contents(&mut self, content: &[Arc], copied: bool) { + let level = self.contents_level.replace((self.hidden, self.nest, copied)); for c in content { self.node(c); } + self.contents_level = level; } /// An envelope's grid, when it has one, as a gray gradient mesh (`/Mesh X!` … `/End X!`): its diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index d0a90ce86a..e2702b5714 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -483,6 +483,8 @@ impl Writer<'_> { self.gs.replace(Xy::OPAQUE), (self.clip.take(), std::mem::take(&mut self.shown)), self.block_end, + // The tile's masks' objects are written with their art styles, those of no other art. + (std::mem::replace(&mut self.styled, self.pattern_styled.get(name).cloned().unwrap_or_default()), self.mask_level.take()), ); // `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its // placement is worked out in the tile's space turned y up. @@ -505,7 +507,15 @@ impl Writer<'_> { self.hidden -= 1; self.nest -= 1; let art; - (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved; + ( + art, + (self.to_art, self.grad_space, self.canvas), + self.state, + self.gs, + (self.clip, self.shown), + self.block_end, + (self.styled, self.mask_level), + ) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); if self.left_out_since(before) { diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index a8a2da677c..50aa4df931 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -26,7 +26,8 @@ pub(in crate::ai_editing) const PANEL_EMPTY: &str = pub(in crate::ai_editing) const WARPED_IMAGES: &str = "images with a Warp after the first 256 envelopes and images with a Warp are written as their drawn look, without their Warp, in the editing data for other apps"; pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style"; -/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. +/// How many objects of `doc`'s layers, opacity masks, symbols and pattern tiles are linked to a +/// graphic style. pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { fn count(n: &Node) -> usize { // Ruler guides are not written as objects. @@ -37,7 +38,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) } - doc.layers.iter().map(|l| count(l)).sum::() + doc.symbols.iter().map(|s| count(&s.art)).sum::() + doc.layers.iter().map(|l| count(l)).sum::() + + doc.symbols.iter().map(|s| count(&s.art)).sum::() + + doc.patterns.iter().flat_map(|p| p.art.iter()).map(|a| count(a)).sum::() } /// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art @@ -48,10 +51,30 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { /// Knockout Shape (the reader takes an object's transparency /// from its look, which doesn't carry it). A group's members are paths, compound paths or such /// groups, none with effects or anything baking changes. -pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { - fn walk(nodes: &[Arc], out: &mut HashMap>) { +pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> StyledMap { + styled_in(&doc.layers, true) +} + +/// The objects that may be written with an art style, by id, as they are before baking. +pub(in crate::ai_editing) type StyledMap = HashMap>; + +/// The objects of the opacity masks' art in each of `doc`'s symbols' art, by symbol name, and in +/// each of its patterns' tile art, by pattern name, that may be written with an art style, as +/// [`styled_objects`] finds them on the layers. A definition's own objects outside its masks' art +/// are not among them, and definitions without such objects are left out. +pub(in crate::ai_editing) fn styled_definitions(doc: &Document) -> (HashMap, HashMap) { + let symbols = doc.symbols.iter().map(|s| (s.name.clone(), styled_in(std::slice::from_ref(&s.art), false))); + let patterns = doc.patterns.iter().map(|p| (p.name.clone(), styled_in(&p.art, false))); + let kept = |(_, m): &(String, StyledMap)| !m.is_empty(); + (symbols.filter(kept).collect(), patterns.filter(kept).collect()) +} + +/// The objects of `nodes` that may be written with an art style ([`styled_objects`]): with `own`, +/// `nodes` and their members as well as their opacity masks' art, else their masks' art only. +fn styled_in(nodes: &[Arc], own: bool) -> StyledMap { + fn walk(nodes: &[Arc], own: bool, out: &mut StyledMap) { for n in nodes { - if candidate(n) { + if own && candidate(n) { out.insert(n.id, n.clone()); } if let Some(m) = n.extras().mask.as_ref() { @@ -59,7 +82,7 @@ pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap HashMap { let doc = self.doc; // A new document's styles are what the reader gives a file without a style list; they are // still written when an object is linked to one of them, so that it keeps its link. - let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some()); + let definitions = self.symbol_styled.values().chain(self.pattern_styled.values()).flat_map(HashMap::values); + let linked = self.styled.values().chain(definitions).any(|n| n.extras().graphic_style.is_some()); if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles { return; } diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index 0c8d2c7d3b..4d4bb2acba 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -252,6 +252,8 @@ impl Writer<'_> { self.root_entries.take(), std::mem::take(&mut self.wrote_pattern), (std::mem::take(&mut self.wrote_fixed), std::mem::replace(&mut self.inner_scale, 1.0)), + // The art's masks' objects are written with their art styles, those of no other art. + (std::mem::replace(&mut self.styled, self.symbol_styled.get(name).cloned().unwrap_or_default()), self.mask_level.take()), ); // Gradients' matrices and instances' matrices are written about the canvas point of art // space's origin, y up; `Bg` places a gradient on the art as it lies in the symbol's space. @@ -309,6 +311,7 @@ impl Writer<'_> { self.root_entries, self.wrote_pattern, (self.wrote_fixed, self.inner_scale), + (self.styled, self.mask_level), ) = saved; self.doc = page_doc; (self.in_look, self.drawn) = (in_look, drawn); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 8503500007..f1fb10af78 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -621,6 +621,15 @@ fn place_on(segments: &[[f64; 8]], f: f64) -> f64 { segments.len() as f64 } +/// Where the text document ended at some point ([`TextDoc::written`]). +#[derive(Clone, Copy)] +pub(in crate::ai_editing) struct TextMark { + at: (usize, usize, usize, usize, usize), + values: usize, + lost: bool, + planned: usize, +} + /// The text document being written. #[derive(Default)] pub(in crate::ai_editing) struct TextDoc { @@ -1023,6 +1032,24 @@ impl TextDoc { self.threaded.get(&id).copied() } + /// Where the text document written so far ends, for [`Self::take_back`]. + pub(in crate::ai_editing) fn written(&self) -> TextMark { + TextMark { at: self.mark(), values: self.doc_values, lost: self.lost, planned: self.planned.len() } + } + + /// Take the frames, stories and fonts added since `m` out again, with what they counted. Where + /// a thread was planned since, its frames name its story, so nothing is taken out. + pub(in crate::ai_editing) fn take_back(&mut self, m: TextMark) { + if self.planned.len() != m.planned { + return; + } + let (frames, stories, bytes, text, fonts) = m.at; + self.frames.truncate(frames); + self.stories.truncate(stories); + self.fonts.truncate(fonts); + (self.doc_bytes, self.text_bytes, self.doc_values, self.lost) = (bytes, text, m.values, m.lost); + } + /// Whether a thread written since the last call has attributes the text document leaves out. pub(in crate::ai_editing) fn take_lost(&mut self) -> bool { std::mem::take(&mut self.lost) diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 6b1374cd83..aa819a77ad 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -36,10 +36,8 @@ pub(crate) fn subpath(s: &mut String, xf: Affine, sp: &SubPath) { s.push_str(if smooth { "c\n" } else { "C\n" }); } }; - for pair in sp.anchors.windows(2) { - if let [a, b] = pair { - segment(s, a, b); - } + for (a, b) in sp.anchors.iter().zip(sp.anchors.iter().skip(1)) { + segment(s, a, b); } if sp.closed && sp.anchors.len() > 1 diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index c791f7c459..6f77c141ac 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -24,7 +24,9 @@ //! from those layers alone, without its page. //! //! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page -//! of a PostScript file without one. A file whose program can't be read (an operator the +//! of a PostScript file without one. A `.ai` in the legacy format ([`import_ai`]) that comes in +//! through its layers keeps the artboard its header gives, with its art outside it; an EPS comes in +//! on its page. A file whose program can't be read (an operator the //! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its //! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not //! included, as other apps write it), or with its art on one layer when it is in the legacy format @@ -82,8 +84,6 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -#[cfg(test)] -pub(crate) use ai::ENVELOPE_DRAWN; pub use ai::NameEncoding; #[cfg(test)] pub(crate) use ai::objects_read; @@ -92,6 +92,8 @@ pub(crate) use ai::{ MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, envelope_outline, envelope_upright, read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; +#[cfg(test)] +pub(crate) use ai::{ENVELOPE_DRAWN, ENVELOPE_TYPE_DRAWN}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document, scale_symbols}; @@ -279,7 +281,20 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< /// and data sets) read in `names`. The type of the document it gives that is set in families that /// aren't available shows in the fallback font, and a warning names them. pub fn import_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { - let mut r = read_names(bytes, editing_data, trust, names)?; + read_checked(bytes, editing_data, trust, names, false) +} + +/// [`import_names`] for a `.ai` (or `.ait`) in PostScript form: a program in the legacy format +/// that comes in through its layers keeps the artboard its header gives, with its art outside it, +/// where an EPS comes in on its page, its art's box. A `.ai` of versions 3 and 4, which doesn't +/// give its artboard's size, has a US Letter artboard around its template box's center. +pub fn import_ai(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { + read_checked(bytes, editing_data, trust, names, true) +} + +/// [`import_names`] (a `.ai` when `ai_file`, see [`import_ai`]). +fn read_checked(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding, ai_file: bool) -> Result { + let mut r = read_names(bytes, editing_data, trust, names, ai_file)?; let missing = missing_fonts(&r.document); if !missing.is_empty() { r.warnings.push(format!("{MISSING_FONTS_NOTE}: {}", missing.join(", "))); @@ -314,8 +329,8 @@ fn missing_fonts(d: &Document) -> Vec { missing } -/// [`import_names`] before the type's fonts are checked. -fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { +/// [`import_names`] before the type's fonts are checked (a `.ai` when `ai_file`, see [`import_ai`]). +fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding, ai_file: bool) -> Result { let ps = program(bytes)?; let dsc = Dsc::read(ps); // A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no @@ -323,7 +338,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding // printed). if editing_data && dsc.own_legacy - && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, names) + && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, ai_file, names) { return Ok(Imported { document, warnings, preview: false }); } @@ -343,7 +358,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator, names)), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, dsc.illustrator, ai_file, names)), (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), @@ -351,7 +366,7 @@ fn read_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding // A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the // prolog defining its operators without including it): its layers, which the program has, or // its art on one layer when it has none (Illustrator 3). - let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, names)).flatten() { + let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, ai_file, names)).flatten() { Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }), Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"), None => why, diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 4595061151..1a73f64a50 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -55,8 +55,8 @@ pub use read::{ palette, read_beside, read_in, symbols, variables, }; pub(crate) use read::{ - MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_style_list, slot_frame, slot_of, style_moves_with, - symbol_art_member, + MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, read_over_page, read_program, read_style_list, slot_frame, slot_of, + style_moves_with, symbol_art_member, }; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index bc1b82c22d..d32ae4c177 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -21,9 +21,10 @@ //! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`. //! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id. //! - Artboards: the `ArtboardArray` of the `/Document` dictionary, with each one's display -//! options, else `%AI3_Cropmarks`, else the art size around the template box's centre -//! (`%AI3_TemplateBox`, `%%TemplateBox` in older files), else (a file in the legacy format -//! another app wrote) the bounding box. +//! options, else `%AI3_Cropmarks`, else the art size (`%AI5_ArtSize`; VectorCraft's own default, +//! US Letter, in a `.ai` of versions 3 and 4, which don't give it) around the template box's center (`%AI3_TemplateBox`, +//! `%%TemplateBox` in older files), which the version 7 specification (§2.2) puts at the +//! artboard's center, else (a file in the legacy format another app wrote) the bounding box. //! - Document settings: Document Raster Effects Settings from the `/Document` dictionary's //! `AI Auto Rasterize`; the author from the header's `%%For`; swatches for the custom colors of //! `%%CMYKCustomColor` and `%%RGBCustomColor` that the document has none of. @@ -273,12 +274,28 @@ pub(crate) fn read_over_page(data: &[u8], large_canvas: bool, names: NameEncodin read_with(data, large_canvas, true, names) } -fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result { +/// [`read_in`] (or [`read_over_page`] when `page_art`) for the program of a `.ai` in the legacy +/// format: one of versions 3 and 4 that has a template box but no art size has a US Letter +/// artboard ([`Reader::sized_board`]). +pub(crate) fn read_program(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result { let mut r = Reader::with_names(data, names); - r.large_canvas = large_canvas.then_some(1.0); - r.page_art = page_art; - r.run()?; - r.finish() + r.legacy_ai = true; + r.read_as(large_canvas, page_art) +} + +fn read_with(data: &[u8], large_canvas: bool, page_art: bool, names: NameEncoding) -> Result { + Reader::with_names(data, names).read_as(large_canvas, page_art) +} + +impl Reader<'_> { + /// The structure read, the declared large canvas scale applied when `large_canvas`, the page's + /// art replacing the objects not read when `page_art` ([`read_over_page`]). + fn read_as(mut self, large_canvas: bool, page_art: bool) -> Result { + self.large_canvas = large_canvas.then_some(1.0); + self.page_art = page_art; + self.run()?; + self.finish() + } } /// How many objects [`read`] counts against [`MAX_NODES`] in the editing data `data`. @@ -645,6 +662,11 @@ struct Reader<'a> { /// The artboard's size and centre in files without artboards or crop marks. art_size: Option<(f64, f64)>, template_center: Option<(f64, f64)>, + /// The data is the program of a `.ai` in the legacy format ([`read_program`]), not an EPS's: + /// without an art size, its artboard can be US Letter ([`Reader::sized_board`]). + legacy_ai: bool, + /// The header has `%AI5_FileFormat`, which files of version 5 and later write. + file_format: bool, /// `%AI24_LargeCanvasScale`: a Large Canvas document's editing data is written at 1/N scale /// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None` /// when the declared factor is not applied ([`read`]). @@ -827,6 +849,8 @@ impl<'a> Reader<'a> { xml_id_bytes: 0, art_size: None, template_center: None, + legacy_ai: false, + file_format: false, large_canvas: None, page_art: false, canvas_scale: 1.0, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 0fe6b9da87..8ca3781717 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -242,10 +242,13 @@ impl<'a> Reader<'a> { && let Ok(n) = v.trim().parse::() { self.ruler_units = Some(n); + } else if self.frames.is_empty() && line.starts_with("AI5_FileFormat") { + self.file_format = true; } else if let Some(v) = line.strip_prefix("AI5_ArtSize:") { self.art_size = two(v); } else if let Some(v) = line.strip_prefix("AI3_TemplateBox:").or_else(|| line.strip_prefix("%TemplateBox:")) { - self.template_center = two(v); + // The center of the box (a file without a template writes it with no width or height). + self.template_center = four(v).map(|[x0, y0, x1, y1]| ((x0 + x1) / 2.0, (y0 + y1) / 2.0)).or_else(|| two(v)); self.template = four(v).or(self.template); } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { self.large_canvas_scale(v); diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ba6a8bd2de..312b7cc275 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -871,9 +871,15 @@ impl<'a> Reader<'a> { // ---- the document ---------------------------------------------------------------------- /// The artboard of a file without artboards or crop marks: its size around the template - /// box's centre (half a point off the grid). + /// box's center (half a point off the grid). The program of a `.ai` of versions 3 and 4 + /// ([`Reader::legacy_ai`], no `%AI5_FileFormat`), which don't record the artboard's size, has + /// VectorCraft's own default there, a US Letter artboard (612 × 792 pt); an EPS, or a newer + /// file without `%AI5_ArtSize`, has none (its page is its bounding box). fn sized_board(&self) -> Option { - let ((w, h), (cx, cy)) = (self.art_size?, self.template_center?); + let (cx, cy) = self.template_center?; + let [_, _, letter_w, letter_h] = crate::import::LETTER; + let letter = self.legacy_ai && !self.file_format; + let (w, h) = self.art_size.or(letter.then_some((letter_w, letter_h)))?; // Rounded out, but not off a whole point the decimals in the file only just miss. let near = |v: f64| (v - v.round()).abs() < 1e-6; let (left, top) = (cx - w / 2.0, cy + h / 2.0); @@ -1393,8 +1399,9 @@ impl<'a> Reader<'a> { } /// Link the objects of `links` among `nodes` to their graphic style's id, each only when it has its -/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order). A path, which -/// has no contents, takes the style's contents slot. +/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order), their members +/// and their opacity masks' art included. A path, which has no contents, takes the style's contents +/// slot. fn link_styles(nodes: &mut [Arc], links: &HashMap, styles: &[GraphicStyle], depth: usize) { if depth > MAX_DEPTH { return; @@ -1413,6 +1420,11 @@ fn link_styles(nodes: &mut [Arc], links: &HashMap, styles: &[ n.extras_mut().graphic_style = Some(*id); } } + if n.extras().mask.is_some() + && let Some(m) = Arc::make_mut(n).extras_mut().mask.as_mut() + { + link_styles(std::slice::from_mut(&mut m.art), links, styles, depth + 1); + } if n.children().is_some_and(|c| !c.is_empty()) && let Some(c) = Arc::make_mut(n).children_mut() { diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c469f02cd4..09368870e8 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -19,6 +19,16 @@ pub(super) enum Page { Art, /// A `.ai` is read on its (first and only) artboard. Artboard, + /// A `.ai` in the legacy format (PostScript, not an EPS): its page is its art's bounding box, as + /// an EPS's is, and its artboard is the one its header gives. + Program, +} + +impl Page { + /// Is the page the art's bounding box? + fn of_art(self) -> bool { + matches!(self, Page::Art | Page::Program) + } } /// How art moved from a page to its place in the layers' document transforms: its pattern tiles @@ -62,8 +72,12 @@ pub(super) fn build( ) -> Result<(Document, Vec), String> { // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art // space, as its PDF part may (#2031). - let large_canvas = matches!(page, Page::Art); - let structure = if visible.is_some() { ai::read_over_page(data, large_canvas, names)? } else { ai::read_in(data, large_canvas, names)? }; + let large_canvas = page.of_art(); + let structure = match page { + Page::Program => ai::read_program(data, large_canvas, visible.is_some(), names)?, + _ if visible.is_some() => ai::read_over_page(data, large_canvas, names)?, + _ => ai::read_in(data, large_canvas, names)?, + }; build_from(structure, data, visible, page, outlined, trust, unit) } @@ -184,7 +198,7 @@ fn page_scale_alone(structure: &Structure, pages: &[(f64, f64)], unit: f64) -> O /// page, which draws nothing for them, the linked images' `placeholders`. A PDF part draws those /// images' pixels, which the placeholders don't match. fn left_out_of_comparisons(page: Page, mut restyled: Vec, placeholders: Vec) -> Vec { - if matches!(page, Page::Art) { + if page.of_art() { restyled.extend(placeholders); } restyled @@ -295,7 +309,7 @@ pub(super) fn build_weighing( let mut frame = None; if let Some(visible) = visible { let art_box = match page { - Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, + Page::Art | Page::Program => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?, Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?, }; let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?; @@ -393,8 +407,9 @@ pub(super) fn build_weighing( // An EPS saved as its artboard has the art outside it left out of its page. let artboard_page = artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); - let whole = matches!(page, Page::Art) && !artboard_page; - if whole { + let whole = page.of_art() && !artboard_page; + // A `.ai` keeps the artboard its editing copy gives, with the art outside it. + if whole && matches!(page, Page::Art) { // Its page is the file's own (its bounding box), as when it comes in as its page: art // reaching past the editing data's artboard stays on it (#1102). doc.artboards.truncate(1); @@ -1815,7 +1830,7 @@ fn last_boards(boards: &[vectorcraft_doc::Artboard], pages: &[Rect]) -> Option bool) -> usize { - if matches!(page, Page::Art) || visible.artboards.len() >= doc.artboards.len() { + if page.of_art() || visible.artboards.len() >= doc.artboards.len() { return 0; } let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index afd8f2568c..5154718a2f 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -13,12 +13,16 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// ([`use_symbols`]) and its variable library ([`use_variables`]) when it could be read, and with /// its layer tree, empty, below the page's layer when that could be read (#2033). A program in the /// legacy format without layers (`illustrator`, see [`program_alone`]) is read with its art on one -/// layer; when that can't be used, the file is `visible` as it was. `names`: the encoding of names -/// that aren't UTF-8. -pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool, names: ai::NameEncoding) -> Imported { +/// layer; when that can't be used, the file is `visible` as it was. The program of a `.ai` +/// (`ai_file`) in the legacy format keeps the artboard its header gives ([`Page::Program`]). +/// `names`: the encoding of names that aren't UTF-8. +pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illustrator: bool, ai_file: bool, names: ai::NameEncoding) -> Imported { + let program_page = if ai_file { Page::Program } else { Page::Art }; let Some(data) = editing_copy(ps) else { - let read = - illustrator.then(|| one_layer(ps)).flatten().and_then(|p| build(&p, Some(&visible.document), Page::Art, false, trust, 1.0, names).ok()); + let read = illustrator + .then(|| one_layer(ps)) + .flatten() + .and_then(|p| build(&p, Some(&visible.document), program_page, false, trust, 1.0, names).ok()); return match read { Some((document, warnings)) => with_page_notes(document, warnings, &visible), None => visible, @@ -28,11 +32,17 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, illus // can't be used. // Private data kept apart from the program: the program's own header gives the author and // custom colors the private data's header may lack. - let read = - |d: &[u8]| if matches!(data, Ok(Cow::Owned(_))) { ai::read_beside(d, true, ps, true, names) } else { ai::read_over_page(d, true, names) }; + let private = matches!(data, Ok(Cow::Owned(_))); + let read = |d: &[u8]| match (private, program_page) { + (true, _) => ai::read_beside(d, true, ps, true, names), + (false, Page::Program) => ai::read_program(d, true, true, names), + (false, _) => ai::read_over_page(d, true, names), + }; + // Private data is an EPS's editing copy, whatever the file is named. + let page = if private { Page::Art } else { program_page }; let (tree, result) = match data.as_deref() { Ok(d) => match read(d) { - Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), + Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), page, false, trust, 1.0)), Err(why) => (vec![], Err(why)), }, Err(why) => (vec![], Err(why.clone())), @@ -607,16 +617,23 @@ fn editing_copy(ps: &[u8]) -> Option, String>> { /// but whose program has no layers (Illustrator 3 had none; image editors still export their /// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program /// has no layers and isn't in the format: other PostScript isn't read as the format's operators. +/// The program of a `.ai` (`ai_file`) has the artboard its header gives ([`Page::Program`]). /// `names`: the encoding of names that aren't UTF-8. -pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool, names: ai::NameEncoding) -> Option), String>> { +pub(in super::super) fn program_alone( + ps: &[u8], + illustrator: bool, + ai_file: bool, + names: ai::NameEncoding, +) -> Option), String>> { if ai::has_eps_data(ps) { return None; } + let page = if ai_file { Page::Program } else { Page::Art }; if has_layers(ps) { - return Some(build(ps, None, Page::Art, false, false, 1.0, names)); + return Some(build(ps, None, page, false, false, 1.0, names)); } let program = illustrator.then(|| one_layer(ps)).flatten()?; - Some(build(&program, None, Page::Art, false, false, 1.0, names)) + Some(build(&program, None, page, false, false, 1.0, names)) } /// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index 19d89ad33f..4cae36ac52 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::clipnest::MAX_NEST; use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind}; use vectorcraft_geom::Rect; -use crate::import::import; +use crate::import::{import, import_ai}; /// A minimal file in Illustrator's format: its header comments, a prolog of our own defining the /// operators `body` uses (`u` and `U` as `ugroup` and `Ugroup`), and `body`. @@ -329,6 +329,100 @@ fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() { assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1")); } +/// A `.ai` in the legacy format (version 8, `%AI5_FileFormat 4.0`) whose prolog is included: a +/// square on its 200-point artboard and one far to its right. Its bounding box is the box of that +/// art; its artboard is its art size around the template box's center (version 7 specification +/// §2.2). Its first line names the EPSF version, as the format's files may. +const LEGACY_AI_ART_OFF_ITS_ARTBOARD: &str = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 320 30\n\ + %%HiResBoundingBox: 10 10 320 30\n%AI5_FileFormat 4.0\n%AI3_TemplateBox: 100.5 99.5 100.5 99.5\n%AI5_ArtSize: 200 200\n\ + %%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def\n\ + /Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Art) Ln\n0 A\n0 0 0 1 k\n\ + 10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n300 10 m\n320 10 L\n320 30 L\n300 30 L\nf\nLB\n%AI5_EndLayer--\n\ + %%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + +/// `bytes` opened as a `.ai`. +fn open_ai(bytes: &[u8]) -> crate::Imported { + import_ai(bytes, true, false, crate::NameEncoding::default()).unwrap() +} + +/// Such a `.ai` keeps the artboard its header gives, with the square outside it, when it comes in +/// through its layers. The same program opened as an EPS comes in on its page, its art's box +/// (#1102). +#[test] +fn a_legacy_ai_keeps_its_artboard_and_the_art_outside_it() { + let r = open_ai(LEGACY_AI_ART_OFF_ITS_ARTBOARD.as_bytes()); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let boards: Vec<_> = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 200.0, 200.0)]); + let art: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap()).collect(); + // Art space has y up from the artboard's bottom: (10, 10) is 10 above it. + assert_eq!(art, [Rect::new(10.0, 170.0, 30.0, 190.0), Rect::new(300.0, 170.0, 320.0, 190.0)]); + let eps = import(LEGACY_AI_ART_OFF_ITS_ARTBOARD.as_bytes()).unwrap(); + let boards: Vec<_> = eps.document.artboards.iter().map(|a| a.rect.size()).collect(); + assert_eq!(boards, [kurbo::Size::new(310.0, 20.0)], "{:?}", eps.warnings); +} + +/// A version 3 `.ai` doesn't give its artboard's size (`%AI5_ArtSize` came with version 5): its +/// artboard is VectorCraft's own default, a US Letter page, around the template box's center, the +/// artboard's center (version 7 specification §2.2). Without a template box it is the art's box, +/// and so is the page of the same file opened as an EPS. +#[test] +fn a_version_3_ai_has_a_letter_artboard_around_its_template_boxs_center() { + let v3 = PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_TemplateBox: 100 100 100 100\n"); + let r = open_ai(v3.as_bytes()); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let boards: Vec<_> = d.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 612.0, 792.0)]); + // The artboard runs from -206 to 406 across and from -296 to 496 up in art space. + let art = d.layers[0].children().unwrap(); + assert_eq!(art[1].geometric_bounds(), Some(Rect::new(226.0, 446.0, 266.0, 476.0))); + // The center of a template box with a size (a file with a template image). + let r = open_ai(PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_TemplateBox: 50 50 150 150\n").as_bytes()); + assert_eq!(r.document.layers[0].children().unwrap()[1].geometric_bounds(), Some(Rect::new(226.0, 446.0, 266.0, 476.0))); + let r = open_ai(PATHS_ONLY.replace("%AI3_Cropmarks: 0 0 200 100\n", "%AI3_ColorUsage: Color\n").as_bytes()); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings); + let r = import(v3.as_bytes()).unwrap(); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings); + // A newer file (`%AI5_FileFormat`, version 5 on) without an art size keeps the art's box too. + let r = open_ai(v3.replace("%%EndComments", "%AI5_FileFormat 1.0\n%%EndComments").as_bytes()); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "{:?}", r.warnings); +} + +/// A version 3 `.ai` whose prolog is included and whose program has no layers: its page draws, and +/// its art comes in on one layer around a US Letter artboard, the square far to the right outside +/// it. +#[test] +fn a_version_3_ai_that_draws_keeps_its_letter_artboard_and_the_art_outside_it() { + let v3 = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: a script\n%%BoundingBox: 10 10 720 30\n%AI3_TemplateBox: 100 100 100 100\n\ + %%EndComments\n%%BeginProlog\n/m {moveto} def /L {lineto} def /f {closepath fill} def /k {setcmykcolor} def\n\ + %%EndProlog\n%%BeginSetup\n%%EndSetup\n0 0 0 1 k\n10 10 m\n30 10 L\n30 30 L\n10 30 L\nf\n\ + 700 10 m\n720 10 L\n720 30 L\n700 30 L\nf\n%%PageTrailer\nshowpage\n%%Trailer\n%%EOF\n"; + let r = open_ai(v3.as_bytes()); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let boards: Vec<_> = r.document.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(boards, [Rect::new(0.0, 0.0, 612.0, 792.0)]); + assert_eq!(r.document.layers.len(), 1); + let art: Vec<_> = r.document.layers[0].children().unwrap().iter().map(|n| n.geometric_bounds().unwrap()).collect(); + // The artboard runs from -206 to 406 across and from -296 to 496 up in art space. + assert_eq!(art, [Rect::new(216.0, 466.0, 236.0, 486.0), Rect::new(906.0, 466.0, 926.0, 486.0)]); + let eps = import(v3.as_bytes()).unwrap(); + assert_eq!(eps.document.artboards[0].rect, Rect::new(0.0, 0.0, 710.0, 20.0), "{:?}", eps.warnings); +} + +/// A `.ai` another app wrote (#1027) that gives a template box but no art size opens on a US Letter +/// artboard around the box's center, from -18 to 594 across and -12 to 780 up in art space (as an +/// EPS it opens on its bounding box, see above). +#[test] +fn a_postscript_ai_with_a_template_box_and_no_art_size_opens_on_a_letter_artboard() { + let r = open_ai(NAMED_PROLOG.as_bytes()); + assert_eq!(r.document.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0), "{:?}", r.warnings); + let square = &r.document.layers[0].children().unwrap()[0]; + assert_eq!(square.geometric_bounds(), Some(Rect::new(28.0, 690.0, 108.0, 770.0))); +} + /// A placed EPS in the legacy format (version 7 specification §16, no `%AI5_BeginPlace`), its file /// included whole: the layers come in, the placed file as the art the page draws in its box. #[test] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 4c5b8dde8a..327dc22f6f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -4791,6 +4791,9 @@ fn an_eps_page_of_its_art_stays_its_page_through_the_layers() { assert_eq!(boards.len(), 1); assert!((boards[0].width() - 4.0).abs() < 1e-6 && (boards[0].height() - 4.0).abs() < 1e-6, "{boards:?}"); assert!(boards[0].inflate(1e-6, 1e-6).contains_rect(art), "{boards:?} round {art:?}"); + // Its private data is an EPS's editing copy, whatever the file is named. + let named_ai = crate::import::import_ai(&file, true, false, crate::NameEncoding::default()).unwrap(); + assert_eq!(named_ai.document.artboards.iter().map(|a| a.rect).collect::>(), boards, "{:?}", named_ai.warnings); } #[test] diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index e07f119215..12a11a78f4 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -790,6 +790,41 @@ fn colorized_images_keep_their_color() { assert_eq!(out.warnings, [IMAGE_ALPHA]); } +/// The pieces of a colorized warped image work out the image's coverage once, and an image with +/// other data works out its own: a black and white image's pieces are written as image masks +/// in its color, and a gray image's pieces with its color in their pixels. +#[test] +fn the_pieces_of_a_colorized_warped_image_work_out_its_coverage_once() { + // GUARDS: each piece of a colorized warped image decoded the image again for its coverage. + let mut doc = Document::new(400.0, 200.0); + doc.layers.clear(); + let layer = doc.add_layer(Some("Art")); + let paint = Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)); + let checks: Vec = (0..16 * 16).map(|i| if (i % 16 + i / 16) % 2 == 0 { 0 } else { 255 }).collect(); + let ramp: Vec = (0..16 * 16).map(|i| (i % 16 * 16) as u8).collect(); + for (gray, x) in [(checks, 10.0), (ramp, 210.0)] { + let photo = colorized_image(&mut doc, (16, 16), gray, &paint, (x, 10.0)); + let NodeKind::Image(im) = &photo.kind else { panic!("{:?}", photo.kind) }; + let bend = move |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 8.0 * ((p.x - x) / 160.0 * std::f64::consts::PI).sin()); + let warped = vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, false); + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + } + super::writer::COVERAGE_RUNS.with(|n| n.set(0)); + let out = write(&doc); + assert_eq!(super::writer::COVERAGE_RUNS.with(std::cell::Cell::get), 2, "once for each image"); + let text = String::from_utf8(out.bytes).unwrap(); + let images = rasters(&text); + assert_eq!(images.len(), 64, "32 pieces of each image"); + let xi = |piece: &Vec<&str>| piece.iter().find(|l| l.ends_with(" XI")).map(|l| l.to_string()).unwrap_or_default(); + assert!(images[..32].iter().all(|p| xi(p).ends_with(" 1 1 0 0 0 1 XI")), "{:?}", images[..32].iter().map(xi).collect::>()); + assert!(images[32..].iter().all(|p| xi(p).ends_with(" 8 3 0 0 0 0 XI")), "{:?}", images[32..].iter().map(xi).collect::>()); + // The first piece holds the whole image: each row of 16 pixels in two bytes, its black pixels 0 bits. + assert!(xi(&images[0]).ends_with(" ] 0 0 16 16 16 16 1 1 0 0 0 1 XI"), "{}", xi(&images[0])); + let data: Vec<&str> = images[0].iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).collect(); + assert_eq!(data.concat(), "5555AAAA".repeat(8)); + assert_eq!(out.warnings, [IMAGE_ALPHA]); +} + /// An image mask is only for a colorized image the writer draws as the document does: not one /// with an opacity mask turned on, and not one with more pixels than the writer writes. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index d8f88e0d0b..c8bce40227 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -11,6 +11,15 @@ use super::{ }; use super::{CONIC, CONIC_WEDGES, DIAMOND, GRAPHS, HUE_STOPS, SPREAD, SPREAD_CAPPED}; +#[cfg(test)] +thread_local! { + /// How many times [`Writer::coverage`] has worked out an image's coverage on this thread. + pub(super) static COVERAGE_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// An image's coverage ([`ImageBlob::coverage`]): its width, its height and one byte a pixel. +type Coverage = (u32, u32, Arc>); + pub(super) struct Writer<'a> { doc: &'a Document, /// The document → art space. @@ -50,6 +59,9 @@ pub(super) struct Writer<'a> { /// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow /// one another, decode it once. last_image: Option<(ImageBlob, Option>)>, + /// The coverage of [`Self::last_image`] once a colorized piece of it asked for it (`Some(None)` + /// when it can't be worked out), so the pieces of a colorized warped image work it out once. + last_coverage: Option>, /// The images written whole so far, by the address of their data (which each entry keeps). whole_images: std::collections::HashMap>>, /// Writing a graph's art with its records: a graph inside it is written as its art. @@ -79,6 +91,7 @@ impl<'a> Writer<'a> { kumi: false, clips: vec![], last_image: None, + last_coverage: None, whole_images: std::collections::HashMap::new(), in_graph: false, } @@ -1104,7 +1117,7 @@ impl<'a> Writer<'a> { let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h)); let coverage = match colorize { Some(paint) => { - let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { + let Some((_, _, coverage)) = self.coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { self.warn(UNDECODED); return; }; @@ -1172,9 +1185,26 @@ impl<'a> Writer<'a> { } let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new); self.last_image = Some((blob.clone(), img.clone())); + self.last_coverage = None; img } + /// The coverage of `blob` ([`ImageBlob::coverage`]), worked out once for images that follow one + /// another with the same data, as [`Self::decoded`] decodes them. + fn coverage(&mut self, blob: &ImageBlob) -> Option { + let last = self.last_image.as_ref().is_some_and(|(last, _)| Arc::ptr_eq(&last.bytes, &blob.bytes) && last.mime == blob.mime); + if last && let Some(coverage) = &self.last_coverage { + return coverage.clone(); + } + #[cfg(test)] + COVERAGE_RUNS.with(|n| n.set(n.get() + 1)); + let coverage = ImageBlob::coverage(blob).map(|(w, h, c)| (w, h, Arc::new(c))); + if last { + self.last_coverage = Some(coverage.clone()); + } + coverage + } + /// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]` /// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`] /// digits, `%AI5_EndRaster`, then `F`. diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index ada3171e0a..e36422b759 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -49,7 +49,7 @@ mod tiff; pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data}; pub use import::{ AiLayers, Imported, NameEncoding, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, - family_style, import, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page, + family_style, import, import_ai, import_names, import_trusting, import_with, is_loss, layered_ai, layered_ai_from, page, }; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask}; pub use print::{PrintJob, PrintPage, print}; diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 8d147cb2d1..17608c7346 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -266,6 +266,14 @@ pub(crate) struct Scene<'a> { images: HashMap>, /// The source key and solid paint together identify a colorized encoding. colorized_images: HashMap<(String, String), Option>, + /// Image keys whose encoding didn't fit under the page limit → how much its first placement + /// adds to the body. The image is encoded again only once the page has room for that. + too_large_images: HashMap, + /// The same for colorized encodings, by `colorized_images`' keys. + too_large_colorized: HashMap<(String, String), usize>, + /// How many times an image or a colorized image was encoded. + #[cfg(test)] + encodes: usize, /// Freeform gradients' images by [`ImageCode::content_key`] → their indices in `codes`: a field /// encoded the same as one already kept draws from it. fields: HashMap>, @@ -297,6 +305,10 @@ impl<'a> Scene<'a> { brushes: None, images: HashMap::new(), colorized_images: HashMap::new(), + too_large_images: HashMap::new(), + too_large_colorized: HashMap::new(), + #[cfg(test)] + encodes: 0, fields: HashMap::new(), codes: vec![], placements: vec![], @@ -313,6 +325,30 @@ impl<'a> Scene<'a> { self } + /// [`Self::run`], with how many placements draw each encoded image the scene kept, and the + /// indices in `codes` that its image, colorized-image and field caches name. + #[cfg(test)] + pub fn run_with_codes(mut self) -> (Page, Vec, Vec) { + let doc = self.doc; + for l in &doc.layers { + self.node(l, false); + } + let uses = self.codes.iter().map(|c| c.uses).collect(); + let cached = self.images.values().chain(self.colorized_images.values()).flatten().chain(self.fields.values().flatten()).copied().collect(); + (self.page(), uses, cached) + } + + /// [`Self::run`], with how many times the scene encoded an image or a colorized image. + #[cfg(test)] + pub fn run_counting_encodes(mut self) -> (Page, usize) { + let doc = self.doc; + for l in &doc.layers { + self.node(l, false); + } + let encodes = self.encodes; + (self.page(), encodes) + } + pub fn run(mut self) -> Page { let doc = self.doc; for l in &doc.layers { @@ -556,7 +592,7 @@ impl<'a> Scene<'a> { if self.size() > self.max_body { self.body.truncate(was); self.unplace(placed); - self.forget_fields(codes); + self.forget_codes(codes); self.warn(TOO_LARGE); } } @@ -1439,24 +1475,21 @@ impl<'a> Scene<'a> { self.place_image(i, place); } - /// Forget the field images after the first `n` codes that no placement draws any longer, last - /// first, up to the first code that is drawn or isn't a field image. - fn forget_fields(&mut self, n: usize) { - while self.codes.len() > n { - let i = self.codes.len() - 1; - let Some(code) = self.codes.get(i) else { break }; - if code.uses > 0 { - break; - } - let key = code.content_key(); - let Some(bucket) = self.fields.get_mut(&key) else { break }; - let Some(at) = bucket.iter().position(|&j| j == i) else { break }; - bucket.swap_remove(at); - if bucket.is_empty() { - self.fields.remove(&key); - } - self.codes.pop(); + /// Forget the encoded images after the first `n` codes that no placement draws any longer, and + /// the cache entries that name them: placed images, colorized images and fields. A code that a + /// placement still draws keeps itself and every code before it. + fn forget_codes(&mut self, n: usize) { + let keep = self.codes.iter().rposition(|c| c.uses > 0).map_or(n, |last| n.max(last + 1)); + if keep >= self.codes.len() { + return; } + self.codes.truncate(keep); + self.images.retain(|_, e| e.is_none_or(|i| i < keep)); + self.colorized_images.retain(|_, e| e.is_none_or(|i| i < keep)); + self.fields.retain(|_, bucket| { + bucket.retain(|&i| i < keep); + !bucket.is_empty() + }); } /// A placed image (`blob`, cached under `key`): its pixel grid of `width` × `height` placed @@ -1469,7 +1502,28 @@ impl<'a> Scene<'a> { let encoded = match self.images.get(key) { Some(e) => *e, None => { - let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(|c| self.add_code(c)); + // An image turned away at the page limit is encoded again only once it fits. + if let Some(&len) = self.too_large_images.get(key) { + if self.size().saturating_add(len).saturating_add(64) > self.max_body { + self.warn(TOO_LARGE); + return; + } + self.too_large_images.remove(key); + } + #[cfg(test)] + { + self.encodes += 1; + } + let code = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())); + // An image that can't be placed isn't kept. + if let Some(c) = &code + && self.size().saturating_add(c.inline_len).saturating_add(64) > self.max_body + { + self.too_large_images.insert(key.to_string(), c.inline_len); + self.warn(TOO_LARGE); + return; + } + let e = code.map(|c| self.add_code(c)); self.images.insert(key.to_string(), e); e } @@ -1501,7 +1555,28 @@ impl<'a> Scene<'a> { let encoded = match self.colorized_images.get(&cache) { Some(e) => *e, None => { - let e = self.encode_colorized(blob, paint).map(|c| self.add_code(c)); + // An image turned away at the page limit is encoded again only once it fits. + if let Some(&len) = self.too_large_colorized.get(&cache) { + if self.size().saturating_add(len).saturating_add(64) > self.max_body { + self.warn(TOO_LARGE); + return; + } + self.too_large_colorized.remove(&cache); + } + #[cfg(test)] + { + self.encodes += 1; + } + let code = self.encode_colorized(blob, paint); + // An image that can't be placed isn't kept. + if let Some(c) = &code + && self.size().saturating_add(c.inline_len).saturating_add(64) > self.max_body + { + self.too_large_colorized.insert(cache, c.inline_len); + self.warn(TOO_LARGE); + return; + } + let e = code.map(|c| self.add_code(c)); self.colorized_images.insert(cache, e); e } diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 963e78c9ef..60b5832859 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -1495,6 +1495,228 @@ fn a_field_taken_back_at_the_page_limit_leaves_no_image_behind() { assert!(page.setup.contains("/VCim0 VCsrc") && !page.setup.contains("VCim1"), "{}", page.setup); } +/// A 200 × 200 pt document holding 8 × 8 pixels of noise as image `pic` and a three-pixel gray ramp +/// as image `gray`, its first layer holding what `nodes` makes. +fn image_doc_with(nodes: impl FnOnce(&mut Document) -> Vec) -> Document { + let mut d = Document::new(200.0, 200.0); + let mut png = vec![]; + noise(8, 8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("pic".into(), ImageBlob::new("image/png", png)); + let mut gray = vec![]; + image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(3, 1, vec![0, 128, 255]).unwrap()) + .write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png) + .unwrap(); + d.images.insert("gray".into(), ImageBlob::png(gray)); + let nodes = nodes(&mut d); + let layer = d.layers[0].id; + for n in nodes { + d.insert(Some(layer), usize::MAX, n).unwrap(); + } + d +} + +/// One of [`image_doc_with`]'s images: `pic`, or `gray` colorized in process red. +#[derive(Clone, Copy, Debug)] +enum Pic { + Placed, + Colorized, +} + +/// An image of `pic` at (`x`, `y`), its pixels `scale` points wide. +fn pic_node(d: &mut Document, pic: Pic, x: f64, y: f64, scale: f64) -> Node { + let (key, width, height) = match pic { + Pic::Placed => ("pic", 8, 8), + Pic::Colorized => ("gray", 3, 1), + }; + let im = ImageObject { + key: key.into(), + width, + height, + xf: Affine::translate((x, y)) * Affine::scale(scale), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + if let Pic::Colorized = pic { + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); + } + n +} + +/// A pattern whose tile is an image of `pic` about 60 points wide. +fn pic_pattern(d: &mut Document, pic: Pic) -> Paint { + let scale = match pic { + Pic::Placed => 8.0, + Pic::Colorized => 20.0, + }; + let tile = pic_node(d, pic, 0.0, 0.0, scale); + d.patterns.push(vectorcraft_doc::PatternDef::new("tile", vec![Arc::new(tile)])); + Paint::Pattern { pattern: "tile".into(), xf: Affine::IDENTITY } +} + +/// A 2000-sided shape filled with each of `fills` in turn, and stroked in black when `stroked`. +fn many_sided(d: &mut Document, fills: &[Paint], stroked: bool) -> Node { + let stroke = if stroked { Paint::solid(Color::rgb(0.0, 0.0, 0.0)) } else { Paint::None }; + let mut n = path(d, shapes::polygon(Point::new(100.0, 100.0), 90.0, 2000, 0.0), fills[0].clone(), stroke, 1.0); + let Some(AppearanceItem::Fill(first)) = n.appearance.items.first().cloned() else { panic!("a filled shape") }; + for (k, paint) in fills.iter().enumerate().skip(1) { + let mut fill = first.clone(); + fill.paint = paint.clone(); + n.appearance.items.insert(k, AppearanceItem::Fill(fill)); + } + n +} + +/// A page limit that the many-sided shape of `build` (`true`: stroked) passes with its stroke and +/// not without it. +fn stroke_passes_the_limit(build: impl Fn(bool) -> Document, o: &EpsOptions) -> usize { + let fill = crate::scene::Scene::new(&build(false), o).run().body.len(); + let both = crate::scene::Scene::new(&build(true), o).run().body.len(); + assert!(both > fill + 20_000, "{fill} {both}"); + fill + (both - fill) / 2 +} + +/// Art taken back for passing the page limit after it placed an image (a placed or a colorized +/// one) keeps no encoding of that image, and no cache entry names one. A later placement of the +/// image is encoded again and written where it is drawn, at both levels. +#[test] +fn a_rolled_back_node_frees_its_image_codes() { + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let build = |stroked: bool, after: bool| { + image_doc_with(|d| { + let fill = pic_pattern(d, pic); + let mut nodes = vec![many_sided(d, &[fill], stroked)]; + if after { + nodes.push(pic_node(d, pic, 20.0, 20.0, 4.0)); + } + nodes + }) + }; + let o = EpsOptions { level, ..opts(&build(false, false)) }; + let limit = stroke_passes_the_limit(|stroked| build(stroked, false), &o); + let (page, uses, cached) = crate::scene::Scene::new(&build(true, false), &o).with_max_body(limit).run_with_codes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!((uses, cached), (vec![], vec![]), "{case}"); + assert!(page.body.is_empty() && page.setup.is_empty(), "{case}: {} {}", page.setup, page.body); + let (page, uses, cached) = crate::scene::Scene::new(&build(true, true), &o).with_max_body(limit).run_with_codes(); + assert_eq!((uses, cached), (vec![1], vec![0]), "{case}"); + let alone = crate::scene::Scene::new(&image_doc_with(|d| vec![pic_node(d, pic, 20.0, 20.0, 4.0)]), &o).run(); + assert_eq!(alone.body.matches("} exec\n").count(), 1, "{case}: {}", alone.body); + assert_eq!((&page.setup, &page.body), (&alone.setup, &alone.body), "{case}"); + } + } +} + +/// A field encoded before an image in art taken back for passing the page limit is forgotten with +/// it. Two later fields sampled the same share one image, the first one the page keeps. +#[test] +fn a_rolled_back_image_does_not_pin_an_earlier_field_code() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let build = |stroked: bool, after: bool| { + image_doc_with(|d| { + let field = freeform(&[(60.0, 100.0, Color::rgb(0.0, 1.0, 0.0)), (140.0, 100.0, red)]); + let fills = [field, pic_pattern(d, pic)]; + let mut nodes = vec![many_sided(d, &fills, stroked)]; + if after { + nodes.extend([freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]); + } + nodes + }) + }; + let o = EpsOptions { level, ..opts(&build(false, false)) }; + let limit = stroke_passes_the_limit(|stroked| build(stroked, false), &o); + let (page, uses, cached) = crate::scene::Scene::new(&build(true, false), &o).with_max_body(limit).run_with_codes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!((uses, cached), (vec![], vec![]), "{case}"); + let (page, uses, cached) = crate::scene::Scene::new(&build(true, true), &o).with_max_body(limit).run_with_codes(); + assert_eq!((uses, cached), (vec![2], vec![0]), "{case}"); + assert_eq!(reuses(&page.body, 0, level), 2, "{case}: {}", page.body); + let alone = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]); + let alone = crate::scene::Scene::new(&alone, &o).run(); + assert_eq!((&page.setup, &page.body), (&alone.setup, &alone.body), "{case}"); + } + } +} + +/// An image whose first placement doesn't fit under the page limit is left out with a warning and +/// keeps no encoding, though the object that draws it fits. The art before it is written. +#[test] +fn an_image_rejected_by_the_page_limit_keeps_no_unused_code() { + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let d = image_doc_with(|d| vec![red_rect(d), pic_node(d, pic, 20.0, 20.0, 4.0)]); + let o = EpsOptions { level, ..opts(&d) }; + let rect = crate::scene::Scene::new(&image_doc_with(|d| vec![red_rect(d)]), &o).run(); + let full = crate::scene::Scene::new(&d, &o).run().body.len(); + let (page, uses, cached) = crate::scene::Scene::new(&d, &o).with_max_body(full - 1).run_with_codes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!((uses, cached), (vec![], vec![]), "{case}"); + assert_eq!((&page.setup, &page.body), (&rect.setup, &rect.body), "{case}"); + } + } +} + +/// An image turned away at the page limit is encoded once, not again at each later placement +/// that doesn't fit either. +#[test] +fn an_image_rejected_by_the_page_limit_is_encoded_once() { + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let d = image_doc_with(|d| { + let mut nodes = vec![red_rect(d)]; + nodes.extend((0..50).map(|k| pic_node(d, pic, f64::from(k), 20.0, 4.0))); + nodes + }); + let o = EpsOptions { level, ..opts(&d) }; + let rect = crate::scene::Scene::new(&image_doc_with(|d| vec![red_rect(d)]), &o).run(); + let (page, encodes) = crate::scene::Scene::new(&d, &o).with_max_body(rect.body.len() + 64).run_counting_encodes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!(encodes, 1, "{case}"); + assert_eq!((&page.setup, &page.body), (&rect.setup, &rect.body), "{case}"); + } + } +} + +/// An image turned away at the page limit inside art that is then taken back is encoded again +/// once the page has room for it, and written where it is drawn. +#[test] +fn an_image_rejected_in_art_taken_back_is_encoded_again_when_it_fits() { + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let build = |stroked: bool, after: bool| { + image_doc_with(|d| { + let fill = pic_pattern(d, pic); + let mut nodes = vec![many_sided(d, &[fill], stroked)]; + if after { + nodes.push(pic_node(d, pic, 20.0, 20.0, 4.0)); + } + nodes + }) + }; + let o = EpsOptions { level, ..opts(&build(false, false)) }; + let solid = image_doc_with(|d| vec![many_sided(d, &[Paint::solid(Color::rgb(1.0, 0.0, 0.0))], false)]); + // The shape's outline fits under this limit, and its first tile of the image doesn't. + let limit = crate::scene::Scene::new(&solid, &o).run().body.len() + 64; + let (page, encodes) = crate::scene::Scene::new(&build(false, false), &o).with_max_body(limit).run_counting_encodes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!((encodes, page.body.matches("} exec\n").count()), (1, 0), "{case}: {}", page.body); + let (page, encodes) = crate::scene::Scene::new(&build(true, true), &o).with_max_body(limit).run_counting_encodes(); + assert_eq!(encodes, 2, "{case}"); + let alone = crate::scene::Scene::new(&image_doc_with(|d| vec![pic_node(d, pic, 20.0, 20.0, 4.0)]), &o).run(); + assert_eq!((&page.setup, &page.body), (&alone.setup, &alone.body), "{case}"); + } + } +} + #[test] fn run_length_and_ascii85_round_trip() { let cycle: Vec = (0..=255u8).cycle().take(1000).collect(); diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index fc6a060a5a..bd004d2d1e 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] vectorcraft-doc = { workspace = true } +vectorcraft-brush = { workspace = true } craft-vectorcraft-format = { workspace = true } serde ={ workspace = true } serde_json = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 968cd720a0..9add0798c7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -52,6 +52,7 @@ use std::sync::Arc; use craft_vectorcraft_format::{Contents, Head, Image, ReadError, Tail}; use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}; use serde::{Deserializer, Serialize}; +use vectorcraft_brush::stored_art::StoredArt; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] @@ -243,18 +244,20 @@ fn body(doc: &Document, o: &SaveOptions) -> Result { // version reads them. d.guides_to_saved(); // Older apps don't know placed documents: they get the art the objects show, with its - // resources. Otherwise resources only exporting adds are never saved. - if version < VERSION && d.has_placed() { - d = d.with_placed_art().into_owned(); - d.placed_as_groups(); - } else { - d.drop_placed_resources(); - } + // resources. Otherwise resources only exporting adds are never saved. The images and placed + // documents in the stored brushes' art count as those in the layers do. + let linked = with_brush_art(&mut d, |d| { + if version < VERSION { + placed_as_groups(d); + } else { + d.drop_placed_resources(); + } + d.linked_only_images() + }); // The art placed documents became for older versions is rounded too. if let Some(decimals) = o.precision { d.round_geometry(decimals.min(MAX_PRECISION)); } - let linked = d.linked_only_images(); // The blobs go in the file's `images` (only those the document uses). let blobs = std::mem::take(&mut d.images); if version < 3 { @@ -277,6 +280,39 @@ fn body(doc: &Document, o: &SaveOptions) -> Result { Ok(Body { d, v1, blobs, linked }) } +/// `f` run on `d` with the art of its stored brushes set on it with the ids it is stored with +/// ([`StoredArt::mount_as_stored`]); the brushes whose art `f` changed are then written back into +/// the library, and the art is taken off `d` again. `f` runs on `d` as it is when no stored brush's +/// art holds an image or a placed document, or when art is set on `d` already. +pub fn with_brush_art(d: &mut Document, f: impl FnOnce(&mut Document) -> T) -> T { + if !d.mounted_art.is_empty() { + return f(d); + } + let art = StoredArt::read(d); + if art.is_empty() { + return f(d); + } + art.mount_as_stored(d); + let out = f(d); + art.write_back(d); + out +} + +/// Every placed document in `d`'s art, the art of its stored brushes included, replaced by a group +/// of the art it shows, with the resources that art uses, as a save for a version older than +/// [`VERSION`] writes it. Without a placed document, the resources only exporting adds are dropped +/// ([`Document::drop_placed_resources`]). +pub fn placed_as_groups(d: &mut Document) { + with_brush_art(d, |d| { + if d.has_placed() { + *d = d.with_placed_art().into_owned(); + d.placed_as_groups(); + } else { + d.drop_placed_resources(); + } + }); +} + /// Serialize a document (pretty = human-diffable) in the current version. Editing-mode working /// copies are left out ([`Document::without_edit_modes`]). pub fn save(doc: &Document, pretty: bool) -> Vec { diff --git a/crates/format/tests/it/links.rs b/crates/format/tests/it/links.rs index e5a789f0d1..557218a706 100644 --- a/crates/format/tests/it/links.rs +++ b/crates/format/tests/it/links.rs @@ -4,8 +4,9 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::{Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind}; -use vectorcraft_format::{base64_decode, load, save}; +use vectorcraft_brush::{Brush, BrushKind, PatternBrush, Scatter}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; +use vectorcraft_format::{SaveOptions, base64_decode, load, save, save_with}; use vectorcraft_geom::Affine; fn image(d: &mut Document, key: &str, link: Option) { @@ -59,3 +60,94 @@ fn link_details_round_trip_and_linked_only_images_save_their_preview() { let again: Value = serde_json::from_slice(&save(&back, false)).unwrap(); assert_eq!(again["images"]["linked"], v["images"]["linked"]); } + +/// An image object for a stored brush's art. +fn brush_image(key: &str, link: Option) -> Node { + let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default(), pixels: None }; + Node::new(NodeId(0), NodeKind::Image(Box::new(im))) +} + +/// Stores a scatter brush with `scatter` as its art and a pattern brush with `tile` as its side tile. +fn brushes(d: &mut Document, scatter: Node, tile: Node) { + vectorcraft_brush::store( + d, + &[ + Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: scatter, ..Default::default() }) }, + Brush { name: "Frame".into(), kind: BrushKind::Pattern(PatternBrush { side: tile, ..Default::default() }) }, + ], + ); +} + +/// `key`'s entry in the saved file `v`: its bytes and its `proxy` flag. +fn saved_image(v: &Value, key: &str) -> (Vec, Value) { + (base64_decode(v["images"][key]["data"].as_str().unwrap()).unwrap(), v["images"][key]["proxy"].clone()) +} + +/// A linked image shown only in a stored brush's art (a scatter brush's art, a pattern brush's tile) +/// is saved as its preview, as one in the layers is, and with Include Linked Files as its file's +/// bytes; the brushes are saved as they were, their links kept. +#[test] +fn brush_only_link_saves_preview_unless_included() { + let mut d = Document::new(100.0, 100.0); + d.images.insert("dot".into(), blob(&[1, 2, 3], Some(&[9]))); + d.images.insert("tile".into(), blob(&[4, 5, 6], Some(&[8]))); + brushes(&mut d, brush_image("dot", Some(LinkInfo::new("/work/dot.jpg"))), brush_image("tile", Some(LinkInfo::new("/work/tile.jpg")))); + let stored = d.unknown[vectorcraft_brush::DOC_KEY].clone(); + + let bytes = save(&d, false); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(saved_image(&v, "dot"), (vec![9], Value::Bool(true))); + assert_eq!(saved_image(&v, "tile"), (vec![8], Value::Bool(true))); + let back = load(&bytes).unwrap(); + assert_eq!(back.unknown[vectorcraft_brush::DOC_KEY], stored, "the brushes are saved as they were"); + assert!(back.mounted_art.is_empty()); + let mut links = vec![]; + for b in vectorcraft_brush::library(&back) { + for art in b.art() { + art.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + links.extend(im.link.as_ref().map(|l| l.path.clone())); + } + }); + } + } + links.sort(); + assert_eq!(links, ["/work/dot.jpg", "/work/tile.jpg"]); + + let included = save_with(&d, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap(); + let v: Value = serde_json::from_slice(&included).unwrap(); + assert_eq!(saved_image(&v, "dot"), (vec![1, 2, 3], Value::Null)); + assert_eq!(saved_image(&v, "tile"), (vec![4, 5, 6], Value::Null)); +} + +/// An image key shown linked in one place and embedded in another keeps its full bytes, whichever +/// of the layers and a stored brush's art embeds it; the links stay as they were. +#[test] +fn shared_brush_image_key_keeps_full_bytes_for_embedded_use() { + for layer_linked in [true, false] { + let mut d = Document::new(100.0, 100.0); + d.images.insert("k".into(), blob(&[1, 2, 3], Some(&[9]))); + let link = Some(LinkInfo::new("/work/k.jpg")); + let (in_layer, in_brush) = if layer_linked { (link, None) } else { (None, link) }; + image(&mut d, "k", in_layer.clone()); + brushes(&mut d, brush_image("k", in_brush.clone()), brush_image("k", in_brush.clone())); + let bytes = save(&d, false); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(saved_image(&v, "k"), (vec![1, 2, 3], Value::Null), "linked in the layer: {layer_linked}"); + let back = load(&bytes).unwrap(); + let mut layer_links = vec![]; + for l in &back.layers { + l.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + layer_links.push(im.link.clone()); + } + }); + } + assert_eq!(layer_links, [in_layer]); + let brush_links: Vec> = vectorcraft_brush::library(&back) + .iter() + .flat_map(|b| b.art().into_iter().map(|n| if let NodeKind::Image(im) = &n.kind { im.link.clone() } else { None }).collect::>()) + .collect(); + assert_eq!(brush_links, [in_brush.clone(), in_brush]); + } +} diff --git a/crates/format/tests/it/native_v3.rs b/crates/format/tests/it/native_v3.rs index 512025375e..afc3bea9d2 100644 --- a/crates/format/tests/it/native_v3.rs +++ b/crates/format/tests/it/native_v3.rs @@ -212,6 +212,44 @@ fn text_composer_round_trips() { assert!(!String::from_utf8_lossy(&save(&d2, false)).contains("composer")); } +/// Every composer reads back as it was saved (the two Standard ones as their own values), and the +/// ME&SA names are the ones files have always used: `everyLine` stays the unwritten default and +/// `singleLine` keeps its spelling, so a document that never picks a Standard composer is unchanged. +#[test] +fn all_four_composers_round_trip() { + use vectorcraft_doc::{TextKind, TextObject}; + let one = |c: Composer| { + let mut d = Document::new(200.0, 200.0); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "ક\u{ACD}ક", Default::default()); + t.para.composer = c; + t.kind = TextKind::Point; + let id = d.alloc_id(); + d.insert(None, usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + d + }; + for (c, written) in [ + (Composer::SingleLine, Some("singleLine")), + (Composer::EveryLine, None), + (Composer::StandardSingleLine, Some("standardSingleLine")), + (Composer::StandardEveryLine, Some("standardEveryLine")), + ] { + let bytes = save(&one(c), false); + let text = String::from_utf8_lossy(&bytes); + match written { + Some(w) => assert!(text.contains(&format!("\"composer\":\"{w}\"")), "{c:?}"), + None => assert!(!text.contains("\"composer\""), "{c:?}"), + } + let back = load(&bytes).unwrap(); + let mut got = None; + back.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + got = Some(t.para.composer); + } + }); + assert_eq!(got, Some(c)); + } +} + /// A document as the first version wrote it (format name `drawcraft`, anchors as maps). const V1_FILE: &str = r#"{"format":"drawcraft","version":1,"generator":"DrawCraft 0.1.0","document":{"version":1,"title":"Old","artboards":[{"id":1,"name":"Artboard 1","rect":{"x0":0.0,"y0":0.0,"x1":100.0,"y1":80.0}}], "layers":[{"id":1,"name":"Layer 1","visible":true,"locked":false,"opacity":1.0,"kind":{"type":"layer","children":[ diff --git a/crates/geom/src/anchors.rs b/crates/geom/src/anchors.rs new file mode 100644 index 0000000000..57b8081686 --- /dev/null +++ b/crates/geom/src/anchors.rs @@ -0,0 +1,790 @@ +//! The anchors of a subpath, held in shared chunks. +//! +//! [`Anchors`] keeps its anchors in chunks of at most [`CHUNK`] anchors, each behind an `Arc`. +//! Copying the list copies the table of chunks and shares the chunks themselves; changing an +//! anchor copies only the chunk that holds it when another copy still shares that chunk. An undo +//! step that keeps the version of a path before a one-anchor edit then keeps one chunk of its +//! own, however many anchors the path has. Inserting or removing an anchor copies the chunk it is +//! in and rebuilds the table, which has one entry per chunk. +//! +//! The list reads and writes as one flat sequence of anchors, as a `Vec` does. + +use std::borrow::Cow; +use std::fmt; +use std::ops::{Index, IndexMut, RangeBounds}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::path::Anchor; + +/// An [`Anchors`] list of the anchors given, as `vec!` makes a `Vec`. +#[macro_export] +macro_rules! anchors { + ($($t:tt)*) => { + $crate::Anchors::from(vec![$($t)*]) + }; +} + +/// Most anchors one chunk holds. +pub const CHUNK: usize = 256; + +/// The anchors of a subpath: a sequence with the reads of a slice and the edits of a `Vec`, whose +/// copies share the chunks they have not changed. +#[derive(Clone, Default)] +pub struct Anchors { + /// The chunks in order, none of them empty. + chunks: Vec>>, + /// Empty while every chunk but the last holds [`CHUNK`] anchors (anchor `i` is then in chunk + /// `i / CHUNK`). Otherwise `ends[c]` is the number of anchors in chunks `0..=c`. + ends: Vec, + /// The number of anchors. + len: usize, +} + +impl Anchors { + /// An empty list. + pub const fn new() -> Self { + Self { chunks: Vec::new(), ends: Vec::new(), len: 0 } + } + + /// The number of anchors. + pub fn len(&self) -> usize { + self.len + } + + /// Whether the list has no anchors. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// The chunk that holds anchor `i` and its place in that chunk. For `i` at or past the end the + /// chunk or the place is out of range. + fn locate(&self, i: usize) -> (usize, usize) { + if self.ends.is_empty() { + (i / CHUNK, i % CHUNK) + } else { + let c = self.ends.partition_point(|&e| e <= i); + let start = c.checked_sub(1).and_then(|p| self.ends.get(p)).copied().unwrap_or(0); + (c, i.saturating_sub(start)) + } + } + + /// Anchor `i`, or `None` past the end. + pub fn get(&self, i: usize) -> Option<&Anchor> { + if i >= self.len { + return None; + } + let (c, o) = self.locate(i); + self.chunks.get(c)?.get(o) + } + + /// Anchor `i` to change, or `None` past the end. Copies the chunk that holds it when another + /// list shares that chunk. + pub fn get_mut(&mut self, i: usize) -> Option<&mut Anchor> { + if i >= self.len { + return None; + } + let (c, o) = self.locate(i); + Arc::make_mut(self.chunks.get_mut(c)?).get_mut(o) + } + + /// The first anchor. + pub fn first(&self) -> Option<&Anchor> { + self.chunks.first()?.first() + } + + /// The last anchor. + pub fn last(&self) -> Option<&Anchor> { + self.chunks.last()?.last() + } + + /// The first anchor to change. + pub fn first_mut(&mut self) -> Option<&mut Anchor> { + Arc::make_mut(self.chunks.first_mut()?).first_mut() + } + + /// The last anchor to change. + pub fn last_mut(&mut self) -> Option<&mut Anchor> { + Arc::make_mut(self.chunks.last_mut()?).last_mut() + } + + /// The anchors in order. + pub fn iter(&self) -> Iter<'_> { + let (front, back): (&[Anchor], &[Anchor]) = (&[], &[]); + Iter { chunks: self.chunks.iter(), front: front.iter(), back: back.iter(), left: self.len } + } + + /// The anchors in order, to change. Each chunk is copied, when another list shares it, as the + /// iterator reaches it. + pub fn iter_mut(&mut self) -> IterMut<'_> { + let chunk: fn(&mut Arc>) -> std::slice::IterMut<'_, Anchor> = |c| Arc::make_mut(c).iter_mut(); + self.chunks.iter_mut().flat_map(chunk) + } + + /// The anchors as one slice: borrowed when they are in one chunk, copied otherwise. + pub fn contiguous(&self) -> Cow<'_, [Anchor]> { + match self.chunks.as_slice() { + [] => Cow::Borrowed(&[]), + [one] => Cow::Borrowed(one.as_slice()), + _ => Cow::Owned(self.to_vec()), + } + } + + /// The anchors in `range` copied into a `Vec`, or `None` when the range is out of bounds or + /// starts after it ends (as `slice::get` with a range). + pub fn get_range(&self, range: impl RangeBounds) -> Option> { + use std::ops::Bound; + let start = match range.start_bound() { + Bound::Included(&s) => s, + Bound::Excluded(&s) => s.checked_add(1)?, + Bound::Unbounded => 0, + }; + let end = match range.end_bound() { + Bound::Included(&e) => e.checked_add(1)?, + Bound::Excluded(&e) => e, + Bound::Unbounded => self.len, + }; + (start <= end && end <= self.len).then(|| self.iter().skip(start).take(end - start).copied().collect()) + } + + /// The anchors copied into a `Vec`. + pub fn to_vec(&self) -> Vec { + let mut v = Vec::with_capacity(self.len); + for c in &self.chunks { + v.extend_from_slice(c); + } + v + } + + /// The anchors as a `Vec`, without a copy when they are in one chunk no other list shares. + pub fn into_vec(mut self) -> Vec { + if self.chunks.len() == 1 + && let Some(one) = self.chunks.pop() + { + return Arc::try_unwrap(one).unwrap_or_else(|shared| (*shared).clone()); + } + self.to_vec() + } + + /// Change the anchors as a `Vec` (for edits that move many of them, such as reversing). + pub fn with_vec(&mut self, f: impl FnOnce(&mut Vec) -> R) -> R { + let mut v = std::mem::take(self).into_vec(); + let r = f(&mut v); + *self = Self::from(v); + r + } + + /// Rebuild `ends` and `len` after the chunks changed, dropping empty chunks. + fn reindex(&mut self) { + self.chunks.retain(|c| !c.is_empty()); + self.len = self.chunks.iter().map(|c| c.len()).sum(); + let n = self.chunks.len(); + let uniform = self.chunks.iter().enumerate().all(|(k, c)| if k + 1 == n { c.len() <= CHUNK } else { c.len() == CHUNK }); + self.ends.clear(); + if !uniform { + let mut total = 0; + self.ends.extend(self.chunks.iter().map(|c| { + total += c.len(); + total + })); + } + } + + /// Add `a` at the end. + pub fn push(&mut self, a: Anchor) { + match self.chunks.last_mut() { + Some(last) if last.len() < CHUNK => Arc::make_mut(last).push(a), + _ => { + self.chunks.push(Arc::new(vec![a])); + if !self.ends.is_empty() { + self.ends.push(self.len); + } + } + } + self.len += 1; + if let Some(end) = self.ends.last_mut() { + *end = self.len; + } + } + + /// Remove the last anchor. + pub fn pop(&mut self) -> Option { + let last = self.chunks.last_mut()?; + let a = Arc::make_mut(last).pop(); + if last.is_empty() { + self.chunks.pop(); + self.ends.pop(); + } + self.len = self.len.saturating_sub(1); + if let Some(end) = self.ends.last_mut() { + *end = self.len; + } + a + } + + /// Insert `a` before anchor `i`, or at the end when `i` is the length or more. + pub fn insert(&mut self, i: usize, a: Anchor) { + if i >= self.len { + self.push(a); + return; + } + let (c, o) = self.locate(i); + let Some(chunk) = self.chunks.get_mut(c) else { return }; + let chunk = Arc::make_mut(chunk); + chunk.insert(o.min(chunk.len()), a); + if chunk.len() > CHUNK { + let tail = chunk.split_off(chunk.len() / 2); + self.chunks.insert(c + 1, Arc::new(tail)); + } + self.reindex(); + } + + /// Remove anchor `i`, or `None` past the end. + pub fn try_remove(&mut self, i: usize) -> Option { + if i >= self.len { + return None; + } + let (c, o) = self.locate(i); + let chunk = Arc::make_mut(self.chunks.get_mut(c)?); + let a = (o < chunk.len()).then(|| chunk.remove(o)); + // A chunk left small joins the next one when the two fit in one chunk. + if chunk.len() < CHUNK / 4 + && let Some(next) = self.chunks.get(c + 1) + && self.chunks.get(c).map_or(0, |c| c.len()) + next.len() <= CHUNK + { + let next = self.chunks.remove(c + 1); + if let Some(chunk) = self.chunks.get_mut(c) { + Arc::make_mut(chunk).extend_from_slice(&next); + } + } + self.reindex(); + a + } + + /// Remove anchor `i`. Panics past the end, as `Vec::remove` does. + pub fn remove(&mut self, i: usize) -> Anchor { + match self.try_remove(i) { + Some(a) => a, + None => { + let none: &[Anchor] = &[]; + none[i] + } + } + } + + /// Keep the first `n` anchors. + pub fn truncate(&mut self, n: usize) { + if n >= self.len { + return; + } + let (c, o) = self.locate(n); + self.chunks.truncate(c + 1); + if let Some(last) = self.chunks.last_mut() { + Arc::make_mut(last).truncate(o); + } + self.reindex(); + } + + /// Remove every anchor. + pub fn clear(&mut self) { + *self = Self::new(); + } + + /// Add the anchors of `s` at the end. + pub fn extend_from_slice(&mut self, s: &[Anchor]) { + self.extend(s.iter().copied()); + } + + /// Keep the anchors for which `f` is true. + pub fn retain(&mut self, f: impl FnMut(&Anchor) -> bool) { + self.with_vec(|v| v.retain(f)); + } + + /// Keep the anchors for which `f` is true, letting `f` change them. + pub fn retain_mut(&mut self, f: impl FnMut(&mut Anchor) -> bool) { + self.with_vec(|v| v.retain_mut(f)); + } + + /// Reverse the order of the anchors. + pub fn reverse(&mut self) { + self.with_vec(|v| v.reverse()); + } + + /// Rotate the anchors `k` places towards the start. + pub fn rotate_left(&mut self, k: usize) { + self.with_vec(|v| v.rotate_left(k)); + } + + /// Rotate the anchors `k` places towards the end. + pub fn rotate_right(&mut self, k: usize) { + self.with_vec(|v| v.rotate_right(k)); + } + + /// Remove the anchors in `range` and return them. + pub fn drain(&mut self, range: impl RangeBounds) -> std::vec::IntoIter { + self.with_vec(|v| v.drain(range).collect::>()).into_iter() + } + + /// Replace the anchors in `range` with those of `with`, and return the anchors replaced. + pub fn splice(&mut self, range: impl RangeBounds, with: impl IntoIterator) -> std::vec::IntoIter { + self.with_vec(|v| v.splice(range, with).collect::>()).into_iter() + } + + /// The pairs of anchors in the same place in `self` and `other`, with that place, in order. + /// When the two are chunked alike, the chunks they share in the same place are skipped (their + /// anchors are the same); otherwise every pair is visited. Stops at the end of the shorter list. + pub fn pairs_not_shared<'a>(&'a self, other: &'a Self) -> impl Iterator + 'a { + let alike = self.len == other.len && self.chunks.len() == other.chunks.len() && self.ends == other.ends; + let chunked = alike.then(|| { + let starts = self.chunks.iter().scan(0, |start, c| { + let at = *start; + *start += c.len(); + Some(at) + }); + starts + .zip(self.chunks.iter().zip(&other.chunks)) + .filter(|(_, (a, b))| !Arc::ptr_eq(a, b)) + .flat_map(|(at, (a, b))| a.iter().zip(b.iter()).enumerate().map(move |(o, (x, y))| (at + o, x, y))) + }); + let flat = (!alike).then(|| self.iter().zip(other.iter()).enumerate().map(|(i, (a, b))| (i, a, b))); + chunked.into_iter().flatten().chain(flat.into_iter().flatten()) + } + + /// Each chunk's address and capacity (in anchors): two lists hold a chunk in common when they + /// give the same address. For memory estimates. + pub fn chunk_allocations(&self) -> impl Iterator + '_ { + self.chunks.iter().map(|c| (Arc::as_ptr(c) as usize, c.capacity())) + } + + /// The bytes the list's own tables hold on the heap (the chunk table and the running totals), + /// without the chunks. + pub fn table_bytes(&self) -> usize { + self.chunks.capacity() * size_of::>>() + self.ends.capacity() * size_of::() + } +} + +/// [`Anchors::iter_mut`]'s iterator. +pub type IterMut<'a> = std::iter::FlatMap< + std::slice::IterMut<'a, Arc>>, + std::slice::IterMut<'a, Anchor>, + fn(&mut Arc>) -> std::slice::IterMut<'_, Anchor>, +>; + +/// [`Anchors::iter`]'s iterator. +#[derive(Clone)] +pub struct Iter<'a> { + /// The chunks neither end has reached. + chunks: std::slice::Iter<'a, Arc>>, + /// What is left of the chunk the front is in. + front: std::slice::Iter<'a, Anchor>, + /// What is left of the chunk the back is in. + back: std::slice::Iter<'a, Anchor>, + /// The anchors left. + left: usize, +} + +impl<'a> Iterator for Iter<'a> { + type Item = &'a Anchor; + + fn next(&mut self) -> Option<&'a Anchor> { + loop { + if let Some(a) = self.front.next() { + self.left -= 1; + return Some(a); + } + match self.chunks.next() { + Some(c) => self.front = c.iter(), + None => { + let a = self.back.next()?; + self.left -= 1; + return Some(a); + } + } + } + } + + fn size_hint(&self) -> (usize, Option) { + (self.left, Some(self.left)) + } + + fn nth(&mut self, mut n: usize) -> Option<&'a Anchor> { + // Whole chunks are skipped without visiting their anchors. + loop { + let f = self.front.len(); + if n < f { + self.left -= n + 1; + return self.front.nth(n); + } + n -= f; + self.left -= f; + self.front = Default::default(); + match self.chunks.as_slice().first() { + Some(c) if c.len() <= n => { + n -= c.len(); + self.left -= c.len(); + self.chunks.next(); + } + Some(_) => { + if let Some(c) = self.chunks.next() { + self.front = c.iter(); + } + } + None => { + let b = self.back.len(); + if n < b { + self.left -= n + 1; + return self.back.nth(n); + } + self.left -= b; + self.back = Default::default(); + return None; + } + } + } + } + + fn count(self) -> usize { + self.left + } + + fn last(mut self) -> Option<&'a Anchor> { + self.next_back() + } +} + +impl<'a> DoubleEndedIterator for Iter<'a> { + fn next_back(&mut self) -> Option<&'a Anchor> { + loop { + if let Some(a) = self.back.next_back() { + self.left -= 1; + return Some(a); + } + match self.chunks.next_back() { + Some(c) => self.back = c.iter(), + None => { + let a = self.front.next_back()?; + self.left -= 1; + return Some(a); + } + } + } + } +} + +impl ExactSizeIterator for Iter<'_> {} + +impl std::iter::FusedIterator for Iter<'_> {} + +impl Index for Anchors { + type Output = Anchor; + + /// Anchor `i`. Panics past the end, as indexing a slice does. + fn index(&self, i: usize) -> &Anchor { + let (c, o) = self.locate(i); + &self.chunks[c][o] + } +} + +impl IndexMut for Anchors { + /// Anchor `i` to change ([`Anchors::get_mut`]). Panics past the end, as indexing a slice does. + fn index_mut(&mut self, i: usize) -> &mut Anchor { + let (c, o) = self.locate(i); + &mut Arc::make_mut(&mut self.chunks[c])[o] + } +} + +impl From> for Anchors { + fn from(v: Vec) -> Self { + if v.is_empty() { + return Self::new(); + } + let len = v.len(); + let chunks = if len <= CHUNK { vec![Arc::new(v)] } else { v.chunks(CHUNK).map(|c| Arc::new(c.to_vec())).collect() }; + Self { chunks, ends: Vec::new(), len } + } +} + +impl From<&[Anchor]> for Anchors { + fn from(s: &[Anchor]) -> Self { + Self::from(s.to_vec()) + } +} + +impl From<[Anchor; N]> for Anchors { + fn from(s: [Anchor; N]) -> Self { + Self::from(Vec::from(s)) + } +} + +impl From for Vec { + fn from(a: Anchors) -> Self { + a.into_vec() + } +} + +impl FromIterator for Anchors { + fn from_iter>(it: I) -> Self { + Self::from(it.into_iter().collect::>()) + } +} + +impl Extend for Anchors { + fn extend>(&mut self, it: I) { + for a in it { + self.push(a); + } + } +} + +impl<'a> Extend<&'a Anchor> for Anchors { + fn extend>(&mut self, it: I) { + self.extend(it.into_iter().copied()); + } +} + +impl<'a> IntoIterator for &'a Anchors { + type Item = &'a Anchor; + type IntoIter = Iter<'a>; + + fn into_iter(self) -> Iter<'a> { + self.iter() + } +} + +impl<'a> IntoIterator for &'a mut Anchors { + type Item = &'a mut Anchor; + type IntoIter = IterMut<'a>; + + fn into_iter(self) -> IterMut<'a> { + self.iter_mut() + } +} + +impl IntoIterator for Anchors { + type Item = Anchor; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> std::vec::IntoIter { + self.into_vec().into_iter() + } +} + +impl PartialEq for Anchors { + fn eq(&self, other: &Self) -> bool { + self.len == other.len && self.iter().eq(other.iter()) + } +} + +impl PartialEq<[Anchor]> for Anchors { + fn eq(&self, other: &[Anchor]) -> bool { + self.len == other.len() && self.iter().eq(other.iter()) + } +} + +impl PartialEq<&[Anchor]> for Anchors { + fn eq(&self, other: &&[Anchor]) -> bool { + *self == **other + } +} + +impl PartialEq> for Anchors { + fn eq(&self, other: &Vec) -> bool { + *self == **other + } +} + +impl PartialEq<[Anchor; N]> for Anchors { + fn eq(&self, other: &[Anchor; N]) -> bool { + *self == other[..] + } +} + +impl PartialEq for [Anchor] { + fn eq(&self, other: &Anchors) -> bool { + *other == *self + } +} + +impl PartialEq for Vec { + fn eq(&self, other: &Anchors) -> bool { + *other == **self + } +} + +impl fmt::Debug for Anchors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list().entries(self.iter()).finish() + } +} + +impl Serialize for Anchors { + fn serialize(&self, s: S) -> Result { + s.collect_seq(self.iter()) + } +} + +impl<'de> Deserialize<'de> for Anchors { + fn deserialize>(d: D) -> Result { + Vec::::deserialize(d).map(Self::from) + } +} + +#[cfg(test)] +mod tests { + use kurbo::Point; + + use super::*; + + fn line(n: usize) -> Vec { + (0..n).map(|i| Anchor::corner(Point::new(i as f64, (i % 7) as f64))).collect() + } + + /// Chunks of `a` that `b` does not hold, with their capacity in anchors. + fn own_chunks(a: &Anchors, b: &Anchors) -> Vec { + let theirs: Vec = b.chunk_allocations().map(|(id, _)| id).collect(); + a.chunk_allocations().filter(|(id, _)| !theirs.contains(id)).map(|(_, cap)| cap).collect() + } + + #[test] + fn editing_one_anchor_copies_one_chunk_whatever_the_length() { + for n in [10_000, 200_000] { + let before = Anchors::from(line(n)); + let mut after = before.clone(); + after[n / 2].p = Point::new(-1.0, -1.0); + if let Some(a) = after.get_mut(n / 2 + 1) { + a.h_out = Point::new(-2.0, -2.0); + } + let copied = own_chunks(&after, &before); + assert_eq!(copied.len(), 1, "{n} anchors: one chunk copied"); + assert!(copied.iter().all(|&cap| cap <= CHUNK), "{n} anchors: {copied:?}"); + assert_eq!(own_chunks(&before, &after).len(), 1, "{n} anchors: the old version keeps one chunk of its own"); + // The old version is as it was; the new one differs only at the two anchors. + assert_eq!(before, line(n)); + let mut expected = line(n); + expected[n / 2].p = Point::new(-1.0, -1.0); + expected[n / 2 + 1].h_out = Point::new(-2.0, -2.0); + assert_eq!(after, expected); + } + } + + #[test] + fn inserting_and_removing_copy_only_the_chunk_touched() { + let n = 200_000; + let before = Anchors::from(line(n)); + let mut after = before.clone(); + let extra = Anchor::corner(Point::new(0.5, 0.5)); + after.insert(n / 2, extra); + assert!(own_chunks(&after, &before).len() <= 2, "the chunk split in two"); + assert_eq!(after.len(), n + 1); + assert_eq!(after[n / 2], extra); + assert_eq!(after[n / 2 + 1], line(n)[n / 2]); + assert_eq!(after.remove(n / 2), extra); + assert_eq!(after, line(n)); + assert!(own_chunks(&after, &before).len() <= 2); + assert_eq!(before, line(n)); + } + + #[test] + fn acts_as_a_vec_through_edits() { + // A run of edits applied to an `Anchors` and to a `Vec` leaves the two the same, across + // chunk splits, joins and the uniform and non-uniform layouts. + let mut v = line(700); + let mut a = Anchors::from(v.clone()); + let mut seed = 7u64; + let mut next = |m: usize| { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (seed >> 33) as usize % m.max(1) + }; + for step in 0..3000 { + let x = Anchor::corner(Point::new(step as f64, 0.25)); + match next(8) { + 0 | 1 => { + let i = next(v.len() + 1); + v.insert(i, x); + a.insert(i, x); + } + 2 | 3 if !v.is_empty() => { + let i = next(v.len()); + assert_eq!(v.remove(i), a.remove(i)); + } + 4 => { + v.push(x); + a.push(x); + } + 5 => assert_eq!(v.pop(), a.pop()), + 6 if !v.is_empty() => { + let i = next(v.len()); + v[i].h_in = x.p; + a[i].h_in = x.p; + } + _ => { + let keep = v.len().saturating_sub(next(3)); + v.truncate(keep); + a.truncate(keep); + } + } + assert_eq!(a.len(), v.len(), "step {step}"); + if step % 50 == 0 { + assert_eq!(a, v, "step {step}"); + for i in 0..v.len() { + assert_eq!(a.get(i), v.get(i), "step {step}, anchor {i}"); + } + } + } + assert_eq!(a, v); + assert_eq!(a.iter().rev().copied().collect::>(), v.iter().rev().copied().collect::>()); + assert_eq!(a.get(v.len()), None); + } + + #[test] + fn iterators_skip_and_count_as_a_slice_does() { + let v = line(1000); + let a = Anchors::from(v.clone()); + for skip in [0, 1, 255, 256, 257, 999, 1000, 1001] { + assert_eq!(a.iter().nth(skip), v.get(skip), "nth {skip}"); + assert_eq!(a.iter().skip(skip).count(), v.iter().skip(skip).count(), "skip {skip}"); + assert_eq!(a.iter().skip(skip).len(), v.iter().skip(skip).len(), "len after {skip}"); + } + let mut both = a.iter(); + let mut vs = v.iter(); + for k in 0..1000 { + let (x, y) = if k % 3 == 0 { (both.next_back(), vs.next_back()) } else { (both.next(), vs.next()) }; + assert_eq!(x, y, "step {k}"); + assert_eq!(both.len(), vs.len()); + } + assert_eq!(both.next(), None); + assert_eq!(a.iter().last(), v.last()); + let mut m = a.clone(); + m.iter_mut().for_each(|x| x.p.x += 1.0); + assert!(m.iter().zip(&v).all(|(x, y)| x.p.x == y.p.x + 1.0)); + assert_eq!(a, v, "changing a copy left the original alone"); + } + + #[test] + fn reads_and_writes_as_a_flat_list() { + let v = line(600); + let a = Anchors::from(v.clone()); + assert_eq!(serde_json::to_string(&a).ok(), serde_json::to_string(&v).ok()); + let back: Anchors = serde_json::from_str(&serde_json::to_string(&v).unwrap_or_default()).unwrap_or_default(); + assert_eq!(back, v); + assert_eq!(format!("{a:?}"), format!("{v:?}")); + } + + #[test] + fn pairs_not_shared_skip_the_chunks_both_hold() { + let before = Anchors::from(line(10_000)); + let mut after = before.clone(); + after[5000].p = Point::new(-3.0, 0.0); + let pairs: Vec<_> = before.pairs_not_shared(&after).collect(); + assert!(pairs.len() <= CHUNK); + let changed: Vec = pairs.iter().filter(|(_, a, b)| a != b).map(|(i, _, _)| *i).collect(); + assert_eq!(changed, [5000]); + assert!(pairs.iter().all(|&(i, a, b)| *a == before[i] && *b == after[i])); + // Chunked differently: every pair. + let mut moved = before.clone(); + moved.insert(0, Anchor::corner(Point::ZERO)); + moved.remove(0); + assert_eq!(before.pairs_not_shared(&moved).count(), 10_000); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 12a7492e7f..7d3e3f0f4b 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -7,6 +7,7 @@ //! Coordinates are document points (1/72 in), y pointing down. #![forbid(unsafe_code)] +pub mod anchors; pub mod arc; pub mod bez; pub mod corners; @@ -17,6 +18,7 @@ pub mod recognize; pub mod shapes; pub mod snap; +pub use anchors::Anchors; pub use arc::ArcPath; pub use kurbo; pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2}; diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index bef18334ae..c5343171ef 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -6,6 +6,7 @@ use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveExtrema, ParamCurve use serde::{Deserialize, Serialize}; use crate::EPS; +use crate::anchors::Anchors; /// Fill rule for paths and compound paths. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -224,15 +225,15 @@ pub fn with_anchor_budget(max: usize, f: impl FnOnce() -> T) -> T { } /// A subpath's anchors read within [`with_anchor_budget`]. -fn anchors_within<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { +fn anchors_within<'de, D: serde::Deserializer<'de>>(d: D) -> Result { struct V; impl<'de> serde::de::Visitor<'de> for V { - type Value = Vec; + type Value = Anchors; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("a list of anchors") } - fn visit_seq>(self, mut a: A) -> Result, A::Error> { - let mut out = Vec::new(); + fn visit_seq>(self, mut a: A) -> Result { + let mut out = Anchors::new(); while let Some(anchor) = a.next_element::()? { if let Some(left) = ANCHORS_LEFT.get() { let left = left @@ -262,13 +263,17 @@ pub fn measured_segments() -> u64 { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct SubPath { #[serde(deserialize_with = "anchors_within")] - pub anchors: Vec, + pub anchors: Anchors, #[serde(default)] pub closed: bool, } impl SubPath { pub fn new(anchors: Vec, closed: bool) -> Self { + Self { anchors: anchors.into(), closed } + } + /// A subpath of `anchors`, sharing their chunks. + pub fn of(anchors: Anchors, closed: bool) -> Self { Self { anchors, closed } } /// Polyline from points. @@ -442,7 +447,7 @@ impl SubPath { let (run, cuts): (Vec, BTreeSet) = if self.closed { (self.anchors.iter().cycle().skip(first).take(n + 1).copied().collect(), cuts.map(|c| (c + n - first) % n).chain([n]).collect()) } else { - (self.anchors.clone(), cuts.collect()) + (self.anchors.to_vec(), cuts.collect()) }; let mut pieces = vec![]; let mut cur: Vec = vec![]; @@ -511,11 +516,11 @@ impl PathData { { subpaths.push(sp); } - cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false }); + cur = Some(SubPath::new(vec![Anchor::corner(p)], false)); last = p; } PathEl::LineTo(p) => { - let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false }); + let sp = cur.get_or_insert_with(|| SubPath::new(vec![Anchor::corner(last)], false)); sp.anchors.push(Anchor::corner(p)); last = p; } @@ -752,7 +757,7 @@ pub fn segment_bounds(seg: PathSeg) -> Rect { } fn push_curve(cur: &mut Option, last: Point, c1: Point, c2: Point, p: Point) { - let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false }); + let sp = cur.get_or_insert_with(|| SubPath::new(vec![Anchor::corner(last)], false)); if let Some(a) = sp.anchors.last_mut() { a.h_out = c1; } diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 3baf55f147..0ae8a75190 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -814,7 +814,7 @@ mod tests { for decay in [20.0, 30.0, 50.0, 80.0, 95.0] { for clockwise in [true, false] { let p = spiral(center, 100.0, decay, 8, clockwise); - let anchors = &p.subpaths[0].anchors; + let anchors = &p.subpaths[0].anchors.to_vec(); let k = (decay / 100.0_f64).ln() / TAU; let mut worst = 0.0_f64; for pair in anchors.windows(2) { diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c41e6fe86f..ecbdf780fb 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -392,7 +392,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "type.lineComposer", "Toggle Line Composer", "Cmd+Alt+Shift+C", - "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer (text.setFormat {composer}) → \"singleLine\" | \"everyLine\"", + "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer; paragraphs that all use a Standard composer keep it, any other mix takes the Middle Eastern & South Asian one (text.setFormat {composer}) → \"singleLine\" | \"everyLine\" | \"standardSingleLine\" | \"standardEveryLine\"", ), ( "type.hyphenate", @@ -432,6 +432,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("view.nextArtboard", "Next Artboard", "", "{} the navigator's Next Artboard button: view.goToArtboard {index: \"next\"} → {index}"), ("view.lastArtboard", "Last Artboard", "", "{} the navigator's Last Artboard button: view.goToArtboard {index: \"last\"} → {index}"), + ( + "view.statusBarShow", + "Status Bar Show", + "", + "{show: \"artboardName\" | \"currentTool\" | \"dateTime\" | \"undos\" | \"colorProfile\"} the status bar's Show menu (a click on its info field): the field shows the name of the artboard the navigator shows, the tool in use (the default), the date and time (local on the desktop, UTC on the web), the active document's undo and redo step counts, or the profile its colors use (the one assigned for its color mode, else, or when that one is not available, that mode's working space); kept with the UI state; any other value is an error → show", + ), ("view.edges", "Hide Edges", "Cmd+H", "{}"), ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), ( @@ -612,6 +618,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", ), + ( + "window.panels.peek", + "Show Hidden Panels at Edge", + "", + "{side: left|right|null} while Tab or Shift+Tab hides the panels, show the docked panels on that side of the canvas over its edge, as resting the pointer on that edge of the window for 0.3 s does; they hide again 0.3 s after the pointer moves away from them, and null hides them now. Floating panels, the toolbar and the Control bar stay hidden, and Tab or Shift+Tab shows the panels for good. Fails and changes nothing while the panels show, in Presentation Mode, for any other side, or when no panel is docked on that side; returns the side shown or null", + ), ( "window.panel", "Show Panel", @@ -955,7 +967,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil, paintbrush and smooth, the same at any zoom; pt for blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), cursorSwatchPreview (on: the active fill or stroke shows above the pointer between the color swatches before and after it; Left and Right without modifiers step it through the color swatches whether it is on or off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph, or for the series picked in it with Group Selection (dialog `graphType`, fields the options graph.setType {} returns under their names, with legend, pieLegend, piePosition, pieSort, firstRowInFront, firstColumnInFront and filledLines held for every type; overrideValues and rightOverrideValues, Override Calculated Values of the left or only value axis and of the right one: while one is on OK sends axisMin, axisMax and ticks, or rightAxisMin, rightAxisMax and rightTicks, filled with the calculated values where the graph has none, and one turned off sends null, null and 0; __page: options|valueAxis|rightAxis|categoryAxis, the page shown, options when the type picked has no such page; OK runs graph.setType, and with nothing changed closes without a step of history; an overridden value axis whose maximum is not more than its minimum is an error and the dialog stays open; an error without a selected graph), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil, paintbrush and smooth, the same at any zoom; pt for blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), cursorSwatchPreview (on: the active fill or stroke shows above the pointer between the color swatches before and after it; Left and Right without modifiers step it through the color swatches whether it is on or off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", ), ( "ui.colorGuideOptions", @@ -1609,7 +1621,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.actionSet.load", "Load Actions…", "", - "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file. A Set Selection step (found by its internal name, whatever its label) comes in as select.byNote {note, caseSensitive, wholeWord} when it has exactly three recorded values: text (ustring, UTF-8; 1 to 1,024 bytes once trimmed), word and case (boolean, 0 or 1); with any other values it is left out. The other steps, every other step with no label and every other step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", ), ( "ui.actionSet.delete", diff --git a/crates/pathops/src/addons/cleanup.rs b/crates/pathops/src/addons/cleanup.rs index 75cf45b583..b0c000be9a 100644 --- a/crates/pathops/src/addons/cleanup.rs +++ b/crates/pathops/src/addons/cleanup.rs @@ -104,7 +104,7 @@ pub fn merge_duplicate_anchors(sp: &mut SubPath, distance: f64) -> usize { { *f = Anchor::with_handles(f.p, moved(f.p, last.p, last.h_in), f.h_out); } - sp.anchors = out; + sp.anchors = out.into(); before - sp.anchors.len() } diff --git a/crates/pathops/src/addons/keep_shape.rs b/crates/pathops/src/addons/keep_shape.rs index a3721f8f22..12ba4b21df 100644 --- a/crates/pathops/src/addons/keep_shape.rs +++ b/crates/pathops/src/addons/keep_shape.rs @@ -27,7 +27,7 @@ const ACCURACY: f64 = 1e-6; fn merged(prev: &Anchor, cur: &Anchor, next: &Anchor) -> CubicBez { let mut run = SubPath::new(vec![*prev, *cur, *next], false); remove_anchor(&mut run, 1); - match run.anchors.as_slice() { + match &*run.anchors.contiguous() { [a, b] => CubicBez::new(prev.p, a.h_out, b.h_in, next.p), _ => CubicBez::new(prev.p, prev.h_out, next.h_in, next.p), } diff --git a/crates/pathops/src/addons/pathtools/shared_edge.rs b/crates/pathops/src/addons/pathtools/shared_edge.rs index 6b4e8bfb1e..5acee01def 100644 --- a/crates/pathops/src/addons/pathtools/shared_edge.rs +++ b/crates/pathops/src/addons/pathtools/shared_edge.rs @@ -377,7 +377,7 @@ fn splice(sp: &SubPath, st: Stretch, chain: &[CubicBez]) -> Option { let own = backward(chain); let chain = if st.reversed { own.as_slice() } else { chain }; let (first, last) = (chain.first()?, chain.last()?); - let mut out = sp.anchors.clone(); + let mut out = sp.anchors.to_vec(); let start = st.start.min(out.len()); out.rotate_left(start); let settle = |a: &mut Anchor| { diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 87b4cb000d..3ad1281eb2 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Rect, Vec2}; use vectorcraft_geom::path::measured_arclen; -use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; +use vectorcraft_geom::{Anchor, AnchorKind, Anchors, PathData, SubPath}; use crate::boolean::{Seg, segs_to_subpath}; use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, largest_second_difference, sample, start_tangent, unit}; @@ -1091,7 +1091,7 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool { /// Refit the facing handles of anchors `pi` and `ni` so one cubic follows the two segments that /// meet at anchor `index` between them ([`remove_anchor`]). Two straight segments are left as /// they are, and so are the handles when the fit is not finite. -fn refit_across(anchors: &mut [Anchor], pi: usize, index: usize, ni: usize) { +fn refit_across(anchors: &mut Anchors, pi: usize, index: usize, ni: usize) { let (Some(&a), Some(&b), Some(&c)) = (anchors.get(pi), anchors.get(index), anchors.get(ni)) else { return }; let line = |a: &Anchor, b: &Anchor| !a.has_out() && !b.has_in(); if line(&a, &b) && line(&b, &c) { @@ -1233,7 +1233,7 @@ pub fn add_anchor_points(path: &PathData) -> PathData { v[2 * seg + 1] = Anchor { p: l.p3, h_in: l.p2, h_out: r.p1, kind: AnchorKind::Smooth }; } } - sp.anchors = v; + sp.anchors = v.into(); } out } diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 0b5b0bbf25..a31c9af2e4 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -769,7 +769,7 @@ fn add_anchor_points_doubles() { /// The one-pass build gives what inserting a midpoint into each segment (last first) gives, open or closed. #[test] fn add_anchor_points_matches_inserting() { - let open = PathData::single(SubPath::new(circle(0.0, 0.0, 10.0).subpaths[0].anchors.clone(), false)); + let open = PathData::single(SubPath::new(circle(0.0, 0.0, 10.0).subpaths[0].anchors.to_vec(), false)); for src in [circle(0.0, 0.0, 10.0), rect(0.0, 0.0, 10.0, 10.0), open] { let mut want = src.clone(); for sp in &mut want.subpaths { diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 403cbc5431..71b65e65d0 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -859,7 +859,9 @@ impl Exporter<'_> { /// Opens the blend mode and the transparency group `n` is drawn with (`container` when it is a /// group or layer whose children `knockout` each other). Returns how many were opened, and /// whether the children are to be drawn in an isolated group of their own: the knockout group of -/// an unclipped group's elements is its isolated group too. +/// an unclipped group's elements is its isolated group too. An opaque knockout group in Normal +/// mode that does not isolate opens no group: its elements are drawn on the art below it, in a +/// knockout group without `/I` when there are several. fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout: bool) -> (usize, bool) { let mut pushes = 0; if n.blend != BlendMode::Normal { @@ -876,6 +878,8 @@ fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout } else if n.opacity < 1.0 { s.push_opacity(norm(n.opacity)); pushes += 1; + } else if knockout && !n.isolate && n.blend == BlendMode::Normal { + // A knockout group that does not isolate: its elements start from the art below it. } else if knockout && matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { isolated_children = true; } else { diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 6baffa48a8..2855a61ff1 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -529,18 +529,18 @@ impl Exporter<'_> { /// their own. fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) { let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points(); - let prints_spot = || g.gradient.stops.iter().zip(g.gradient.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some()); - if from_stops && prints_spot() { + if from_stops && self.prints_spot(&g.gradient) { self.warn( "spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", ); } } - /// Is a stop of `g` linked to a spot swatch? - fn has_spot_stop(&self, g: &vectorcraft_color::Gradient) -> bool { - let doc = self.doc; - g.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) + /// Does a stop of `g` print a spot swatch ([`Self::printed_spot`]): color something that draws + /// ([`vectorcraft_color::Gradient::drawn_stops`]) at a tint above 0 or with process plates? A + /// stop linked to a spot swatch that colors nothing adds no spot ink. + fn prints_spot(&self, g: &vectorcraft_color::Gradient) -> bool { + g.stops.iter().zip(g.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some()) } /// Draw diamond gradient `g`, placed on `bounds`, over `area` (inside the current clip) as four @@ -638,8 +638,8 @@ impl Exporter<'_> { /// scaled type gets its pixels too): a freeform gradient's colour field (at most /// [`MAX_FIELD_PX`] a side), or a linear, radial, conic or diamond gradient (at most [`MAX_GRADIENT_PX`] a side), /// with dither's noise when it paints a fill (`fill`). A linear or radial gradient whose stops - /// are all CMYK and not linked to a spot swatch is a CMYK image, unless its Perceptual curve - /// has too many stops; any other is an RGB image. + /// are all CMYK and none of which prints a spot swatch ([`Self::prints_spot`]) is a CMYK image, + /// unless its Perceptual curve has too many stops; any other is an RGB image. pub(super) fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) { use vectorcraft_color::cms::Model; use vectorcraft_color::freeform::{painted_box, spread_scale}; @@ -667,7 +667,7 @@ impl Exporter<'_> { } else if !freeform { self.warn("linear and radial gradients drawn as pixels are exported as images"); } - let ramp_inks = if freeform || self.has_spot_stop(&g.gradient) { None } else { g.ink_pixels(b, area, cols, rows, fill) }; + let ramp_inks = if freeform || self.prints_spot(&g.gradient) { None } else { g.ink_pixels(b, area, cols, rows, fill) }; let ink_image = |inks, alpha| { let convert = if self.convert { self.out.cmyk_pixels() } else { None }; crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert) diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 6806aab756..ac1d77f372 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -87,8 +87,8 @@ fn icc_for(icc: Option>, space: ImageColorspace) -> Option>> icc.filter(|icc| valid_icc(icc, want)).map(Arc::new) } -/// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, -/// which reads tags of any size), another format's as its decoder reads it. `None` without one. +/// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, up +/// to 16 MiB), another format's as its decoder reads it. `None` without one. pub(crate) fn source_icc(bytes: &[u8]) -> Option> { let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; if reader.format() == Some(ImageFormat::Tiff) { @@ -183,8 +183,8 @@ fn lossless( /// it holds is still written. pub(crate) const MAX_EMBED_PIXELS: u64 = 1 << 27; -/// The most bytes an ICC profile carried by an image is written with (16 MiB). -const MAX_ICC: usize = 16 << 20; +/// The most bytes an ICC profile carried by an image is written or read with (16 MiB). +pub(crate) const MAX_ICC: usize = 16 << 20; /// The CMYK ICC profile image `blob` carries ([`ImageBlob::cmyk_icc_profile`]), when it is one a /// CMYK image can be tagged with ([`valid_icc`]). `None` for other images and other profiles. diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index d978680d67..e5a5e2e382 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -27,7 +27,7 @@ use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, TextOverprint, Walk use crate::import_shading::{ FunctionBudget, FunctionPaint, clipped, extend_clip, fold_stop_opacity, function_paint, gradient_shape, mesh_shading, shading_gradient, }; -use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; +use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, TypeStroke, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; mod actual_text; diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 0b32054c7b..0d50c58f31 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -4,7 +4,7 @@ use super::entry::{ UNNAMED_CHARACTERS_NOTE, max_objects, }; use super::reshaped; -use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, same_stroke, stroke_layer}; +use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, mean_scale, paints, round3, same_stroke, stroke_layer, type_stroke}; use super::{ Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget, FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint, @@ -961,7 +961,7 @@ impl<'p> Builder<'p> { } None => (None, 1.0), }; - let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); + let stroke = stroke.zip(paint.clone()).map(|(p, paint)| type_stroke(paint, p, scale)); let look = Look { font: font_key, family: info.family, @@ -1109,7 +1109,7 @@ impl<'p> Builder<'p> { }; // What the glyph is drawn with as an outline, should its line be kept so. let drawn = paint.clone().map(|p| (p, stroke.cloned())); - let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); + let stroke = stroke.zip(paint.clone()).map(|(p, paint)| type_stroke(paint, p, scale)); let look = Look { font: key, family: info.family, diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 12eb4498dc..7deb2bd73b 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -1,7 +1,7 @@ use super::cid::{CidText, FontInfo}; use super::{ Affine, Arc, BezPath, BlendMode, Colors, Dash, Families, FillRule, FontNames, FunctionBudget, HashMap, HashSet, ImageBlob, LineCap, LineFacts, - LineJoin, LumaData, MaskSpec, Node, NodeId, Paint, PathEl, PatternDef, Rect, Scan, StrokeLayer, StrokeProps, TextAs, TextLine, + LineJoin, LumaData, MaskSpec, Node, NodeId, Paint, PathEl, PatternDef, Rect, Scan, StrokeLayer, StrokeProps, TextAs, TextLine, TypeStroke, }; /// Decoded RGBA pixels, width, height and hayro's scale factors. @@ -277,6 +277,13 @@ pub(super) fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f st } +/// The stroke `p` draws type with in `paint` at `scale`: width, cap, join and miter limit as +/// [`stroke_layer`] takes them (#7741). +pub(super) fn type_stroke(paint: Paint, p: &StrokeProps, scale: f64) -> TypeStroke { + let st = stroke_layer(paint, 1.0, p, scale); + TypeStroke { paint: st.paint, width: st.width, cap: st.cap, join: st.join, miter_limit: st.miter_limit } +} + /// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps). const MAX_DASH_ENTRIES: usize = 64; /// Most dash periods [`stroke_outline`] cuts a stroke into. diff --git a/crates/pdf/src/import_image.rs b/crates/pdf/src/import_image.rs index d376c2b31e..cafae6d448 100644 --- a/crates/pdf/src/import_image.rs +++ b/crates/pdf/src/import_image.rs @@ -2,7 +2,10 @@ //! four components. A JPEG whose Decode array inverts its samples (Adobe's CMYK JPEGs, the //! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any //! other is decoded, its Decode array applied, and stored as a CMYK TIFF -//! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer +//! ([`ImageBlob::cmyk_tiff`]). An ICC-based image keeps the CMYK profile its color space holds +//! ([`icc_profile`]): the TIFF carries it, and a kept JPEG carries it in place of its own +//! ([`jpeg_with_icc`]), or, when its segments can't hold it, is stored as a CMYK TIFF carrying it. +//! A mask (`/SMask`, `/Mask`) doesn't change that: the importer //! carries it as the image's opacity mask ([`has_mask`]). A DeviceN image of process plates only //! keeps them the same way, as a CMYK image ([`process_devicen`]). An image of one ink (a //! Separation, or a DeviceN of one colorant) keeps it: its tints are read as a grayscale image @@ -12,8 +15,9 @@ use craft_pdf::syntax::Filter; use craft_pdf::syntax::object::stream::{ImageColorSpace, ImageDecodeParams}; use craft_pdf::syntax::object::{Array, Dict, Name, Stream}; use vectorcraft_doc::ImageBlob; -use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components}; +use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components, jpeg_with_icc}; +use crate::images::{MAX_ICC, valid_icc}; use crate::import_color::ink_names; /// A CMYK image read as RGB: why. @@ -42,6 +46,24 @@ fn cmyk_space(d: &Dict<'_>) -> bool { } } +/// The profile of the image's four-component `ICCBased` color space, when it is a CMYK profile an +/// image can be tagged with ([`valid_icc`], at most [`MAX_ICC`] bytes). `None` for other color +/// spaces and other profiles: the image then keeps its samples untagged. +fn icc_profile(d: &Dict<'_>) -> Option> { + let a = d.get::>(key(d, b"ColorSpace", b"CS"))?; + let mut it = a.flex_iter(); + if it.next::>()?.as_ref() != b"ICCBased" { + return None; + } + let s = it.next::>()?; + if s.dict().get::(b"N") != Some(4) { + return None; + } + // One byte over the limit, so a longer profile reads as too long rather than cut short. + let icc = s.decoded_within(MAX_ICC + 1).ok()?; + valid_icc(&icc, *b"CMYK").then(|| icc.into_owned()) +} + /// Each component's Decode range (`[0 1]` each when the array is missing or malformed). fn decode_ranges(d: &Dict<'_>) -> [(f32, f32); 4] { let mut out = [(0.0, 1.0); 4]; @@ -63,11 +85,25 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8); @@ -91,7 +127,7 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result, - pub stroke: Option<(Paint, f64)>, + pub stroke: Option, /// The fill, the stroke overprints. pub overprint_fill: bool, pub overprint_stroke: bool, @@ -102,7 +113,11 @@ impl Look { } fn style(&self) -> CharStyle { - let (stroke, stroke_width) = self.stroke.clone().unwrap_or((Paint::None, 0.0)); + let default = CharStyle::default(); + let (stroke, stroke_width, stroke_cap, stroke_join, stroke_miter_limit) = match self.stroke.clone() { + Some(s) => (s.paint, s.width, s.cap, s.join, s.miter_limit), + None => (Paint::None, 0.0, default.stroke_cap, default.stroke_join, default.stroke_miter_limit), + }; CharStyle { font_family: self.family.clone(), font_style: self.style.clone(), @@ -112,9 +127,12 @@ impl Look { fill: self.fill.clone().unwrap_or(Paint::None), stroke, stroke_width, + stroke_cap, + stroke_join, + stroke_miter_limit, overprint_fill: self.overprint_fill, overprint_stroke: self.overprint_stroke, - ..CharStyle::default() + ..default } } } @@ -187,6 +205,9 @@ pub(crate) struct TextLine { /// Runs of glyphs drawn alike: their look, how far the file raised them off the line's /// baseline (points, up; text rise, `Ts`), their text. runs: Vec<(Look, f64, String)>, + /// Where the last glyph's text starts in the last run (bytes): a stroke over that glyph + /// alone restyles only its text ([`stroke_last`](Self::stroke_last)). + last_start: usize, /// The bytes of text the horizontal glyphs added to `runs` so far (counted as they come: a /// sum over the runs for each glyph is quadratic when every glyph is a run). text_bytes: usize, @@ -257,6 +278,7 @@ impl TextLine { last_size: at.size, baseline: vec![], runs: vec![], + last_start: 0, text_bytes: 0, slots: vec![], authored: vec![], @@ -303,6 +325,7 @@ impl TextLine { let size = self.at.size.max(at.size); let Some((last, _, run)) = self.runs.last_mut() else { self.runs.push((look.clone(), 0.0, text.to_string())); + self.last_start = 0; self.record_actual_fill(look, at, content); self.authored.extend(text.chars().map(|_| content.authored())); (self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin); @@ -334,9 +357,11 @@ impl TextLine { && (matches!(content, TextContent::Pen) || (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false))) { + self.last_start = run.len(); run.push_str(text); } else { self.runs.push((look.clone(), 0.0, text.to_string())); + self.last_start = 0; } self.record_actual_fill(look, at, content); self.authored.extend(text.chars().map(|_| content.authored())); @@ -425,12 +450,15 @@ impl TextLine { && (*last_shift - shift).abs() < 1e-3 && (pen || (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false))) { + self.last_start = run.len(); run.push_str(text); } else { self.runs.push((look.clone(), shift, text.to_string())); + self.last_start = 0; } } else { self.runs.push((look.clone(), 0.0, text.to_string())); + self.last_start = 0; } self.record_actual_fill(look, at, content); self.text_bytes = self.text_bytes.saturating_add(text.len()); @@ -463,7 +491,7 @@ impl TextLine { /// The reader fills a whole Tj/TJ before replaying its glyphs as strokes. A replay /// must neither consume the replacement nor rewind the pen. Only the first glyph's /// style belongs to the replacement; suppressed glyphs have no text to restyle. - pub fn stroke_actual(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool { + pub fn stroke_actual(&mut self, font: u128, at: Placement, stroke: TypeStroke, overprint: bool) -> bool { let key = (font, at.origin.x.to_bits(), at.origin.y.to_bits()); let Some(run) = self.actual_fills.remove(&key) else { return false }; if let Some((look, ..)) = run.and_then(|i| self.runs.get_mut(i)) { @@ -501,13 +529,34 @@ impl TextLine { Some(bp) } - /// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill - /// and stroke rendering), which `overprint`s or not: the run is stroked. `false`: it isn't - /// that glyph. - pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool { - let Some((look, ..)) = self.runs.last_mut().filter(|(l, ..)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else { + /// A stroke (`stroke`: paint, width, cap, join and miter limit) over the glyph just drawn at + /// `at` in `font` (fill and stroke rendering), which `overprint`s or not: that glyph is + /// stroked. `false`: it isn't that glyph. + /// + /// A filled glyph joins a run of the same fill whatever its stroke ([`Look::takes`]), so the + /// run can hold glyphs before it that are stroked otherwise or not at all: then the glyph's + /// text becomes a run of its own with this stroke, and the glyphs before it keep theirs + /// (#7741). The line's text is the same either way, so the characters' slots, gaps and + /// authored flags, kept along the whole line, still line up. + pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: TypeStroke, overprint: bool) -> bool { + let start = self.last_start; + let Some((look, shift, text)) = + self.runs.last_mut().filter(|(l, ..)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) + else { return false; }; + let same = look.stroke.as_ref() == Some(&stroke) && look.overprint_stroke == overprint; + // Only the last glyph's text in the run (`start` within it, on a character boundary): + // split it off. A run of that glyph alone is stroked whole, as before. + if !same && start > 0 && start < text.len() && text.is_char_boundary(start) { + let tail = text.split_off(start); + let mut own = look.clone(); + (own.stroke, own.overprint_stroke) = (Some(stroke), overprint); + let shift = *shift; + self.runs.push((own, shift, tail)); + self.last_start = 0; + return true; + } (look.stroke, look.overprint_stroke) = (Some(stroke), overprint); true } diff --git a/crates/pdf/src/tests/blend.rs b/crates/pdf/src/tests/blend.rs index c55a0b7504..1cd5c949e0 100644 --- a/crates/pdf/src/tests/blend.rs +++ b/crates/pdf/src/tests/blend.rs @@ -151,6 +151,77 @@ fn a_knockout_group_is_written_as_one_pdf_knockout_group() { } } +/// A knockout group with nothing blending inside is drawn in an isolated group (`/I true`) only +/// when the group isolates, clipped or not, with opaque or translucent elements, and reads back +/// knocking out. +#[test] +fn a_knockout_group_without_blending_inside_is_isolated_only_when_it_isolates() { + for clip in [false, true] { + for opacity in [0.5, 1.0] { + for isolate in [false, true] { + let mut d = knockout_squares(3, false, isolate); + let g = d.layers[0].children().unwrap()[1].id; + let mut squares: Vec<_> = d.node(g).unwrap().children().unwrap().to_vec(); + for s in &mut squares { + std::sync::Arc::make_mut(s).opacity = opacity; + } + if clip { + let c = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(10.0, 10.0, 190.0, 190.0)), + Appearance::basic(Paint::None, Paint::None, 0.0), + ); + squares.insert(0, std::sync::Arc::new(c)); + } + d.node_mut(g).unwrap().kind = vectorcraft_doc::NodeKind::Group { children: squares, clip }; + let case = format!("clip {clip}, opacity {opacity}, isolate {isolate}"); + let t = text(&d); + assert_eq!(t.matches("/Ktrue").count(), 1, "{case}"); + // Each translucent element is an isolated form with its opacity. + let elements = if opacity < 1.0 { 3 } else { 0 }; + assert_eq!(t.matches("/Itrue").count(), elements + usize::from(isolate), "{case}"); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap_or_else(|e| panic!("{case}: {e}")); + let mut found = vec![]; + back.walk(|n| { + if n.knockout == vectorcraft_doc::Knockout::On { + found.push(n.isolate); + } + }); + assert_eq!(found, [false], "{case}"); + } + } + } +} + +/// An overprinting element of a knockout group that does not isolate is drawn in a `/K` group +/// without `/I true`, so it overprints the art below the group, and its graphics state still +/// overprints (`/OP`, `/op`, `/OPM 1`). An isolated group keeps `/I true`. +#[test] +fn an_overprinting_element_of_a_knockout_group_that_does_not_isolate_overprints_the_art_below() { + for isolate in [false, true] { + let mut d = knockout_squares(3, false, isolate); + let g = d.layers[0].children().unwrap()[1].id; + let squares: Vec<_> = d.node(g).unwrap().children().unwrap().iter().map(|s| s.id).collect(); + for s in &squares { + d.node_mut(*s).unwrap().opacity = 1.0; + } + d.node_mut(squares[2]).unwrap().appearance.fill_mut().unwrap().overprint = true; + let t = text(&d); + assert_eq!(t.matches("/Ktrue").count(), 1, "isolate {isolate}"); + assert_eq!(t.matches("/Itrue").count(), usize::from(isolate), "isolate {isolate}"); + assert!(t.contains("/OPtrue/optrue/OPM1"), "isolate {isolate}"); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap_or_else(|e| panic!("isolate {isolate}: {e}")); + let mut overprints = vec![]; + back.walk(|n| { + if let Some(f) = n.appearance.fill() { + overprints.push(f.overprint); + } + }); + assert_eq!(overprints, [false, false, false, true], "isolate {isolate}"); + assert_eq!(knockout_groups(&back), [3], "isolate {isolate}"); + } +} + /// The knockout group of an isolated group is isolated too (`/I true` beside `/K true`), and the /// group reads back as isolated and knocking out. (Each translucent element is a group of its own.) #[test] diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 4719beec70..590afa3df9 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -176,9 +176,17 @@ fn a_cmyk_jpeg_passes_through_open_and_every_pdf_export() { assert_eq!(w[0].decode, [1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0], "{settings}"); assert!(w[0].data == jpg, "{settings}: the JPEG data unchanged"); assert!(out.warnings.iter().all(|w| !w.contains("image")), "{settings}: {:?}", out.warnings); - // And open again: the same JPEG. + // And open again: the same JPEG, carrying the profile it was written in when it was + // ICC-based. let back = import(&out.bytes).unwrap(); - assert_eq!(blobs(&back)[0].bytes.as_slice(), jpg.as_slice(), "{settings}"); + let blob = blobs(&back)[0]; + if icc_n.is_some() { + assert_eq!(blob.mime, "image/jpeg", "{settings}"); + assert_eq!(blob.cmyk(), b[0].cmyk(), "{settings}: the same samples"); + assert_eq!(blob.cmyk_icc_profile(), w[0].icc, "{settings}: the profile it was written in"); + } else { + assert_eq!(blob.bytes.as_slice(), jpg.as_slice(), "{settings}"); + } } } @@ -669,6 +677,125 @@ fn a_cmyk_image_with_a_malformed_profile_is_written_untagged() { assert_eq!((w.len(), w[0].space.as_str()), (1, "DeviceCMYK"), "{w:?}"); } +/// An ICC-based CMYK image opens with the profile its color space holds, byte for byte, beside its +/// ink amounts: stored as a CMYK TIFF carrying it whatever its Decode array and depth, and a CMYK +/// JPEG kept with its samples as they are, carrying that profile in place of its own. An export +/// with source profiles writes the image in that profile. DeviceCMYK and process-plate DeviceN +/// images have no profile of their own and stay untagged. +#[test] +fn an_icc_based_cmyk_image_opens_with_its_profile() { + let ink = [0u8, 102, 255, 0]; + let inks: Vec = [[0, 0, 0, 0], [255, 0, 0, 0], [10, 20, 30, 40], [0, 102, 255, 0]].concat(); + let z = |data: &[u8]| crate::output::deflate(data).unwrap(); + let inverted: Vec = inks.iter().map(|v| 255 - v).collect(); + let wide: Vec = inks.iter().flat_map(|v| (*v as u16 * 257).to_be_bytes()).collect(); + let icc = cmyk_profile(|_| {}); + let other = cmyk_profile(|i| i[152] = b'X'); + let jpg = |icc: Option<&[u8]>| match icc { + Some(icc) => cmyk_jpeg_with(&flat(ink, 4), 2, 2, icc), + None => cmyk_jpeg(&flat(ink, 4), 2, 2), + }; + let jpeg_inks = ImageBlob::new("image/jpeg", jpg(None)).cmyk().unwrap().data; + let iccbased = |more: &str| cmyk_dict(2, 2, more).replace("/DeviceCMYK", "[/ICCBased @icc 0 R]"); + // At most five images fit the page. The profile and a type 4 function follow them. + let open = |images: &[(String, Vec)]| { + let n = images.len(); + let images: Vec<(String, Vec)> = + images.iter().map(|(d, data)| (d.replace("@icc", &(5 + n).to_string()).replace("@fn", &(6 + n).to_string()), data.clone())).collect(); + let function = stream("/FunctionType 4 /Domain [0 1 0 1 0 1 0 1] /Range [0 1 0 1 0 1 0 1]", b"{ }"); + let r = import_with_report(&pdf_with_images(&images, &[stream("/N 4 /Filter /FlateDecode", &z(&icc)), function]), &ImportOptions::default()) + .unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(blobs(&r.document).len(), n); + r.document + }; + let tiffs = open(&[ + (iccbased("/Filter /FlateDecode"), z(&inks)), + (iccbased(&format!("/Filter /FlateDecode {INVERTED}")), z(&inverted)), + (iccbased("/Filter /FlateDecode").replace("/BitsPerComponent 8", "/BitsPerComponent 16"), z(&wide)), + (cmyk_dict(2, 2, "/Filter /FlateDecode"), z(&inks)), + (cmyk_dict(2, 2, "/Filter /FlateDecode").replace("/DeviceCMYK", "[/DeviceN [/Cyan /Magenta /Yellow /Black] /DeviceCMYK @fn 0 R]"), z(&inks)), + ]); + for (i, blob) in blobs(&tiffs).iter().enumerate() { + let profile = (i < 3).then_some(icc.as_slice()); + assert_eq!( + (blob.mime.as_str(), blob.cmyk().unwrap().data, blob.cmyk_icc_profile().as_deref()), + ("image/tiff", inks.clone(), profile), + "image {i}" + ); + } + // A JPEG without a profile, with the same one and with another. + let jpegs = open(&[ + (iccbased(&format!("/Filter /DCTDecode {INVERTED}")), jpg(None)), + (iccbased(&format!("/Filter /DCTDecode {INVERTED}")), jpg(Some(&icc))), + (iccbased(&format!("/Filter /DCTDecode {INVERTED}")), jpg(Some(&other))), + ]); + for (i, blob) in blobs(&jpegs).iter().enumerate() { + assert_eq!( + (blob.mime.as_str(), blob.cmyk().unwrap().data, blob.cmyk_icc_profile().as_deref()), + ("image/jpeg", jpeg_inks.clone(), Some(icc.as_slice())), + "image {i}" + ); + } + assert!(*blobs(&jpegs)[1].bytes == jpg(Some(&icc)), "the JPEG carrying the same profile, as it is"); + + // Images written the same way are written once: some are tagged, and only the TIFFs have + // untagged ones (the DeviceCMYK and DeviceN images, in the document's profile). + for (d, untagged) in [(tiffs, true), (jpegs, false)] { + let out = export_with_report(&d, &opts(json!({"output": {"profiles": "all"}}))).unwrap(); + let w: Vec = written(&out.bytes).into_iter().filter(|w| w.width == 2).collect(); + let tagged = w.iter().filter(|w| w.icc.as_deref() == Some(icc.as_slice())).count(); + assert!( + tagged > 0 && (w.len() > tagged) == untagged, + "{tagged} of {} tagged: {:?}", + w.len(), + w.iter().map(|w| (&w.space, &w.filter, w.icc.as_ref().map(|i| i.len()))).collect::>() + ); + } +} + +/// A profile an image can't be tagged with (malformed, of RGB colors, or over 16 MiB) leaves the +/// ICC-based image's inks as they are, untagged and without a warning; a profile of 16 MiB exactly +/// is kept. +#[test] +fn an_icc_based_cmyk_image_with_a_profile_it_cant_carry_opens_untagged() { + let inks: Vec = [[0, 0, 0, 0], [255, 0, 0, 0], [10, 20, 30, 40], [0, 102, 255, 0]].concat(); + let z = |data: &[u8]| crate::output::deflate(data).unwrap(); + let sized = |len: usize| { + cmyk_profile(|i| { + i.resize(len, 0); + i[0..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes()); + }) + }; + let limit = crate::images::MAX_ICC; + let jpeg = cmyk_jpeg(&flat([0, 102, 255, 0], 4), 2, 2); + for (what, icc, kept) in [ + ("signature", cmyk_profile(|i| i[36..40].copy_from_slice(b"xxxx")), false), + ("RGB color space", cmyk_profile(|i| i[16..20].copy_from_slice(b"RGB ")), false), + ("declared size", cmyk_profile(|i| i[0..4].copy_from_slice(&200u32.to_be_bytes())), false), + ("empty", vec![], false), + ("over 16 MiB", sized(limit + 1), false), + ("16 MiB", sized(limit), true), + ] { + let profile = stream("/N 4 /Filter /FlateDecode", &z(&icc)); + let images = [ + (cmyk_dict(2, 2, "/Filter /FlateDecode").replace("/DeviceCMYK", "[/ICCBased 7 0 R]"), z(&inks)), + (cmyk_dict(2, 2, &format!("/Filter /DCTDecode {INVERTED}")).replace("/DeviceCMYK", "[/ICCBased 7 0 R]"), jpeg.clone()), + ]; + let r = import_with_report(&pdf_with_images(&images, &[profile]), &ImportOptions::default()).unwrap(); + assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings); + let b = blobs(&r.document); + assert_eq!(b.len(), 2, "{what}"); + assert_eq!((b[0].mime.as_str(), b[0].cmyk().unwrap().data), ("image/tiff", inks.clone()), "{what}: the inks as they were"); + assert_eq!(b[0].cmyk_icc_profile().is_some_and(|p| p == icc), kept, "{what}"); + if kept { + assert_eq!(b[1].cmyk_icc_profile().is_some_and(|p| p == icc), kept, "{what}"); + } else { + assert!(b[1].mime == "image/jpeg" && *b[1].bytes == jpeg, "{what}: the JPEG as it is"); + } + } +} + /// A Press Quality export of a 2 × 2 CMYK image masked by PNG `mask` on its own pixel grid /// (`clip`, `invert`): the file, and the decoded soft mask of each CMYK image (`None`: it has none). fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec>>) { @@ -962,6 +1089,35 @@ fn gradients_with_a_stop_that_is_not_process_cmyk_stay_rgb_images() { assert_eq!(w[0].space, "DeviceCMYK", "{w:?}"); } +/// A stop linked to a spot swatch that prints no ink (it colors only clear stretches, sits out of +/// order where it colors nothing, or has a tint of 0 and no process plates) leaves a dithered +/// gradient of CMYK stops a CMYK image without the spot warning; a drawn stop with process plates +/// at a tint of 0 still prints, so its gradient stays an RGB image with the warning. +#[test] +fn gradients_whose_spot_stops_print_nothing_stay_cmyk_images() { + let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1); + let stop = |offset: f32, color: Color, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, color) }; + let spot = |offset: f32, opacity: f32| GradientStop { swatch: Some("Gold".into()), ..stop(offset, gold, opacity) }; + let (cyan, black) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let clear = vec![stop(0.0, cyan, 1.0), stop(0.5, black, 1.0), stop(0.6, black, 0.0), spot(0.8, 0.0), stop(1.0, black, 0.0)]; + let out_of_order = vec![stop(0.0, cyan, 1.0), stop(0.6, black, 1.0), spot(0.3, 1.0), stop(0.6, black, 1.0), stop(1.0, black, 1.0)]; + let no_tint = vec![stop(0.0, cyan, 1.0), GradientStop { tint: 0.0, ..spot(1.0, 1.0) }]; + let plates = vec![stop(0.0, cyan, 1.0), GradientStop { tint: 0.0, plates: Some([0.0, 0.0, 0.0, 0.5]), ..spot(1.0, 1.0) }]; + for (name, stops, prints) in + [("clear", clear, false), ("out of order", out_of_order, false), ("tint 0", no_tint, false), ("plates", plates, true)] + { + let mut d = gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, stops, true)); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: true }); + let out = export_with_report(&d, &opts(json!({}))).unwrap(); + let w = written(&out.bytes); + let warned = out.warnings.iter().any(|m| m.contains("written in process colors")); + let space = if prints { "DeviceRGB" } else { "DeviceCMYK" }; + assert!(w.iter().any(|i| i.space == space), "{name}: {w:?}"); + assert!(!w.iter().any(|i| i.space == if prints { "DeviceCMYK" } else { "DeviceRGB" }), "{name}: {w:?}"); + assert_eq!(warned, prints, "{name}: {:?}", out.warnings); + } +} + #[test] fn freeform_gradients_of_lab_points_are_written_as_their_lab_mix() { let d = freeform_doc(ColorMode::Rgb, Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index ecd1f25ce1..7fa887a90f 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -662,6 +662,17 @@ fn a_spot_gradient_stop_prints_its_process_plates() { assert_eq!(first(json!({"output": {"mode": "separations", "spotsToProcess": true}})), [25.0, 10.0, 64.0, 5.0]); } +#[test] +fn a_spot_gradient_stop_whose_tint_is_not_a_number_prints_no_spot_ink() { + let mut mixed = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); + (mixed.swatch, mixed.tint, mixed.plates) = (Some("Gold".into()), f32::NAN, Some([0.25, 0.0, 0.4, 0.0])); + let g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![mixed, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))])); + let d = gold_gradient_doc(g); + let first = |v| gradient_plates(&d, v).into_iter().map(|(s, _)| s).collect::>(); + assert_eq!(first(json!({"output": {"mode": "separations"}})), [25.0, 0.0, 40.0, 0.0, 0.0], "Cyan, Magenta, Yellow, Black, Gold"); + assert_eq!(first(json!({"output": {"mode": "separations", "spotsToProcess": true}})), [25.0, 0.0, 40.0, 0.0]); +} + #[test] fn a_freeform_gradients_points_separate_into_plates() { let mut g = GradientPaint::new(Gradient::new(GradientKind::Freeform, vec![GradientStop::new(0.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))])); diff --git a/crates/pdf/src/tests/textstroke.rs b/crates/pdf/src/tests/textstroke.rs index 2669f02844..f28e525f27 100644 --- a/crates/pdf/src/tests/textstroke.rs +++ b/crates/pdf/src/tests/textstroke.rs @@ -55,3 +55,70 @@ fn an_aligned_character_stroke_is_clipped_to_its_side_of_the_glyphs() { assert!(ops.contains(&if clip { "W*" } else { "W" }), "{align:?}: clipped to its side"); } } + +/// The cap, join and miter limit a PDF strokes its type with, whatever the rendering mode that +/// strokes it (stroke, fill and stroke, and either with a clip), and with replacement text +/// (`ActualText`) too: not butt caps and miter joins (#7741). +#[test] +fn imported_stroked_type_keeps_the_files_cap_join_and_miter_limit() { + use vectorcraft_testkit::pdf::{PdfPage, pdf}; + let page = |content: &str| { + let resources = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>"; + pdf(&[PdfPage { resources: resources.into(), ..PdfPage::new(300.0, 200.0, content) }], None) + }; + let styles = |content: &str| { + let d = import(&page(content)).unwrap(); + let mut runs = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + runs.extend(t.runs.iter().map(|r| r.style.clone())); + } + }); + runs + }; + let cases = [ + ("1 J 1 j", (LineCap::Round, LineJoin::Round, 10.0)), + ("2 J 2 j 3 M", (LineCap::Square, LineJoin::Bevel, 3.0)), + ("0 J 0 j 3 M", (LineCap::Butt, LineJoin::Miter, 3.0)), + ]; + for (settings, want) in cases { + for mode in ["1", "2", "5", "6"] { + // A word shown at once, a glyph at a time (a stroke over the glyph just filled), and + // in a span with replacement text. + for text in ["(WAVE) Tj", "(W) Tj (A) Tj", "/Span << /ActualText (WAVE) >> BDC (WAVE) Tj EMC"] { + let content = format!("0 g 0 0 0 RG 4 w {settings} BT {mode} Tr /F1 60 Tf 40 110 Td {text} 0 Tr ET"); + let runs = styles(&content); + let stroked: Vec<_> = runs.iter().filter(|s| !s.stroke.is_none()).collect(); + assert!(!stroked.is_empty(), "{content}: stroked type comes in: {runs:?}"); + if matches!(mode, "2" | "6") && !text.starts_with("(WAVE)") { + assert!(stroked.iter().any(|s| !s.fill.is_none()), "{content}: the stroke is over the filled glyphs: {runs:?}"); + } + for s in stroked { + assert!((s.stroke_width - 4.0).abs() < 1e-6, "{content}: width {}", s.stroke_width); + let got = (s.stroke_cap, s.stroke_join, s.stroke_miter_limit); + assert_eq!(got, want, "{content}"); + } + } + } + } + // Glyphs stroked with another join are a run of their own. + let runs = styles("0 0 0 RG 4 w BT 1 Tr /F1 60 Tf 40 110 Td 1 j (WA) Tj 0 j (VE) Tj 0 Tr ET"); + let joins: Vec<_> = runs.iter().map(|s| s.stroke_join).collect(); + assert_eq!(joins, [LineJoin::Round, LineJoin::Miter]); + // Filled and stroked a glyph at a time: the next glyph's fill joins the run before it, and its + // stroke, with another join, restyles that glyph alone. + for mode in ["2", "6"] { + let content = format!("0 g 0 0 0 RG 4 w BT {mode} Tr /F1 60 Tf 40 110 Td 1 j (W) Tj 0 j (A) Tj 0 Tr ET"); + let runs = styles(&content); + let d = import(&page(&content)).unwrap(); + let mut text = String::new(); + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + text.push_str(&t.plain_text()); + } + }); + assert_eq!(text, "WA", "{content}"); + let joins: Vec<_> = runs.iter().map(|s| (!s.stroke.is_none()).then_some(s.stroke_join)).collect(); + assert_eq!(joins, [Some(LineJoin::Round), Some(LineJoin::Miter)], "{content}: {runs:?}"); + } +} diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index 6d549ed070..02b212cb23 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -620,6 +620,44 @@ mod tests { registry::remove(ID); } + /// A cached result whose gradient stop prints `plates` beside a spot swatch is refused once that + /// swatch is no longer a spot swatch, and used again when it is one again. + #[test] + fn cached_painted_plates_refuse_after_spot_becomes_process() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.spotplates"; + let manifest = format!(r#"{{"id":"{ID}","name":"Test","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#); + let gray = json!({"model": "gray", "k": 0}); + let stops = json!([{"offset": 0, "color": gray}, {"offset": 1, "color": gray, "swatch": "Ink", "plates": [0.1, 0.2, 0.3, 0.4]}]); + let fill = json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": stops}}); + let square = PathData::single(SubPath::polyline(&[(0.0, 0.0), (9.0, 0.0), (9.0, 9.0)].map(|(x, y)| Point::new(x, y)), true)); + let output = json!({"objects": [{"path": square, "fills": [fill]}]}).to_string(); + let src = crate::wat::with_imports(&manifest, "", "", &output); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap(); + let with_ink = |spot: bool| { + let mut doc = Document::new(100.0, 100.0); + doc.swatches.push(vectorcraft_color::Swatch { + name: "Ink".into(), + paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), + global: true, + spot, + }); + doc + }; + let (spot, process) = (with_ink(true), with_ink(false)); + let path = line(2); + let draw = + |doc: &Document| apply_painted(ID, &json!({}), &path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints::default(), Some(doc)); + assert!(draw(&spot).is_some()); + assert_eq!(last_error(ID), None); + assert!(draw(&process).is_none(), "from the cache, refused"); + let e = last_error(ID).unwrap(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].plates: `plates` requires a stop linked to a spot swatch"), "{e}"); + assert!(draw(&spot).is_some(), "from the cache, used again"); + registry::remove(ID); + } + /// A clock that reads `step` milliseconds later each time it is read. fn stepping(step: u64) -> crate::deferred::Clock { let t = Arc::new(std::sync::atomic::AtomicU64::new(0)); diff --git a/crates/plugins/src/painted.rs b/crates/plugins/src/painted.rs index 5b58318073..484e761677 100644 --- a/crates/plugins/src/painted.rs +++ b/crates/plugins/src/painted.rs @@ -7,8 +7,9 @@ //! An effect on a fill may return only `fills`, one on a stroke only `strokes`. //! //! The result is checked whole before any of it is used ([`decode`]): every number finite and in -//! range (nothing is clamped), every key known, and every swatch and pattern a paint names in the -//! document ([`check_references`]). One fault refuses the whole result, naming the field. +//! range (nothing is clamped), every key known, a gradient stop's `plates` only on a stop linked to +//! a spot swatch, and every swatch and pattern a paint names in the document ([`check_references`]). +//! One fault refuses the whole result, naming the field. use serde_json::Value; use vectorcraft_color::{Color, Paint}; @@ -236,7 +237,8 @@ fn paint_keys(v: &Value, at: &str) -> Result<()> { } /// A paint as the filter form has it (`null`: none), checked rather than clamped: colors, tints, -/// stops, opacities, midpoints and placements in range. +/// stops, opacities, midpoints and placements in range, and a gradient stop's `plates` only on a +/// stop linked to a swatch ([`check_references`] checks that the swatch is a spot swatch). fn paint(v: &Value, at: &str) -> Result { if v.is_null() { return Ok(Paint::None); @@ -265,6 +267,11 @@ fn paint(v: &Value, at: &str) -> Result { for x in s.plates.iter().flatten() { unit(*x, &format!("{at}.plates"), "a plate")?; } + // Plates print beside the tint of the spot swatch the stop itself links to; a + // gradient's own `swatch` does not link its stops. + if s.plates.is_some() && s.swatch.is_none() { + return Err(out_err(&format!("{at}.plates"), "`plates` requires a stop linked to a spot swatch")); + } } if stops.windows(2).any(|w| w[1].offset < w[0].offset) { return Err(out_err(&format!("{at}.gradient.stops"), "stops must be in order of `offset`")); @@ -308,8 +315,9 @@ fn paint(v: &Value, at: &str) -> Result { } /// Checks that every swatch a solid paint, gradient or gradient stop of `pieces` links to is a -/// swatch of `doc`, and every pattern a pattern paint names is one of its patterns. Without a -/// document (`None`), any swatch or pattern a piece names is refused. +/// swatch of `doc`, that a gradient stop with `plates` links to a spot swatch, and every pattern a +/// pattern paint names is one of its patterns. Without a document (`None`), any swatch or pattern a +/// piece names is refused. pub fn check_references(pieces: &[Piece], doc: Option<&Document>) -> Result<()> { let swatch = |name: &Option, at: &str| match name { Some(n) if doc.and_then(|d| d.swatch(n)).is_none() => Err(out_err(at, format!("the document has no swatch {n:?}"))), @@ -321,7 +329,16 @@ pub fn check_references(pieces: &[Piece], doc: Option<&Document>) -> Result<()> Paint::Solid { swatch: s, .. } => swatch(s, &format!("{at}.swatch")), Paint::Gradient(g) => { swatch(&g.swatch, &format!("{at}.swatch"))?; - g.gradient.stops.iter().enumerate().try_for_each(|(i, s)| swatch(&s.swatch, &format!("{at}.gradient.stops[{i}].swatch"))) + g.gradient.stops.iter().enumerate().try_for_each(|(i, s)| { + let at = format!("{at}.gradient.stops[{i}]"); + swatch(&s.swatch, &format!("{at}.swatch"))?; + match s.swatch.as_deref().and_then(|n| doc.and_then(|d| d.swatch(n))) { + Some(w) if s.plates.is_some() && !w.spot => { + Err(out_err(&format!("{at}.plates"), "`plates` requires a stop linked to a spot swatch")) + } + _ => Ok(()), + } + }) } Paint::Pattern { pattern, .. } if doc.and_then(|d| d.pattern(pattern)).is_none() => { Err(out_err(&format!("{at}.pattern"), format!("the document has no pattern {pattern:?}"))) diff --git a/crates/plugins/src/painted/tests.rs b/crates/plugins/src/painted/tests.rs index 3ed6dd653e..59acf18521 100644 --- a/crates/plugins/src/painted/tests.rs +++ b/crates/plugins/src/painted/tests.rs @@ -239,3 +239,97 @@ fn named_swatches_and_patterns_must_exist() { let e = check_references(&pieces(json!([{"path": square(), "fills": [{"type": "pattern", "pattern": "Dots"}]}])), None).unwrap_err().to_string(); assert!(e.contains("objects[0].fills[0].pattern"), "{e}"); } + +/// A gradient of two stops: a plain first one, then `stop`, with `extra` keys on the paint. +fn ramp(stop: Value, extra: Value) -> Value { + let mut g = json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": 0, "color": {"model": "gray", "k": 0}}, stop]}}); + g.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + g +} + +/// A second stop with process `plates`, and `extra` keys. +fn plated(extra: Value) -> Value { + let mut s = json!({"offset": 1, "color": {"model": "gray", "k": 1}, "plates": [0.1, 0.2, 0.3, 0.4]}); + s.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s +} + +/// A gradient stop's process `plates` need the stop's own link to a swatch: a stop without one +/// (no `swatch` or a `null` one) refuses the whole result, naming the stop's `plates`, in a fill or +/// a stroke at every scope. +#[test] +fn unlinked_gradient_stop_plates_refuse_the_whole_painted_result() { + const WHY: &str = "`plates` requires a stop linked to a spot swatch"; + for orphan in [ramp(plated(json!({})), json!({})), ramp(plated(json!({"swatch": null})), json!({}))] { + let fills = out(json!([{"path": square(), "fills": [red()]}, {"path": square(), "fills": [red(), orphan]}])); + for scope in [Scope::Object, Scope::Fill] { + let e = decode(&fills, scope, FillRule::NonZero).unwrap_err(); + assert_eq!(e.to_string(), format!("plug-in output: objects[1].fills[1].gradient.stops[1].plates: {WHY}"), "{scope:?}"); + } + let strokes = out(json!([{"path": square(), "strokes": [{"paint": red()}]}, {"path": square(), "strokes": [{"paint": orphan, "width": 2}]}])); + for scope in [Scope::Object, Scope::Stroke] { + let e = decode(&strokes, scope, FillRule::NonZero).unwrap_err(); + assert_eq!(e.to_string(), format!("plug-in output: objects[1].strokes[0].paint.gradient.stops[1].plates: {WHY}"), "{scope:?}"); + } + } + // `plugin.info`'s `lastError` names the plug-in before it. + let e = decode(&out(json!([{"path": square(), "fills": [ramp(plated(json!({})), json!({}))]}])), Scope::Object, FillRule::NonZero).unwrap_err(); + assert_eq!(named("Test Tone", &e), format!("Test Tone: plug-in output: objects[0].fills[0].gradient.stops[1].plates: {WHY}")); + // A plate out of range keeps its own error. + let wide = ramp(json!({"offset": 1, "color": {"model": "gray", "k": 1}, "plates": [2, 0, 0, 0]}), json!({})); + let e = decode(&out(json!([{"path": square(), "fills": [wide]}])), Scope::Object, FillRule::NonZero).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].plates: a plate must be from 0 to 1"), "{e}"); +} + +/// The gradient's own `swatch` does not link its stops, so it does not admit a stop's `plates`. +#[test] +fn gradient_swatch_does_not_link_its_stops_plates() { + let fill = ramp(plated(json!({})), json!({"swatch": "Ink"})); + let e = decode(&out(json!([{"path": square(), "fills": [fill]}])), Scope::Object, FillRule::NonZero).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].plates: `plates` requires a stop linked to a spot swatch"), "{e}"); +} + +/// A stop's `plates` beside a link to a swatch that is not a spot swatch (a process swatch or +/// Registration) refuse the result at the reference check; a link to a missing swatch keeps the +/// error that names the swatch. +#[test] +fn process_swatch_cannot_back_stop_plates() { + let mut doc = Document::new(100.0, 100.0); + doc.swatches.push(Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: true }); + doc.swatches.push(Swatch { name: "Process".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: false }); + let check = |pieces: Value| check_references(&decode(&out(pieces), Scope::Object, FillRule::NonZero).unwrap(), Some(&doc)); + for name in ["Process", vectorcraft_color::swatch::REGISTRATION] { + let fill = ramp(plated(json!({"swatch": name})), json!({})); + let e = check(json!([{"path": square(), "fills": [fill.clone()]}])).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].plates: `plates` requires a stop linked to a spot swatch"), "{name}: {e}"); + let e = check(json!([{"path": square()}, {"path": square(), "strokes": [{"paint": fill}]}])).unwrap_err().to_string(); + assert!(e.contains("objects[1].strokes[0].paint.gradient.stops[1].plates:"), "{name}: {e}"); + // Without `plates`, a stop may link to any swatch of the document. + let plain = ramp(json!({"offset": 1, "color": {"model": "gray", "k": 1}, "swatch": name}), json!({})); + assert!(check(json!([{"path": square(), "fills": [plain]}])).is_ok(), "{name}"); + } + let gone = ramp(plated(json!({"swatch": "Gone"})), json!({})); + let e = check(json!([{"path": square(), "fills": [gone]}])).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].swatch:"), "{e}"); +} + +/// A stop linked to a spot swatch keeps its `plates` as given, all-zero ones included, through the +/// reference check and the swatch colors. +#[test] +fn linked_spot_stop_plates_are_kept() { + let mut doc = Document::new(100.0, 100.0); + doc.swatches.push(Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: true }); + let zero = json!({"offset": 0, "color": {"model": "gray", "k": 0}, "swatch": "Ink", "plates": [0, 0, 0, 0]}); + let fill = json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [zero, plated(json!({"swatch": "Ink"}))]}}); + let stroke = ramp(plated(json!({"swatch": "Ink"})), json!({})); + let pieces = + decode(&out(json!([{"path": square(), "fills": [fill], "strokes": [{"paint": stroke}]}])), Scope::Object, FillRule::NonZero).unwrap(); + assert!(check_references(&pieces, Some(&doc)).is_ok()); + let pieces = with_swatch_colors(std::sync::Arc::new(pieces), &doc); + let plates = |p: &Paint| match p { + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.plates).collect::>(), + _ => panic!("{p:?}"), + }; + assert_eq!(plates(&pieces[0].fills.as_ref().unwrap()[0]), [Some([0.0; 4]), Some([0.1, 0.2, 0.3, 0.4])]); + assert_eq!(plates(&pieces[0].strokes.as_ref().unwrap()[0].paint), [None, Some([0.1, 0.2, 0.3, 0.4])]); +} diff --git a/crates/render/src/bounds.rs b/crates/render/src/bounds.rs index c3aaf98322..ae7ccba77e 100644 --- a/crates/render/src/bounds.rs +++ b/crates/render/src/bounds.rs @@ -58,6 +58,15 @@ pub fn drawn_bounds(doc: &Document, n: &Node) -> Option { fx::painted_reach(&doc.symbol_boxes(), n) } +/// For a path or compound path whose own Rasterize effect is followed by Transform effects that +/// move the art once ([`crate::effects::rasterize_stage`]): `n` with those Transform effects +/// hidden, which the image is made of, and the map they move the image by. The canvas draws the +/// image on its own pixel grid and moves it, and the room around the art, with the map. `None` +/// for other objects. +pub fn rasterize_stage(doc: &Document, n: &Node) -> Option<(Node, vectorcraft_geom::Affine)> { + fx::rasterize_stage(doc, n) +} + /// The outline, with `n`'s geometry effects applied, that the canvas clips `n`'s Rasterize with /// Create Clipping Mask to. Object › Rasterize's clipping mask follows it. pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0dd0beb0eb..2bf05ef537 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -72,7 +72,7 @@ mod renderer; pub(crate) use bounds::visual_bounds; pub(crate) use bounds::{ brushed_cull_bounds, cull_bounds, drawn_outline, drawn_outlines, painted_brush_width, painted_cull_bounds, painted_reach, path_cull_bounds, - three_d_art_bounds, with_own_raster_reach, + rasterize_stage, three_d_art_bounds, with_own_raster_reach, }; #[cfg(test)] pub(crate) use entries::Drawn; diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index c79260e1c0..e6400c90af 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -6,7 +6,7 @@ use super::{ }; use vectorcraft_doc::symbol_overrides::dynamic_art; -fn node_bezpath(n: &Node) -> Option { +pub(super) fn node_bezpath(n: &Node) -> Option { match &n.kind { NodeKind::Path { path, .. } => Some(path.to_bezpath()), NodeKind::Compound { children, .. } => { @@ -48,6 +48,34 @@ pub(crate) fn effected_path_carrying(doc: &Document, n: &Node, bp: &BezPath) -> apply_carrying(&n.appearance.effects, bp, &GeomContext { doc: Some(doc), ..GeomContext::of(n) }) } +/// `n`, a path or compound path whose own Rasterize effect is followed by Transform effects that +/// move its image ([`effects::rasterize_stage`]), with those Transform effects hidden, and the map +/// they move the image by ([`effects::stage_map`]): the image is made of that node's art on its +/// own pixel grid, then drawn through the map. `None` for any other object, when the node without +/// those Transform effects is drawn through its art ([`has_object_fx`]: a Transform effect that +/// copies it, Crop Marks, a 3D effect…), and when the art before the Transform effects is empty. +pub(crate) fn rasterize_stage(doc: &Document, n: &Node) -> Option<(Node, Affine)> { + if !has_fx(n) { + return None; + } + let at = effects::rasterize_stage(&n.appearance.effects)?; + let mut stage = n.clone(); + stage.appearance.effects = effects::staged_effects(&n.appearance.effects, &at); + if has_object_fx(&stage) { + return None; + } + let g = effected_path(doc, &stage, &node_bezpath(n)?); + let map = effects::stage_map(&n.appearance.effects, &at, &vectorcraft_geom::PathData::from_bezpath(&g))?; + Some((stage, map)) +} + +/// The box `map` ([`rasterize_stage`]) takes `b` to; `None` when it flattens it to a line or a +/// point, which leaves nothing to draw. +pub(crate) fn staged_reach(map: Affine, b: Rect) -> Option { + let det = map.determinant(); + (det.is_finite() && det.abs() > 1e-12).then(|| map.transform_rect_bbox(b)) +} + pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] { match item { AppearanceItem::Fill(f) => &f.effects, @@ -74,6 +102,11 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { { return cull_bounds(symbols, &art); } + // A Rasterize image moved by the Transform effects after it reaches as far as the map takes + // what the art before them reaches, the image included. + if let Some((stage, map)) = rasterize_stage(symbols.document(), n) { + return staged_reach(map, visual_bounds(symbols, &stage)?); + } let bp = node_bezpath(n)?; let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) }; let (g, carry) = effected_path_carrying(symbols.document(), n, &bp); @@ -449,6 +482,9 @@ pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option { if let Some(art) = effects::crop_marks_art(n) { return drawn_bounds(symbols, &art); } + if let Some((stage, map)) = rasterize_stage(symbols.document(), n) { + return staged_reach(map, painted_reach(symbols, &stage)?); + } let ran = ran(symbols.document(), n); let b = drawn_bounds_ran(symbols, n, Some(&ran))?; Some(spread(symbols, &ran.appearance.effects, b)) diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index d1a8429fd6..0291d998f0 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -1,4 +1,4 @@ -use super::bounds::{cull_bounds, effected_path_carrying}; +use super::bounds::{cull_bounds, effected_path_carrying, node_bezpath, rasterize_stage}; use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, ItemGeom, Kept, Made, Reads, Shadow, ShadowEntry}; use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted}; use super::raster::{ @@ -330,6 +330,11 @@ impl Renderer { /// `draw_shape` for nodes with live effects. pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { + if !f.opts.outline + && let Some(stage) = self.rasterize_stage_of(f, n, self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned()) + { + return self.draw_staged(ctx, f, stage, bp, rule); + } // The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path, // made from the node alone, so the effects give the same result every frame while the // plug-ins installed stay the same (`check_plugins`). @@ -360,6 +365,47 @@ impl Renderer { } } + /// [`rasterize_stage`] of `n`, kept across frames when `owner` is `n` as held in its document + /// (then `true` in the third place: the stage is kept, and so are its caches). + fn rasterize_stage_of(&mut self, f: &Frame, n: &Node, owner: Option>) -> Option<(Arc, Affine, bool)> { + // Checked first: this runs for every path with effects drawn. + effects::rasterize_stage(&n.appearance.effects)?; + let Some(owner) = owner else { + return rasterize_stage(f.symbols.document(), n).map(|(stage, map)| (Arc::new(stage), map, false)); + }; + let key = Arc::as_ptr(&owner) as usize; + let stamp = self.stamp; + if let Some(e) = self.fx_stages.get_mut(&key) + && Arc::ptr_eq(&e.0, &owner) + { + e.3 = stamp; + return Some((e.1.clone(), e.2, true)); + } + let (stage, map) = rasterize_stage(f.symbols.document(), n)?; + let stage = Arc::new(stage); + self.fx_stages.insert(key, (owner, stage.clone(), map, stamp)); + Some((stage, map, true)) + } + + /// Draw `stage`, the node a path's Rasterize image is made from ([`rasterize_stage`]), through + /// `map`, the Transform effects after that Rasterize effect: its art and its image, on the + /// image's own pixel grid, move with the map. A map that flattens the art leaves nothing to + /// draw. `bp` is the path's own path; with `kept`, `stage` is kept across frames and so are + /// the geometry and rasters drawn for it. + fn draw_staged(&mut self, ctx: &mut RenderContext, f: &Frame, (stage, map, kept): (Arc, Affine, bool), bp: &BezPath, rule: FillRule) { + let det = map.determinant(); + if !(det.is_finite() && det.abs() > 1e-12) { + return; + } + // A pixel's size in document units through the view and the map, measured as a frame + // measures it from its view. + let px = f.px / det.abs().sqrt(); + let fr = Frame { view: f.view * map, visible: map.inverse().transform_rect_bbox(f.visible), px, output: f.output * map, ..*f }; + let outer = if kept { self.fx_owner.replace(stage.clone()) } else { self.fx_owner.take() }; + self.draw_shape_fx(ctx, &fr, &stage, bp, rule); + self.fx_owner = outer; + } + /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry: /// `paints` is `n` with the fills and strokes it paints. #[allow(clippy::too_many_arguments)] @@ -417,6 +463,15 @@ impl Renderer { if !self.charge_fx_instance(f.doc, a) { return; } + // A path whose Transform effects after its Rasterize effect hold options that reach its + // fills and strokes: its image moves with them as for any other path. + if !f.opts.outline + && let NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } = &a.kind + && let Some(stage) = self.rasterize_stage_of(f, a, cache.then(|| a.clone())) + && let Some(bp) = node_bezpath(a) + { + return self.draw_staged(ctx, f, stage, &bp, *rule); + } let art = self.fx_art(f.doc, a, cache); // Art the cache did not keep is made again in the next frame. let kept = cache && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &art)); diff --git a/crates/render/src/kept_paths.rs b/crates/render/src/kept_paths.rs index a6deb8e334..c609590d16 100644 --- a/crates/render/src/kept_paths.rs +++ b/crates/render/src/kept_paths.rs @@ -290,7 +290,7 @@ impl Sub { /// them. fn moved(&mut self, was: &SubPath, now: &SubPath, els: &mut [PathEl], st: Option<&StrokeLayer>) { let (mut first, mut segs) = (false, vec![]); - for (k, (a, b)) in was.anchors.iter().zip(&now.anchors).enumerate() { + for (k, a, b) in was.anchors.pairs_not_shared(&now.anchors) { if same(a, b) { continue; } @@ -489,7 +489,7 @@ fn differs(was: &PathData, now: &PathData) -> bool { !std::ptr::eq(was, now) && (was.subpaths.len() != now.subpaths.len() || was.subpaths.iter().zip(&now.subpaths).any(|(w, p)| { - w.closed != p.closed || w.anchors.len() != p.anchors.len() || w.anchors.iter().zip(&p.anchors).any(|(a, b)| !same(a, b)) + w.closed != p.closed || w.anchors.len() != p.anchors.len() || w.anchors.pairs_not_shared(&p.anchors).any(|(_, a, b)| !same(a, b)) })) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6a92d91e8f..c49671fd8c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -125,6 +125,10 @@ pub struct Renderer { live_root: Option>, /// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`. fx_geom: PtrMap, + /// Per node whose Rasterize image moves with the Transform effects after it, keyed by `Arc` + /// identity like `fx_geom`: the node, the node it draws its image from and the map that moves + /// the image ([`fx::rasterize_stage`]), and the frame it was last drawn in. + fx_stages: PtrMap, Arc, Affine, u64)>, stamp: u64, /// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer). alpha: f32, @@ -274,7 +278,7 @@ mod renderer; mod text_geom; mod threads; -pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds}; +pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds, rasterize_stage}; pub use frame::FrameStats; use frame::{ ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey, diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 500d62d5b4..58d378173d 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -56,6 +56,7 @@ impl Renderer { fx_owner: None, live_root: None, fx_geom: PtrMap::default(), + fx_stages: PtrMap::default(), stamp: 0, alpha: 1.0, contexts: Vec::new(), @@ -230,6 +231,9 @@ impl Renderer { if self.fx_geom.len() > 256 { self.fx_geom.retain(|_, e| g - e.stamp <= 3); } + if self.fx_stages.len() > 256 { + self.fx_stages.retain(|_, e| g - e.3 <= 3); + } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); self.type_edges.sweep(g); diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 15bd354496..37f3fbf4f7 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -146,6 +146,7 @@ impl Renderer { } if stale { self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node)); + self.fx_stages.retain(|_, e| !effects::has_plugin_effect(&e.0)); self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); // The art of a group holds what its members' plug-in effects draw. self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node)); diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index d2d228154f..9174696d20 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -145,7 +145,7 @@ fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() { let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new("JetBrains Mono", "Regular"); parts.numerals = CompositePart::new("Inter", "Regular"); - db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts }); + db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts, custom: Vec::new() }); let style = CharStyle { size: 40.0, font_family: "CF Highlight".into(), ..Default::default() }; let t = TextObject::point(Point::new(10.0, 60.0), "Ab12", style); let g = text_geom(&t); @@ -175,7 +175,7 @@ fn a_documents_own_composite_font_draws_without_being_registered() { let (mut composite, id) = typed(NAME); let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new("JetBrains Mono", "Regular"); - composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts }); + composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts, custom: Vec::new() }); let (plain, plain_id) = typed("JetBrains Mono"); assert!(db.composite_font(NAME).is_none()); let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; @@ -236,7 +236,7 @@ fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() { let definition = |roman: &str| { let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new(roman, "Regular"); - CompositeFont { name: NAME.into(), parts } + CompositeFont { name: NAME.into(), parts, custom: Vec::new() } }; let typed = |family: &str| { let mut d = Document::new(300.0, 150.0); @@ -319,7 +319,7 @@ fn a_document_draws_type_in_another_documents_composite_font_as_missing() { }; let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new("JetBrains Mono", "Regular"); - db.set_composite_font(&CompositeFont { name: NAME.into(), parts }); + db.set_composite_font(&CompositeFont { name: NAME.into(), parts, custom: Vec::new() }); let (drawn, as_missing, in_front) = (draw(&back, id), draw(&missing, missing_id), draw(&plain, plain_id)); db.remove_composite_font(NAME); assert!(drawn.0 != in_front.0, "not in the front document's definition"); diff --git a/crates/render/src/tests/keptpath.rs b/crates/render/src/tests/keptpath.rs index e7097c4850..12e87f272b 100644 --- a/crates/render/src/tests/keptpath.rs +++ b/crates/render/src/tests/keptpath.rs @@ -140,7 +140,7 @@ fn a_long_path_draws_and_culls_after_each_edit_as_when_its_path_is_made_again() }), ("that anchor moved off it", |p| moved(&mut p.subpaths[0].anchors[1535], Vec2::new(5.0, 5.0))), ("a doubled anchor moved off its twin", |p| moved(&mut p.subpaths[0].anchors[1536], Vec2::new(0.0, -12.0))), - ("1,200 anchors moved", |p| p.subpaths[0].anchors[..1200].iter_mut().for_each(|a| moved(a, Vec2::new(2.0, 1.0)))), + ("1,200 anchors moved", |p| p.subpaths[0].anchors.iter_mut().take(1200).for_each(|a| moved(a, Vec2::new(2.0, 1.0)))), ("an anchor added", |p| _ = p.subpaths[0].insert_anchor(1520, 0.5)), ("an anchor deleted", |p| _ = p.subpaths[0].anchors.remove(1521)), ("the subpath closed", |p| p.subpaths[0].closed = true), @@ -454,7 +454,7 @@ fn random_edit(g: &mut Seeded, p: &mut PathData) -> String { match g.below(3) { 0 => moved(&mut sp.anchors[at], Vec2::new(g.offset(30), g.offset(30))), 1 => sp.anchors[at].h_out = sp.anchors[at].p + Vec2::new(g.offset(10), g.offset(10)), - _ => sp.anchors[at..].iter_mut().take(n / 10).for_each(|a| moved(a, Vec2::new(1.0, -1.0))), + _ => sp.anchors.iter_mut().skip(at).take(n / 10).for_each(|a| moved(a, Vec2::new(1.0, -1.0))), } format!("anchors moved at {at} of subpath {s} of {n}") } @@ -486,7 +486,7 @@ fn same_as_made_again(k: &mut crate::kept_paths::KeptPath, n: &Arc, st: &S fn random_edits_of_a_long_path_keep_what_making_its_path_again_makes() { let st = stroke(6.0, LineJoin::Miter, LineCap::Butt); let mut g = Seeded(5744); - let closed = |sp: SubPath| SubPath::new(sp.anchors, true); + let closed = |sp: SubPath| SubPath::of(sp.anchors, true); let path = PathData::new(vec![ zigzag(2, 11_990.0), closed(zigzag(3, 12_000.0)), @@ -517,7 +517,7 @@ fn random_edits_of_a_long_compound_path_keep_what_making_its_path_again_makes() Arc::new(Node::path(NodeId(ids.get()), p, Appearance::default())) }; let children = vec![ - member(PathData::new(vec![SubPath::new(zigzag(3, 11_990.0).anchors, true), zigzag(5, 12_010.0)])), + member(PathData::new(vec![SubPath::of(zigzag(3, 11_990.0).anchors, true), zigzag(5, 12_010.0)])), member(PathData::single(zigzag(3000, 0.0))), member(PathData::single(ring(40, Point::new(12_000.0, 15.0), 30.0))), member(PathData::single(zigzag(2, 12_020.0))), diff --git a/crates/render/src/tests/rasterize.rs b/crates/render/src/tests/rasterize.rs index 3f27ddfebe..a374d43d7a 100644 --- a/crates/render/src/tests/rasterize.rs +++ b/crates/render/src/tests/rasterize.rs @@ -421,3 +421,99 @@ fn rasterize_fills_its_area_on_white_and_add_around_grows_it() { let stroked = draw(&stroked, 10.0, 400); assert_eq!((pixel(&stroked, 96, 105), pixel(&stroked, 93, 105)[3]), ([255; 4], 0)); } + +/// Rasterize params for hard-edged pixels a point wide, with `more`. +fn hard(more: Value) -> Value { + let mut p = json!({"useDocumentResolution": false, "resolution": 72, "antiAlias": "none"}); + if let (Some(p), Some(more)) = (p.as_object_mut(), more.as_object()) { + p.extend(more.clone()); + } + p +} + +/// A Transform effect after Rasterize enlarges the image it is given: each of the image's pixels +/// is drawn twice as wide, and so is the room around the art. +#[test] +fn rasterize_then_scale_uses_the_pretransform_pixels() { + for strokes in [false, true] { + let scale = ("distort.transform", json!({"scaleH": 200, "scaleV": 200, "strokes": strokes})); + let r = draw(&doc(triangle(), &[("rasterize", hard(json!({}))), scale.clone()], 72.0), 10.0, 400); + // The 20 × 20 pixels of the image over 10..30 pt, each 2 pt wide over 0..40 pt. + assert!(squares_are_flat(&r, 0, 20, 20), "Scale Strokes & Effects {strokes}"); + assert_eq!((pixel(&r, 10, 10), pixel(&r, 390, 390)[3]), ([255, 0, 0, 255], 0), "Scale Strokes & Effects {strokes}"); + // 2 pt of white around the art becomes 4 pt: from -4 pt. + let framed = doc(triangle(), &[("rasterize", hard(json!({"background": "white", "addAround": 2}))), scale], 72.0); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let r = Renderer::new().render(&framed, 600, 600, Affine::translate((100.0, 100.0)) * Affine::scale(10.0), &opts); + assert_eq!((pixel(&r, 65, 300), pixel(&r, 55, 300)[3]), ([255; 4], 0), "Scale Strokes & Effects {strokes}"); + // It is measured where it is drawn: the image over the 10..30 pt art and 2 pt around it, + // twice as wide about its center. + let n = framed.layers[0].children().unwrap()[0].clone(); + let b = crate::drawn_bounds(&framed, &n).unwrap(); + assert!((b.x0 + 4.0).abs() < 1e-9 && (b.x1 - 44.0).abs() < 1e-9, "{b:?}"); + assert!(crate::painted_bounds(&framed, &n).is_some_and(|p| p.x0 <= -4.0 && p.x1 >= 44.0)); + } +} + +/// A Transform effect after Rasterize turns, reflects or stretches the image it is given, its pixel +/// grid included: the canvas draws what the art before the Transform effect draws, moved by the +/// effect's map. +#[test] +fn rasterize_then_rotate_and_reflect_moves_the_pixel_grid() { + let about = |m: Affine| Affine::translate((20.0, 20.0)) * m * Affine::translate((-20.0, -20.0)); + let cases = [ + (json!({"rotate": 30}), about(Affine::rotate(-30f64.to_radians()))), + (json!({"reflectX": true}), about(Affine::scale_non_uniform(-1.0, 1.0))), + (json!({"scaleH": 200, "scaleV": 100}), about(Affine::scale_non_uniform(2.0, 1.0))), + ]; + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let before = doc(triangle(), &[("rasterize", hard(json!({"background": "white", "addAround": 1})))], 72.0); + for (params, map) in cases { + let fx = [("rasterize", hard(json!({"background": "white", "addAround": 1}))), ("distort.transform", params.clone())]; + let r = draw(&doc(triangle(), &fx, 72.0), 10.0, 400); + let want = Renderer::new().render(&before, 400, 400, Affine::scale(10.0) * map, &opts); + let off = r.pixels.iter().zip(&want.pixels).filter(|(a, b)| a.abs_diff(**b) > 2).count(); + assert_eq!(off, 0, "{params}"); + } + // Twice as wide: each pixel 20 × 10 device pixels. + let r = draw(&doc(triangle(), &[("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 200, "scaleV": 100}))], 72.0), 10.0, 400); + let flat = (0..20 * 20).all(|k| { + let (x0, y0) = ((k % 20) * 20, 100 + (k / 20) * 10); + (0..200).all(|i| pixel(&r, x0 + i % 20, y0 + i / 20) == pixel(&r, x0, y0)) + }); + assert!(flat); +} + +/// A Transform effect that scales the image to nothing leaves nothing to draw or measure. +#[test] +fn rasterize_then_nonuniform_scale_and_zero_scale_are_bounded() { + let fx = [("rasterize", hard(json!({"background": "white"}))), ("distort.transform", json!({"scaleH": 0, "scaleV": 100}))]; + let d = doc(triangle(), &fx, 72.0); + let r = draw(&d, 10.0, 400); + assert!(r.pixels.chunks(4).all(|p| p[3] == 0)); + let n = d.layers[0].children().unwrap()[0].clone(); + assert_eq!(crate::drawn_bounds(&d, &n), None); + // A huge scale enlarges where the image is drawn, not the image: 20 × 20 pixels still. + let fx = [("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 10000, "scaleV": 10000}))]; + let drawn = || crate::fx::RASTERIZED.with(std::cell::Cell::get); + let before = drawn(); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let r = Renderer::new().render(&doc(triangle(), &fx, 72.0), 100, 100, Affine::translate((500.0, 500.0)), &opts); + assert_eq!(drawn() - before, 1); + // The view, around -450 pt, lies inside the image's pixel at 15..16 pt, drawn 100 pt wide + // about the art's center at 20 pt. + assert_eq!(pixel(&r, 50, 50), [255, 0, 0, 255]); +} + +/// A hidden Transform effect, or one with Transform Objects off, leaves the image where it is. +#[test] +fn rasterize_before_a_hidden_transform_keeps_its_image() { + let plain = draw(&doc(triangle(), &[("rasterize", hard(json!({})))], 72.0), 10.0, 400); + let off = + draw(&doc(triangle(), &[("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 200, "objects": false}))], 72.0), 10.0, 400); + assert!(plain.pixels == off.pixels); + let mut hidden = doc(triangle(), &[("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 200}))], 72.0); + let l = hidden.layers[0].id; + Arc::make_mut(&mut hidden.children_mut(Some(l)).unwrap()[0]).appearance.effects[1].visible = false; + assert!(plain.pixels == draw(&hidden, 10.0, 400).pixels); +} diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 36f215d032..818a64e293 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -500,8 +500,8 @@ impl Writer<'_> { /// `` when its radii are written the same, else ``. fn ellipse_shape(&self, sp: &SubPath, m: Affine) -> Option<(&'static str, String)> { let tol = 10f64.powi(-i32::from(self.opts.decimals)); - let (anchors, closed) = (sp.anchors.as_slice(), sp.closed); - let [a0, a1, a2, a3] = anchors else { return None }; + let (anchors, closed) = (sp.anchors.contiguous(), sp.closed); + let [a0, a1, a2, a3] = &*anchors else { return None }; let [p0, p1, p2, p3] = [a0.p, a1.p, a2.p, a3.p].map(|p| m * p); let c = p0.midpoint(p2); let near = |a: f64, b: f64| (a - b).abs() <= tol; diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index bedf456f11..7e67f09cf7 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -604,25 +604,41 @@ impl Writer<'_> { let mut inner = n.clone(); inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); // An SVG filter's user-space coordinates are the document's from the effect's origin: the - // object is written in them, in a group that moves it where the rest of the art goes. + // object is written in them, in a group that moves it where the rest of the art goes. A + // user space that is scaled or turned ([`turned_filter_space`]) is the group's own: the + // object is written as it is in a group inside the filter that maps it back + // ([`Self::open_filters`]), so its strokes keep their widths. let xf = self.xf; - let origin = live_svg_filter(&n.appearance.effects).map(|f| Affine::translate(f.origin())); + let live = live_svg_filter(&n.appearance.effects); + let turned = self.turned_filter_space(live.as_ref()); + let origin = live.filter(|_| turned.is_none()).map(|f| Affine::translate(f.origin())); let moved = origin.is_some_and(|o| xf * o != Affine::IDENTITY); if let Some(o) = origin.filter(|_| moved) { self.line(&format!("", self.matrix(xf * o))); self.depth += 1; self.xf = o.inverse(); } + if let Some(space) = turned { + self.line(&format!("", exact_matrix(space))); + self.depth += 1; + } let opened = self.open_filters(n, &n.appearance.effects); self.node_body(&inner); self.close_filters(opened); - if moved { + if moved || turned.is_some() { self.xf = xf; self.depth -= 1; self.line(""); } } + /// The group transform that holds a live SVG filter `live` ([`live_svg_filter`]) whose user + /// space is scaled or turned (not only moved, [`vectorcraft_effects::svg_filter::SvgFilterFx::moves_only`]): the filter's user + /// space in the output's. `None` for other filters and in SVG Tiny, which writes no filters. + fn turned_filter_space(&self, live: Option<&vectorcraft_effects::svg_filter::SvgFilterFx>) -> Option { + live.filter(|f| !f.moves_only() && !self.tiny()).map(|f| self.xf * f.to_doc()) + } + /// Open one `` per visible raster effect of `effects` (an object's, or one fill or /// stroke's) on `n`, outermost last effect; returns how many to close. pub(super) fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize { @@ -644,6 +660,7 @@ impl Writer<'_> { let mut opened = 0; // An SVG filter that is the object's last effect is written as it is ([`Self::filtered`]). let live = if std::ptr::eq(effects, n.appearance.effects.as_slice()) { live_svg_filter(effects) } else { None }; + let turned = self.turned_filter_space(live.as_ref()); for (k, f) in fx.iter().enumerate().rev() { if let RasterFx::Pixel(vectorcraft_effects::PixelFx::Svg(sf)) = f && live.is_some() @@ -654,12 +671,40 @@ impl Writer<'_> { // against the object alone, which is all it runs on ([`live_svg_filter`]). let mut input = n.clone(); input.appearance.effects.clear(); - let input_bounds = input.visual_bounds_with(&self.doc.symbol_boxes()).unwrap_or_default(); - if let Some(def) = sf.written_xml(input_bounds).and_then(|xml| vectorcraft_effects::svg_filter::with_id(&xml, &fid)) { - self.def(1, &def); + // Its box in the filter's user space: in a turned or skewed one, the box of the + // object's geometry turned into it, grown by what its strokes and other paint add + // around it in the document: a square that far to each side, as it measures in + // user space along each axis (a miter corner reaches that far along both). + let symbols = self.doc.symbol_boxes(); + let visual = input.visual_bounds_with(&symbols).unwrap_or_default(); + let user_box = if sf.moves_only() { + visual - sf.origin() + } else { + let geometric = input.geometric_bounds_with(&symbols).unwrap_or(visual); + let margin = [geometric.x0 - visual.x0, geometric.y0 - visual.y0, visual.x1 - geometric.x1, visual.y1 - geometric.y1] + .into_iter() + .fold(0.0, f64::max); + let to_user = sf.to_doc().inverse(); + input.transform(to_user, false); + let [a, b, c, d, _, _] = to_user.as_coeffs(); + let turned = input.geometric_bounds_with(&symbols).unwrap_or_default(); + turned.inflate(margin * (a.abs() + c.abs()), margin * (b.abs() + d.abs())) + }; + let def = sf.written_xml(user_box).and_then(|xml| vectorcraft_effects::svg_filter::with_id(&xml, &fid)); + if let Some(def) = &def { + self.def(1, def); self.line(&format!("")); self.depth += 1; opened += 1; + } + // Inside the group of a turned user space ([`Self::filtered`]), the object and its + // other effects are written as they are, in a group that maps them back to it. + if let Some(space) = turned { + self.line(&format!("", exact_matrix(space.inverse()))); + self.depth += 1; + opened += 1; + } + if def.is_some() { continue; } } @@ -786,8 +831,18 @@ pub(super) fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { } } +/// `a` as an SVG `matrix(…)` with every digit its numbers need to read back the same, whatever +/// the export's decimals: the transforms around a turned SVG filter ([`Writer::turned_filter_space`]) +/// and their inverses still undo each other when one holds very small numbers. +fn exact_matrix(a: Affine) -> String { + // (Without negative zeros.) + let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v + 0.0); + format!("matrix({a} {b} {c} {d} {e} {f})") +} + /// The SVG filter (Effect › SVG Filters) of an object's `effects` that SVG keeps as a ``: -/// its last visible effect, measured in points. +/// its last visible effect, in any user space the effect gives it ([`Writer::turned_filter_space`]) +/// that SVG import can store again. fn live_svg_filter(effects: &[vectorcraft_doc::Effect]) -> Option { if !vectorcraft_effects::svg_filter::writes_live(effects) { return None; @@ -797,7 +852,7 @@ fn live_svg_filter(effects: &[vectorcraft_doc::Effect]) -> Option + if (alone || !f.measured()) && f.writes_as_svg11() && f.opens_again() => { Some(f) } diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 4a549552d4..91180981db 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -142,19 +142,22 @@ fn source_of<'a>(filter: &Filter, sources: &'a HashMap) -> Optio /// XML from `sources` ([`sources`]), in user space mapped to the document by `ts`. `None` when one /// isn't there, looks different outside the document, or isn't a filter the effect draws /// ([`vectorcraft_effects::svg_filter::check`]), or -/// when `ts` turns or skews the user space or scales it past what the effect measures (0.001 to -/// 1000 points to a unit). Where `ts` moves the user space is the effect's `origin`. +/// when `ts` scales the user space past what the effect measures (0.001 to 1000 points to a unit, +/// as the square root of its area scale) or stretches its axes past what the effect's `axes` hold. +/// Where `ts` moves the user space is the effect's `origin`; a user space that `ts` turns, skews +/// or scales unevenly keeps those directions in the effect's `axes`, so the filter runs in the +/// element's own user space as SVG 1.1 defines it (). pub(super) fn kept(filters: &[Arc], ts: Affine, sources: &HashMap) -> Option> { - let [a, b, c, d, e, f] = ts.as_coeffs(); - if b != 0.0 || c != 0.0 || !(0.001..=1000.0).contains(&a) || (a - d).abs() > 1e-9 * a.abs().max(1.0) || !e.is_finite() || !f.is_finite() { - return None; - } + let space = vectorcraft_effects::svg_filter::user_space(ts)?; filters .iter() .map(|filter| { // A filter styled by the document is left out rather than drawn differently. let source = source_of(filter, sources).filter(|s| !s.styled)?; - let params = json!({ "name": source.name()?, "xml": source.xml, "scale": a, "origin": [e, f] }); + let mut params = json!({ "name": source.name()?, "xml": source.xml, "scale": space.scale, "origin": space.origin }); + if let Some(axes) = space.axes { + params["axes"] = json!(axes); + } Some(Effect { id: vectorcraft_effects::SVG_FILTER.into(), params, visible: true }) }) .collect() diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 5d700f9c5a..ef3934c2d6 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -758,7 +758,15 @@ struct Ctx { /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has /// no usable one. The styles of a `` instance share it, so each path is found and parsed /// once however many `` elements show its text. - paths: Rc>>>>, + paths: Rc>>>>, +} + +/// The path a `` follows ([`Ctx::text_path`]). +struct FollowedPath { + path: BezPath, + /// The `pathLength` of the element the `` points at, 0 or greater; `None` when the + /// `` has its own `path` or the element has no such value. + author_length: Option, } #[cfg(test)] @@ -1172,7 +1180,7 @@ impl Ctx { /// The path a `` follows, in the text's user space. It is read from the file once /// ([`Self::paths`]). - fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option> { + fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option> { if let Some(known) = self.paths.borrow().get(&tp.id()) { return known.clone(); } @@ -1184,13 +1192,14 @@ impl Ctx { /// How many elements the path of `` `tp` has, when a text built so far follows it /// ([`Self::text_path`]); 0 for any other node. fn path_elements(&self, tp: XNode) -> usize { - self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.elements().len()) + self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.path.elements().len()) } /// Find and parse the path `` `tp` follows. - fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { #[cfg(test)] TEXT_PATH_READS.set(TEXT_PATH_READS.get() + 1); + let mut author_length = None; let mut bp = match tp.attribute("path") { Some(d) => BezPath::from_svg(d).ok()?, None => { @@ -1202,6 +1211,8 @@ impl Ctx { }; let mut bp = BezPath::from_svg(&d).ok()?; bp.apply_affine(parse_transform(el.attribute("transform"))); + author_length = + el.attribute("pathLength").and_then(|v| v.trim().parse::().ok()).filter(|l| l.is_finite() && *l >= 0.0).map(f64::abs); bp } }; @@ -1209,7 +1220,7 @@ impl Ctx { bp = bp.reverse_subpaths(); } let usable = bp.segments().next().is_some(); - usable.then_some(bp) + usable.then_some(FollowedPath { path: bp, author_length }) } /// The equivalent path of a basic shape (SVG 2 › Basic Shapes), as path data: what a @@ -1458,7 +1469,8 @@ impl Ctx { let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; let mut para_styles: Vec = vec![]; - if let Some((tp, bp)) = path { + if let Some((tp, followed)) = path { + let bp = &followed.path; // Along the path: dx is extra advance, dy shifts off the path. let mut off = 0.0; for i in 0..n { @@ -1470,13 +1482,34 @@ impl Ctx { } let len: f64 = bp.segments().map(|s| s.arclen(1e-4)).sum(); let em = self.font_size(tp); - let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0]; + // `startOffset` as a share of the path's length. A percentage is of the path's computed + // length. Any other length is in the units of the referenced element's `pathLength` + // when it has one, and a `pathLength` of 0 puts any offset other than 0 infinitely far + // along (SVG 2 › Paths › The pathLength attribute). An offset infinitely far along + // starts as far past the path's end, or before its start, as type may go. + let share = tp + .attribute("startOffset") + .and_then(|v| { + let o = self.length(v, em, len)?; + let share = match followed.author_length { + Some(p) if !v.trim().ends_with('%') => { + if o == 0.0 { + 0.0 + } else { + o / p + } + } + _ => o / len, + }; + Some(if share.is_infinite() { vectorcraft_doc::PATH_LEAD_MAX.copysign(share) } else { share }) + }) + .unwrap_or(0.0); let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 }; // On a path of one closed subpath, text that starts before the path's start or past its // end wraps around the path. On an open path text may start before the path's start, // and its characters before the start are not drawn; text that starts past the end // starts at the end. Other paths start it at the path's start or end. - let path = PathData::from_bezpath(&bp); + let path = PathData::from_bezpath(bp); let wraps = path.subpaths.len() == 1 && path.is_closed(); let open = !path.is_closed(); let wrap = |s: f64| { @@ -1484,7 +1517,7 @@ impl Ctx { let s = s.rem_euclid(1.0); if s < 1.0 { s } else { 0.0 } }; - let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| { + let start = Some(share + (dx[0] - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| { if wraps { wrap(s) } else if open { diff --git a/crates/svg/src/import/text/tests.rs b/crates/svg/src/import/text/tests.rs index 1508997dcc..a6bcf07265 100644 --- a/crates/svg/src/import/text/tests.rs +++ b/crates/svg/src/import/text/tests.rs @@ -21,4 +21,9 @@ fn uses_of_a_text_on_a_path_read_the_path_once() { // A path with no usable data is not read again either. let (texts, reads, _) = prepared(&format!(r##"Path{uses}"##)); assert_eq!((texts, reads), (51, 1)); + // The path's pathLength is read with it. + let (texts, reads, _) = prepared(&format!( + r##"Path{uses}"## + )); + assert_eq!((texts, reads), (51, 1)); } diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 3a598f5ca1..239ce7e07a 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -763,14 +763,15 @@ fn percentages_in_a_symbol_are_of_its_view_box() { } /// #1598: the warning for a filter that neither the live effects nor an SVG filter effect can hold -/// (here, on turned objects) names the filter, its primitives and the object, and says it is drawn -/// without it (not "a group": usvg wraps the element in one). Such a filter on an object that -/// isn't turned comes in as an SVG filter effect holding the filter's XML. +/// (here, on objects skewed past what the effect measures) names the filter, its primitives and the +/// object, and says it is drawn without it (not "a group": usvg wraps the element in one). Such a +/// filter on an object that isn't skewed that far comes in as an SVG filter effect holding the +/// filter's XML. #[test] fn unsupported_filters_are_named_in_the_warning() { let body = r#" - - + + "#; let (d, w) = import_with_report(&svg(body)).unwrap(); assert!(w.iter().any(|w| w == "filter 'f' (feColorMatrix) on an object isn't supported; drawn without it"), "{w:?}"); diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index 1d10c5d9fe..b39842e4fa 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -522,7 +522,8 @@ fn a_filter_used_twice_among_many_is_kept_twice() { assert_eq!(filters, vec![vec![json!("g")], vec![json!("g")]], "{:?}", art(&d)); } -/// Other filters come in as SVG filters; one on a turned object is reported and left out. +/// Other filters come in as SVG filters, on a turned or skewed object too, with the object's user +/// space in `scale`, `origin` and `axes`; one past what the effect measures is reported and left out. #[test] fn other_filters_are_kept_or_reported() { let (d, w) = @@ -530,8 +531,32 @@ fn other_filters_are_kept_or_reported() { assert!(w.is_empty(), "{w:?}"); let fx = effects(&d); assert_eq!((fx.len(), fx[0].id.as_str(), &fx[0].params["name"]), (1, "svg.filter", &json!("t"))); + assert!(fx[0].params.get("axes").is_none(), "the document's axes: {:?}", fx[0].params); let (d, w) = open( - r##""##, + r##""##, + ); + assert!(w.is_empty(), "{w:?}"); + let fx = effects(&d); + assert_eq!(fx.len(), 1); + let p = &fx[0].params; + let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + let num = |v: &serde_json::Value| v.as_f64().unwrap(); + assert!((num(&p["scale"]) - 2.0).abs() < 1e-6 && p["origin"] == json!([10.0, 20.0]), "{p}"); + let axes: Vec = p["axes"].as_array().unwrap().iter().map(num).collect(); + assert!(axes.iter().zip([c, s, -s, c]).all(|(a, b)| (a - b).abs() < 1e-6), "{axes:?}"); + // Skewed, and scaled unevenly: the axes keep both. + let (d, w) = open( + r##""##, + ); + assert!(w.is_empty(), "{w:?}"); + let all: Vec> = + art(&d).iter().flat_map(|n| &n.appearance.effects).map(|e| e.params["axes"].as_array().unwrap().iter().map(num).collect()).collect(); + assert_eq!(all.len(), 2); + assert!(all.iter().any(|a| a.iter().zip([1.0, 0.0, 1.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-6)), "{all:?}"); + assert!(all.iter().any(|a| a.iter().zip([0.5, 0.0, 0.0, 2.0]).all(|(a, b)| (a - b).abs() < 1e-6)), "{all:?}"); + // Skewed so far that an axis would stretch past 1000 times: reported and left out. + let (d, w) = open( + r##""##, ); assert!(w.iter().any(|w| w.contains("filter")), "{w:?}"); assert!(effects(&d).is_empty()); @@ -543,6 +568,40 @@ fn other_filters_are_kept_or_reported() { assert!(effects(&d).is_empty()); } +/// A filter on a turned object runs in the object's user space (SVG 1.1 measures its lengths +/// there): opened, it draws as the same filter does on the same art drawn unturned with its offset +/// turned by hand. +#[test] +fn a_filter_on_a_turned_object_draws_in_its_user_space() { + let filter = |dx: f64, dy: f64| { + format!( + r#""# + ) + }; + let turned = format!( + r##"{}"##, + filter(24.0, 0.0) + ); + let (d, w) = open(&turned); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(effects(&d).iter().map(|e| e.id.as_str()).collect::>(), ["svg.filter"]); + // The same square, its corners turned by hand, with the offset 24 along the turned x axis. + let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + let corner = |x: f64, y: f64| format!("{},{}", 80.0 + c * (x - 80.0) - s * (y - 50.0), 50.0 + s * (x - 80.0) + c * (y - 50.0)); + let reference = format!( + r##"{}"##, + filter(24.0 * c, 24.0 * s), + corner(60.0, 30.0), + corner(100.0, 30.0), + corner(100.0, 70.0), + corner(60.0, 70.0) + ); + let canvas = render_artboard(&d); + // The moved square lies beyond where it was: its center, turned and moved, is near (100.8, 62). + assert!(canvas.over_white(101, 62) != [255, 255, 255], "drawn where the offset puts it"); + assert_similar(&canvas, &resvg_render(&svg(&reference), 200, 120), 40.0, 0.02); +} + #[test] fn a_filter_and_a_clip_on_one_object_filter_first() { let body = r##" @@ -638,6 +697,69 @@ fn filtered_rect(xml: &str) -> Document { d } +/// An SVG filter in a scaled, turned or skewed user space is written live: the filter's user space +/// is a group's transform, and the art is written in document coordinates in a group inside the +/// filter that maps it back, so its stroke keeps its width. Opened again, it is the same effect +/// and draws as the canvas drew it. +#[test] +fn an_exported_turned_filter_opens_as_the_same_effect() { + let xml = r#""#; + for (scale, axes) in [(2.0, json!([0.8, 0.6, -0.6, 0.8])), (1.0, json!([1.0, 0.0, 0.5, 1.0])), (0.5, serde_json::Value::Null)] { + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.1, 0.1)), Paint::solid(Color::BLACK), 4.0), + ); + let mut params = json!({ "xml": xml, "scale": scale, "origin": [30.0, 10.0] }); + if !axes.is_null() { + params["axes"] = axes.clone(); + } + n.appearance.effects = vec![Effect { id: "svg.filter".into(), params, visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = export(&d, &ExportOptions::default()); + assert!(out.contains(" = fx[0].params["origin"].as_array().unwrap().iter().map(num).collect(); + assert!((origin[0] - 30.0).abs() < 1e-6 && (origin[1] - 10.0).abs() < 1e-6, "{origin:?}"); + match fx[0].params.get("axes") { + Some(got) => { + let got: Vec = got.as_array().unwrap().iter().map(num).collect(); + let want: Vec = axes.as_array().unwrap().iter().map(num).collect(); + assert!(got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{got:?}"); + } + None => assert!(axes.is_null(), "{:?}", fx[0].params), + } + assert_similar(&render_artboard(&d), &render_artboard(&back), 40.0, 0.02); + } + // A user space stretched 10,000 times along x: the inverse transform holds 0.0001, which the + // export's three decimals would round to 0, and the art still opens at its place. + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.1, 0.1)), Paint::None, 0.0), + ); + let xml = r#""#; + let params = json!({ "xml": xml, "scale": 10.0, "axes": [1000.0, 0.0, 0.0, 1.0] }); + n.appearance.effects = vec![Effect { id: "svg.filter".into(), params, visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = export(&d, &ExportOptions::default()); + let (back, w) = import_with_report(&out).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let bounds = |d: &Document| art(d).iter().find_map(|n| n.path_data().and_then(|p| p.bounds())).unwrap(); + let (a, b) = (bounds(&d), bounds(&back)); + assert!((a.x0 - b.x0).abs() < 1e-3 && (a.x1 - b.x1).abs() < 1e-3 && (a.y1 - b.y1).abs() < 1e-3, "{a:?} {b:?}: {out}"); +} + /// SVG 1.1 export writes an SVG filter's image reference as `xlink:href`, and it imports back. #[test] fn an_exported_filter_image_has_an_xlink_reference() { diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 72b476eb24..fa91f8d162 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -299,6 +299,38 @@ fn text_path_becomes_type_on_a_path() { assert_eq!(t[2].para.justify, Justify::Left); } +#[test] +fn text_path_start_offset_is_scaled_by_the_path_length() { + // A path 400 long whose pathLength is 100: a startOffset of 50 lies 200 along it. A + // percentage stays a share of the computed length. Huge values of both still give their + // ratio. With a pathLength of 0, an offset of 0 stays at the start and any other offset lies + // past the end, as does an offset too large to scale on a short path. + let d = open( + r##" + + + + Half + Quarter + Huge + Zero + Fifty + Past"##, + ); + let t = texts(&d); + assert_eq!(t.len(), 6); + let start_of = |t: &TextObject| match t.kind { + TextKind::OnPath { start, .. } => start, + _ => panic!("{:?}", t.kind), + }; + assert!(close(start_of(t[0]), 0.5, 1e-9), "{}", start_of(t[0])); + assert!(close(start_of(t[1]), 0.25, 1e-9), "{}", start_of(t[1])); + assert!(close(start_of(t[2]), 0.5, 1e-9), "{}", start_of(t[2])); + assert_eq!(start_of(t[3]), 0.0); + assert_eq!(start_of(t[4]), 1.0); + assert_eq!(start_of(t[5]), 1.0); +} + #[test] fn text_on_a_closed_path_wraps_past_the_path_start() { // A closed path 400 long: text anchored before its start, or offset past its end, wraps diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 11f174e1a4..4891e679c3 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -52,6 +52,10 @@ pub struct OtFeatures { pub extra_values: [u16; EXTRA_FEATURES.len()], /// The value `swsh=N` gives swashes ([`Self::swash`]), as [`Self::extra_values`] for `salt`. pub swash_value: u16, + /// Set a complex script (Indic, Arabic, Hebrew) glyph by glyph as the font maps its + /// characters, with none of its layout tables: the Standard composers. Set per paragraph by + /// layout from the paragraph's composer, not by a style. + pub unshaped_complex: bool, } /// The largest value a `tag=N` feature takes: a font's glyph ids are 16 bits, so is an alternate's @@ -94,6 +98,7 @@ impl Default for OtFeatures { extra: 0, extra_values: [0; EXTRA_FEATURES.len()], swash_value: 0, + unshaped_complex: false, } } } @@ -167,6 +172,7 @@ impl OtFeatures { extra: u32::MAX >> (32 - EXTRA_FEATURES.len()), extra_values: [0; EXTRA_FEATURES.len()], swash_value: 0, + unshaped_complex: false, }; change(&mut off); change(&mut on); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f315f2bb69..e96e1d0fae 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -119,6 +119,9 @@ pub struct FontDb { /// type naming one as its family is set in its parts' fonts. A thread's guards can lend others /// in their place ([`FontDb::lend_composite_fonts`]). composites: RwLock>>, + /// The lookups of the custom character sets of the composite fonts held or lent, each built + /// when first used. + custom_classes: Mutex, } /// Where a database's system font scan looks: folders (read with their subfolders) and font files. diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index 79a608788b..7b17fb74de 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -1,15 +1,18 @@ //! Composite fonts (Type › Composite Fonts…) in the font database: each is listed first in the //! font menus and resolves to its base (Kanji) font, and layout sets each character of type in one -//! in the font of the character's class ([`FontDb::composite_split`]). +//! in the font of the custom character set that lists it, else of its class +//! ([`FontDb::composite_split`]). use std::cell::RefCell; use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use std::marker::PhantomData; +use std::sync::Weak; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::UnicodeSegmentation; -use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun}; +use vectorcraft_doc::text::CompositeClasses; +use vectorcraft_doc::{CharClass, CompositeClass, CompositeFont, CompositePart, TextObject, TextRun}; use super::face::FALLBACK_FAMILY; use super::{Arc, FontDb, FontFace, FontMatch}; @@ -17,6 +20,13 @@ use super::{Arc, FontDb, FontFace, FontMatch}; /// A face [`FontDb::resolve`] found and how it matched. type Resolved = Option<(Arc, FontMatch)>; +/// The base class: type without characters and anything no part names takes its part. +const KANJI: CompositeClass = CompositeClass::Fixed(CharClass::Kanji); + +/// Lookups of the custom character sets of composite fonts, by the address of the font's `Arc`, +/// each with a weak reference that tells when that font has gone and the address may be reused. +pub(super) type CustomClasses = HashMap, Arc)>; + thread_local! { /// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the /// latest loan last. Only the latest loan of a database is looked in. @@ -41,15 +51,25 @@ struct Loan { print: u64, } -/// Feed composite font `f`'s name and parts to `h`. +/// Feed composite font `f`'s name, parts and custom character sets to `h`. fn hash_font(f: &CompositeFont, h: &mut DefaultHasher) { f.name.hash(h); let p = &f.parts; for part in [&p.kanji, &p.kana, &p.punctuation, &p.symbols, &p.roman, &p.numerals] { - (&part.font, &part.style, part.scale_at_center).hash(h); - for v in [part.size, part.baseline, part.h_scale, part.v_scale] { - v.to_bits().hash(h); - } + hash_part(part, h); + } + f.custom.len().hash(h); + for s in &f.custom { + (&s.name, &s.characters).hash(h); + hash_part(&s.part, h); + } +} + +/// Feed part `part`'s font and values to `h`. +fn hash_part(part: &CompositePart, h: &mut DefaultHasher) { + (&part.font, &part.style, part.scale_at_center).hash(h); + for v in [part.size, part.baseline, part.h_scale, part.v_scale] { + v.to_bits().hash(h); } } @@ -136,8 +156,8 @@ impl FontDb { .unwrap_or(g) } - /// The face composite font `name` sets character `c` in (its class's part's), and whether - /// that part's font is missing. `None` when `name` is no composite font. + /// The face composite font `name` sets character `c` in (its custom set's or its class's + /// part's), and whether that part's font is missing. `None` when `name` is no composite font. pub fn composite_face(&self, name: &str, c: char) -> Option<(bool, u32)> { self.composite_resolve(name, c).map(|(face, m)| (m == FontMatch::Missing, face.id())) } @@ -149,30 +169,58 @@ impl FontDb { self.composite_resolve(name, first) } - /// The face composite font `name` sets character `c` in (its class's part's), and how that - /// part's font matched ([`FontDb::resolve`]). `None` when `name` is no composite font, or no - /// font at all is loaded. + /// The face composite font `name` sets character `c` in (its custom set's or its class's + /// part's), and how that part's font matched ([`FontDb::resolve`]). `None` when `name` is no + /// composite font, or no font at all is loaded. pub fn composite_resolve(&self, name: &str, c: char) -> Option<(Arc, FontMatch)> { let f = self.composite_font(name)?; - let (family, style) = self.part_font(&f, CharClass::of(c)); + let (family, style) = self.part_font(&f, self.composite_class(&f, c)); self.resolve(&family, &style) } + /// Where composite font `f` sets character `c`: the last custom set that lists it, else its + /// class. The lookup of `f`'s sets is built once and kept while `f` lives. + fn composite_class(&self, f: &Arc, c: char) -> CompositeClass { + if f.custom.is_empty() { + return CompositeClass::Fixed(CharClass::of(c)); + } + self.custom_lookup(f).of(c) + } + + /// The lookup of composite font `f`'s custom character sets, built when first asked for. + fn custom_lookup(&self, f: &Arc) -> Arc { + if f.custom.is_empty() { + return Arc::default(); + } + let key = Arc::as_ptr(f) as usize; + let mut cache = self.custom_classes.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((font, lookup)) = cache.get(&key) + && font.upgrade().is_some_and(|g| Arc::ptr_eq(&g, f)) + { + return lookup.clone(); + } + cache.retain(|_, (font, _)| font.strong_count() > 0); + let lookup = Arc::new(f.classes()); + cache.insert(key, (Arc::downgrade(f), lookup.clone())); + lookup + } + /// The fonts (family, style) composite font `name` sets `text` in: those of the parts its /// grapheme clusters fall in (each by its first character, as layout splits them), each once, /// in the order first used; the Kanji part's for text without characters. `None` when `name` is /// no composite font. pub fn composite_fonts_for(&self, name: &str, text: &str) -> Option> { let f = self.composite_font(name)?; - let mut classes: Vec = Vec::new(); + let lookup = self.custom_lookup(&f); + let mut classes: Vec = Vec::new(); for g in text.graphemes(true) { - let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let c = g.chars().next().map_or(KANJI, |c| lookup.of(c)); if !classes.contains(&c) { classes.push(c); } } if classes.is_empty() { - classes.push(CharClass::Kanji); + classes.push(KANJI); } let mut fonts: Vec<(String, String)> = Vec::with_capacity(classes.len()); for c in classes { @@ -196,8 +244,9 @@ impl FontDb { FontMatch::Style => 1, FontMatch::Missing => 2, }; - let mut faces: Vec<(CharClass, Resolved)> = Vec::new(); - let mut face_of = |class: CharClass| { + let lookup = self.custom_lookup(&f); + let mut faces: Vec<(CompositeClass, Resolved)> = Vec::new(); + let mut face_of = |class: CompositeClass| { if let Some((_, found)) = faces.iter().find(|(c, _)| *c == class) { return found.clone(); } @@ -213,7 +262,7 @@ impl FontDb { graphemes.push(""); } for g in graphemes { - let class = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let class = g.chars().next().map_or(KANJI, |c| lookup.of(c)); let found = face_of(class); let m = found.as_ref().map_or(FontMatch::Missing, |(_, m)| *m); if worst.as_ref().is_none_or(|(w, _)| rank(m) > rank(*w)) { @@ -307,10 +356,11 @@ impl FontDb { LentCompositeFonts { number: pushed.then_some(number), thread: PhantomData } } - /// The family and style composite font `f` sets characters of `class` in: its part's, or the - /// fallback font's when that part names a composite font. - fn part_font(&self, f: &CompositeFont, class: CharClass) -> (String, String) { - let p = f.parts.get(class); + /// The family and style composite font `f` sets characters of `class` in: its part's (the + /// Kanji part's for a custom set it doesn't have), or the fallback font's when that part names + /// a composite font. + fn part_font(&self, f: &CompositeFont, class: CompositeClass) -> (String, String) { + let p = f.part(class).unwrap_or(&f.parts.kanji); if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) } } @@ -326,11 +376,13 @@ impl FontDb { /// names a composite font too. `None` when `name` is no composite font. pub(super) fn composite_base(&self, name: &str) -> Option<(String, String)> { let f = self.composite_font(name)?; - Some(self.part_font(&f, CharClass::Kanji)) + Some(self.part_font(&f, KANJI)) } - /// `t` with each run set in a composite font split into runs of one [`CharClass`] each, set in - /// that class's font: its size and baseline shift from the part's size and baseline (in + /// `t` with each run set in a composite font split into runs of one custom character set or + /// [`CharClass`] each (a set listing a character takes it ahead of its class, the font's last + /// such set when several do), set in that set's or class's font: its size and baseline shift + /// from the part's size and baseline (in /// percent of the run's size), its scales multiplied by the part's, and Kana scaled at the /// center raised so the em box's center stays where it was. Each grapheme cluster goes with /// its first character. Also the index of the run of `t` each run came from. `None` when no run @@ -352,10 +404,11 @@ impl FontDb { from.push(i); continue; }; + let lookup = self.custom_lookup(font); let mut start = 0; let mut class = None; for (at, g) in r.text.grapheme_indices(true) { - let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let c = g.chars().next().map_or(KANJI, |c| lookup.of(c)); if let Some(k) = class && k != c { @@ -365,16 +418,16 @@ impl FontDb { } class = Some(c); } - runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(CharClass::Kanji))); + runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(KANJI))); from.push(i); } Some((TextObject { runs, ..t.clone() }, from)) } - /// Characters `text` of run `r` (set in composite font `font`), all of class `class`, as a run - /// set in that class's part. - fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CharClass) -> TextRun { - let p = font.parts.get(class); + /// Characters `text` of run `r` (set in composite font `font`), all of class or set `class`, + /// as a run set in that class's or set's part. + fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CompositeClass) -> TextRun { + let p = font.part(class).unwrap_or(&font.parts.kanji); let mut st = r.style.clone(); let base = st.size; let (family, style) = self.part_font(font, class); @@ -386,7 +439,7 @@ impl FontDb { st.baseline_shift += base * p.baseline / 100.0; st.h_scale *= p.h_scale / 100.0; st.v_scale *= p.v_scale / 100.0; - if class == CharClass::Kana && p.scale_at_center && p.v_scale != 100.0 { + if class == CompositeClass::Fixed(CharClass::Kana) && p.scale_at_center && p.v_scale != 100.0 { let centre = self.style_face(&st).map_or(0.38, |f| f.ideographic_centre()); st.baseline_shift += st.size * centre * (1.0 - p.v_scale / 100.0); } diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 2fda0e79a1..738a158952 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -74,6 +74,7 @@ impl FontDb { stamp: Mutex::new(None), instances: Mutex::new(Instances::default()), composites: RwLock::new(HashMap::new()), + custom_classes: Mutex::new(HashMap::new()), } } diff --git a/crates/text/src/kashida.rs b/crates/text/src/kashida.rs index c36eacbf2d..8db784a2bc 100644 --- a/crates/text/src/kashida.rs +++ b/crates/text/src/kashida.rs @@ -78,10 +78,11 @@ fn joining(c: char) -> Joining { } /// Is the cluster of glyph `a` joined to the cluster of glyph `b` that follows it in `text`: both -/// right to left in one face, `a`'s last character other than a mark joining the letter after it, -/// and `b` starting with a letter that joins the one before it? +/// right to left in one face and set as joining letters (not by a Standard composer), `a`'s last +/// character other than a mark joining the letter after it, and `b` starting with a letter that +/// joins the one before it? fn joined(text: &str, a: &SGlyph, b: &SGlyph) -> bool { - if a.face.id() != b.face.id() || a.level != b.level || !a.level.is_rtl() || a.byte + a.len != b.byte { + if a.face.id() != b.face.id() || a.level != b.level || !a.level.is_rtl() || a.byte + a.len != b.byte || !(a.cursive && b.cursive) { return false; } let last = text.get(a.byte..a.byte + a.len).and_then(|s| s.chars().rev().find(|&c| joining(c) != Joining::Transparent)); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6f558d86dd..0aa6e1aee5 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -22,7 +22,7 @@ use crate::shape::{ Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, run_face, shape_range, shape_runs, soft_line_start, style_metrics, }; -use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; +use crate::{FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; mod bidi; mod breaking; @@ -229,11 +229,14 @@ impl Ctx<'_> { out } - /// Shape paragraph `r` (its bidi resolution `bidi`). - fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { + /// Shape paragraph `r` (its bidi resolution `bidi`, its attributes `para`). A Standard composer + /// sets a complex script glyph by glyph ([`OtFeatures::unshaped_complex`]). + fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>, para: &ParaStyle) -> Vec { let mut v = Vec::with_capacity(r.len()); let levels = bidi.map_or(&[][..], |b| &b.levels); - shape_runs(self.db, self.text, r, &self.runs, &self.inlines, &self.faces, &self.opts.features, levels, &mut v); + let standard = !self.opts.composer.unwrap_or(para.composer).shapes_complex_scripts(); + let feats = OtFeatures { unshaped_complex: standard, ..self.opts.features }; + shape_runs(self.db, self.text, r, &self.runs, &self.inlines, &self.faces, &feats, levels, &mut v); self.finish_shaping(&mut v, |g| self.style_at(g.byte)); v } @@ -373,7 +376,8 @@ impl Ctx<'_> { // Only the glyph right after an upright glyph can continue its cluster. let cluster = self.upright_cluster.take(); // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). - let blank = g.is_soft_hyphen() || g.ch.is_control() || g.ch == '\u{2028}'; + // Joiners and direction marks are format controls: no ink either, whatever glyph the font maps. + let blank = g.is_soft_hyphen() || g.ch.is_control() || g.ch == '\u{2028}' || crate::justify::is_format_control(g.ch); // A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or // COLR paints documents have no form for), not its outline; COLR, then SVG, then bitmaps. // Art in the text's colours takes the run's fill (black when it isn't a colour) and diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index fadb0165d5..ef62fa2bcc 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -395,8 +395,29 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins if end >= g.len() { set(w, trailing, ink) } else { w } } +/// Whether the cluster `j..end` is drawn last, at the right end, of the left-to-right line of +/// glyphs `i..end`: the line's glyphs are reordered by their levels (UAX #9 L2), with each tab and +/// the spaces before it at the paragraph's level (L1). +fn drawn_last(g: &[SGlyph], i: usize, j: usize, end: usize) -> bool { + let line = g.get(i..end).unwrap_or_default(); + let mut levels: Vec = line.iter().map(|h| h.level).collect(); + let mut before_tab = false; + for (level, h) in levels.iter_mut().zip(line).rev() { + if h.ch == '\t' && h.len > 0 { + before_tab = true; + } else if !h.is_space() { + before_tab = false; + } + if before_tab { + *level = unicode_bidi::Level::ltr(); + } + } + unicode_bidi::BidiInfo::reorder_visual(&levels).last().is_some_and(|&k| i + k >= j) +} + /// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. -/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it. +/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it, +/// unless No Break or Bunri-Kinshi keeps characters after it on its line. /// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when /// the line may hyphenate a word; a line whose text before the overflowing word already ends /// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop @@ -416,7 +437,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// line holds a glyph with a negative advance and has been wider than `width` in the order of its /// text, it reads at most [`MAX_DRAWN_CHECKS_STEPPING_BACK`] legal breaks further, and a forced /// line break right after the last of them, and ends at the latest of the breaks read that fits as -/// drawn. +/// drawn. Such a line with a tab hangs a burasagari mark only when the mark is drawn at its right +/// end in the visual order of the line's levels ([`drawn_last`]) and the line before the mark fits +/// as drawn. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -543,8 +566,24 @@ pub(super) fn break_line( let measured = if tabbed && fits.is_some() { (ink + cluster_ink).max(stops_reached) } else { x + cluster_width }; overflowed |= tabbed && fits.is_some() && j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS; if j > first && !gl.is_space() && measured - end_credit > width + EPS { - // It ends the line with the spaces after it. - if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { + // It ends the line with the spaces after it, when the mark is all the characters kept + // together (the line's last character is the mark, which is what hangs). Once the line + // holds a tab and is measured as drawn, the mark hangs only where it is drawn at the + // line's right end (its cluster is last in the line's visual order, [`drawn_last`]) and + // the line before it fits as drawn with no line-end trim on its last glyph; otherwise the + // line ends at a legal break that fits as drawn. + let alone = g[j..cluster_end].iter().all(|h| h.byte == gl.byte); + let hangs_as_drawn = || match fits.filter(|_| tabbed) { + None => true, + Some(fits) => { + let mut body = j; + while body > i && g[body - 1].is_space() { + body -= 1; + } + drawn_last(g, i, j, cluster_end) && line_end_credit(g, line_end, body) == 0.0 && fits(j, false) + } + }; + if burasagari != Burasagari::None && hangs(gl) && alone && kinsoku_allows(g, cluster_end - 1, kinsoku) && hangs_as_drawn() { let mut end = cluster_end; while g.get(end).is_some_and(SGlyph::is_space) { end += 1; diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 30b615c5fc..0c4e1ae615 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -8,9 +8,9 @@ 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, 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, + Affine, BidiInfo, Burasagari, CharStyle, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo, 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}; @@ -62,7 +62,7 @@ fn compose_para( // The tab of a list marker is on the first line, with the marker, and has a fixed width. let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); let has_soft_break = sg.iter().any(|g| g.ch == '\u{2028}'); - if composer != Composer::EveryLine + if composer.is_single_line() || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS @@ -247,7 +247,7 @@ fn prepare_paragraph<'a, 'text>( let text = cx.text; let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(&pr)); let rtl = is_rtl(bidi.as_ref()); - let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); + let mut sg = cx.shape_para(pr.clone(), bidi.as_ref(), para); // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); // Korean breaks between words, unless Korean Line Break Anywhere lets it break between syllables. @@ -321,7 +321,7 @@ fn prepare_paragraph<'a, 'text>( 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. - let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; + let zone = if cx.opts.composer.unwrap_or(para.composer).is_single_line() { para.hyphenation.zone } else { 0.0 }; let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); let mirror = mirrors_indents(para, rtl, cx.vertical); ParagraphData { @@ -474,7 +474,9 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC ¶.tabs, ind_l, hyphenate, - para.burasagari, + // A right-to-left paragraph draws a burasagari mark inside its line + // ([`prepare_line_geometry`]): the line ends where it fits with the mark. + if *rtl { Burasagari::None } else { para.burasagari }, para.kinsoku, ty, *hanging, diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index d1072d3781..ecb1377d10 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -422,7 +422,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, let text = cx.text; let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical); - let mut glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); + let mut glyphs = cx.shape_para(pr.clone(), bidi.as_ref(), t.para_at(pi)); // Bullets and numbers: a list paragraph's text starts after its marker here too, unless // the paragraph continues from the frame before in its thread. if pi > 0 || !t.first_para_continues { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index a0a09956bc..c7d8ff8a4b 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -477,6 +477,95 @@ fn a_tabbed_line_ends_at_the_latest_break_that_fits_as_drawn() { assert_eq!(ended, (8, false)); } +/// A tabbed line measured as drawn hangs a burasagari full stop that overflows only when the stop +/// is drawn at the line's right end (last in the visual order of the line's levels, whatever its +/// own level) and the line before it fits as drawn with no line-end trim on its last glyph; +/// otherwise it ends at the latest legal break that fits as drawn. +#[test] +fn a_tabbed_burasagari_mark_hangs_only_at_the_drawn_end_of_a_fitting_line() { + let text = "a \t\u{05D0} 漢。"; + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + glyphs[3].level = unicode_bidi::Level::rtl(); + // The text without its tab is 60 pt wide at 。 (glyph 6), past 55 pt; kinsoku keeps 。 off a line + // start, so the latest legal break before it ends the line before 漢 (glyph 5). + let end = |glyphs: &[SGlyph], line_end: &[f64], fits: Option<&dyn Fn(usize, bool) -> bool>| { + break_line( + text, + glyphs, + 0, + 55.0, + &[], + 0.0, + None, + Burasagari::Standard, + Kinsoku::Hard, + KinsokuShoriType::PushOutOnly, + false, + false, + line_end, + fits, + ) + }; + let all_fit = |_end: usize, _hyphenated: bool| true; + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (7, false), "drawn at the right end of a line that fits: 。 hangs"); + let body_too_wide = |end: usize, _hyphenated: bool| end <= 5; + assert_eq!(end(&glyphs, &[], Some(&body_too_wide)), (5, false), "the line before 。 is too wide as drawn"); + let mut trimmed = vec![0.0; glyphs.len()]; + trimmed[5] = 2.0; + assert_eq!(end(&glyphs, &trimmed, Some(&all_fit)), (5, false), "the line before 。 fits only with 漢 trimmed"); + let level = |n: u8| unicode_bidi::Level::new(n).unwrap_or_else(|_| panic!("level {n}")); + glyphs[6].level = level(2); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (7, false), "。 set left to right in right-to-left text is drawn at the right end"); + glyphs[6].level = level(1); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (7, false), "。 alone right to left after 漢 is drawn at the right end"); + glyphs[5].level = level(1); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (5, false), "。 right to left after 漢 in the same run is drawn inside the line"); + glyphs[6].level = level(2); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (5, false), "。 left to right inside the right-to-left run of 漢 is drawn inside the line"); +} + +/// A burasagari full stop that No Break keeps with the character after it does not hang: the line +/// would end with that character, which is drawn inside the line, so it ends at an earlier break. +#[test] +fn a_burasagari_mark_kept_with_the_next_character_does_not_hang() { + let text = "漢漢。漢"; + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + let end = |glyphs: &[SGlyph]| { + break_line(text, glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], None) + }; + assert_eq!(end(&glyphs), (3, false), "alone, 。 overflows by 5 pt and hangs"); + glyphs[2].no_break = true; + glyphs[3].no_break = true; + assert_eq!(end(&glyphs), (1, false), "kept with the 漢 after it, 。 does not end the line"); +} + +/// A line with a tab and right-to-left text pushes a closing bracket out with every Kinsoku Shori +/// Type, where the same widths without them push it in. +#[test] +fn push_in_leaves_a_tabbed_right_to_left_line_to_push_out() { + use vectorcraft_doc::{TabAlign, TabStop}; + let tabbed = "\u{05D0}\t一二三四五」六"; + let mut glyphs = full_width(tabbed); + glyphs[0].level = unicode_bidi::Level::rtl(); + glyphs[1].adv = 2.0; + let tabs = [TabStop { position: 12.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let all_fit = |_end: usize, _hyphenated: bool| true; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&all_fit); + // 」 (glyph 7) ends 72 pt along, 5 pt past the measure: five gaps close by 1 pt each. + let end = |ty| break_line(tabbed, &glyphs, 0, 67.0, &tabs, 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[], fits); + for ty in [KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutFirst, KinsokuShoriType::PushOutOnly] { + assert_eq!(end(ty), (6, false), "{ty:?}: 五 is pushed out with 」"); + } + let plain = "ax一二三四五」六"; + let mut glyphs = full_width(plain); + glyphs[1].adv = 2.0; + let pushed = + break_line(plain, &glyphs, 0, 67.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[], None); + assert_eq!(pushed, (8, false), "without the tab and right-to-left text, 」 is pushed in"); +} + /// Past the breaks a tabbed line measures one by one, it ends at the latest break without a hyphen /// that fits as drawn, whatever the breaks with a hyphen between them measure. #[test] diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 4c320a2bb4..13cd2e06de 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -128,6 +128,27 @@ pub(crate) fn joins(script: Script) -> bool { ) } +/// Is `script` one a Standard composer leaves unshaped: Indic (conjuncts, half forms, marks placed +/// by the font's layout tables), the joining scripts ([`joins`]: Arabic, Syriac, N'Ko, Mongolian…) +/// and Hebrew? +fn is_complex_script(script: Script) -> bool { + joins(script) + || matches!( + script, + Script::Hebrew + | Script::Devanagari + | Script::Bengali + | Script::Gurmukhi + | Script::Gujarati + | Script::Oriya + | Script::Tamil + | Script::Telugu + | Script::Kannada + | Script::Malayalam + | Script::Sinhala + ) +} + /// How `vpal` and the vertical kerning with it move an upright glyph in vertical type and change /// its length down the column, in points: `advance` down the column, `dx` and `dy` in glyph space /// (y down; `dy` is down the column once the glyph stands upright). `proportional`: `vpal` itself @@ -1394,7 +1415,13 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let source_runs = &(source_runs.start + first..source_runs.start + last); let text_seg = &text[range.clone()]; // The segment's script: that of its first character of a script of its own. - let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins); + let first_script = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)); + // A Standard composer sets a complex script glyph by glyph (no layout tables, nominal glyphs). + // shortcut: decided for the whole segment by its first strong script, as `cursive` is; segments + // already split where the script changes. + let unshaped = feats.unshaped_complex && first_script.is_some_and(is_complex_script); + // Unshaped letters don't join, so they are not bent along a path as joined ones are. + let cursive = first_script.is_some_and(joins) && !unshaped; let full = st.size.max(0.0); // Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size. let (_, pos_shift) = st.position.scale_shift(full); @@ -1475,7 +1502,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .and_then(|l| l.parse::().ok()); let mut shaped = Shaped::default(); let Shaped { raw, proportional, vpal, chars, unkerned } = &mut shaped; - let hb_shaped = face.hb().map(|hb| { + let hb_shaped = face.hb().filter(|_| !unshaped).map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // The baseline and character-level pass use exactly the same resolved features. // Alternate positioning passes still resolve their own features, with `more` last. @@ -1591,7 +1618,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe } }); if hb_shaped.is_none() { - // Fallback: nominal glyphs and hmtx advances, no shaping. + // Fallback: nominal glyphs and hmtx advances, no shaping (also a complex script under a + // Standard composer). if let Some(f) = face.skrifa() { let cmap = f.charmap(); let gm = f.glyph_metrics(Size::unscaled(), face.location()); @@ -1600,8 +1628,14 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let c = st.digits.map(c); let chars: Vec = if upper { c.to_uppercase().collect() } else { vec![c] }; for u in chars { - let g = cmap.map(u).unwrap_or_default(); - let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5); + // Format characters (joiners, direction marks) have no glyph of their own in most + // fonts, and the shaper hides them: draw the space's glyph with no advance. + let (g, adv) = if crate::justify::is_format_control(u) { + (cmap.map(' ').unwrap_or_default(), 0.0) + } else { + let g = cmap.map(u).unwrap_or_default(); + (g, gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5)) + }; raw.push((g.to_u32(), cl, adv.round() as i32, 0, 0)); } } diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 7abae3f0eb..d17ce78603 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -534,7 +534,8 @@ pub fn half_form_font(half: bool) -> Option> { pub const CONJUNCT_FAMILY: &str = "Conjuncttest Sans"; /// A test-only cmap over bundled Source Sans 3: Devanagari Ka uses K, the halant uses a period and -/// the vowel sign I (drawn before its syllable) uses i. The `GSUB` table's `dev2` script has an +/// the vowel sign I (drawn before its syllable) uses i; Gujarati Ka, virama and vowel sign I use the +/// same glyphs. The `GSUB` table's `dev2` and `gjr2` scripts have an /// `aalt` single substitution of K by k and a `salt` one of K by i, which list k and the vowel /// sign's glyph as alternates of Ka, and a `half` ligature of K and the period into k (Ka's half /// form). Family [`CONJUNCT_FAMILY`]. `None` when it can't be built. @@ -549,7 +550,7 @@ pub fn conjunct_font() -> Option> { const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); let font = skrifa::FontRef::new(BASE).ok()?; let charmap = font.charmap(); - let mapping = [('क', 'K'), ('\u{094D}', '.'), ('\u{093F}', 'i')]; + let mapping = [('क', 'K'), ('\u{094D}', '.'), ('\u{093F}', 'i'), ('ક', 'K'), ('\u{0ACD}', '.'), ('\u{0ABF}', 'i')]; let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; let family = CONJUNCT_FAMILY; @@ -574,7 +575,8 @@ pub fn conjunct_font() -> Option> { SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![half])), SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt.into()])), ]); - let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"dev2"), Script::new(Some(LangSys::new(vec![0, 1, 2])), vec![]))]); + let langsys = || Script::new(Some(LangSys::new(vec![0, 1, 2])), vec![]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"dev2"), langsys()), ScriptRecord::new(Tag::new(b"gjr2"), langsys())]); builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; for r in font.table_directory.table_records() { let tag = Tag::new(&r.tag().to_be_bytes()); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 3837a1adf4..4bab8aa561 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -10,6 +10,7 @@ mod bidi; mod bitmaps; mod colr; mod combos; +mod composers; mod composite; mod devanagari; mod embed; diff --git a/crates/text/src/tests/composers.rs b/crates/text/src/tests/composers.rs new file mode 100644 index 0000000000..45e760b9d2 --- /dev/null +++ b/crates/text/src/tests/composers.rs @@ -0,0 +1,113 @@ +//! The four composers: the Middle Eastern & South Asian ones shape complex scripts with the font's +//! layout tables, the Standard ones set them glyph by glyph as the font maps their characters. +//! Uses a font built in code ([`conjunct_font`]): Ka K, halant a period and vowel sign I i, in +//! Devanagari and Gujarati, with a `half` ligature of K and the period into k. +use crate::test_fonts::{CONJUNCT_FAMILY, conjunct_font, source_sans_gid}; +use crate::*; +use kurbo::Point; +use vectorcraft_doc::{CharStyle, Composer, TextObject}; + +/// Ka, halant, Ka, vowel sign I in Devanagari, then in Gujarati. +const DEVANAGARI: &str = "क\u{94D}क\u{93F}"; +const GUJARATI: &str = "ક\u{ACD}ક\u{ABF}"; + +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(conjunct_font().expect("the test font builds")); + db +} + +fn object(text: &str, composer: Composer) -> TextObject { + let st = CharStyle { font_family: CONJUNCT_FAMILY.into(), font_style: "Regular".into(), size: 20.0, ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, st); + t.para.composer = composer; + t +} + +/// The glyph ids drawn for `text` with `composer`, and each glyph's source cluster length. +fn drawn(db: &FontDb, text: &str, composer: Composer) -> Vec<(u32, usize)> { + layout(db, &object(text, composer)).glyphs.iter().map(|g| (g.gid, g.len)).collect() +} + +fn g(c: char) -> u32 { + u32::from(source_sans_gid(c).expect("a Source Sans glyph")) +} + +/// Under the ME&SA composers the conjunct forms (the vowel sign I drawn before its syllable and Ka's +/// half form, in Devanagari and in Gujarati); under the Standard ones each character +/// has its own glyph, in text order, as the font maps it. +#[test] +fn standard_composers_set_indic_text_glyph_by_glyph() { + let db = db(); + let ids = |v: Vec<(u32, usize)>| v.into_iter().map(|(gid, _)| gid).collect::>(); + for composer in [Composer::SingleLine, Composer::EveryLine] { + assert_eq!(ids(drawn(&db, DEVANAGARI, composer)), [g('i'), g('k'), g('K')], "{composer:?} Devanagari"); + assert_eq!(ids(drawn(&db, GUJARATI, composer)), [g('i'), g('k'), g('K')], "{composer:?} Gujarati"); + } + for composer in [Composer::StandardSingleLine, Composer::StandardEveryLine] { + let plain = [g('K'), g('.'), g('K'), g('i')]; + let devanagari = drawn(&db, DEVANAGARI, composer); + assert_eq!(ids(devanagari.clone()), plain, "{composer:?} Devanagari"); + assert_eq!(ids(drawn(&db, GUJARATI, composer)), plain, "{composer:?} Gujarati"); + assert!(devanagari.iter().all(|&(_, len)| len == 3), "each glyph is one character's own cluster: {devanagari:?}"); + } +} + +/// The composer is per paragraph: of two paragraphs of the same text, each is shaped by its own. +#[test] +fn each_paragraph_follows_its_own_composer() { + let db = db(); + let mut t = object(&format!("{DEVANAGARI}\n{DEVANAGARI}"), Composer::EveryLine); + let standard = vectorcraft_doc::ParaStyle { composer: Composer::StandardEveryLine, ..t.para.clone() }; + t.set_paragraph_styles(vec![t.para.clone(), standard]); + let l = layout(&db, &t); + let second = DEVANAGARI.len() + 1; + let of = |from: usize, to: usize| l.glyphs.iter().filter(|g| (from..to).contains(&g.byte) && g.len > 0).map(|g| g.gid).collect::>(); + assert_eq!(of(0, second), [g('i'), g('k'), g('K')], "ME&SA paragraph"); + assert_eq!(of(second, second + DEVANAGARI.len()), [g('K'), g('.'), g('K'), g('i')], "Standard paragraph"); +} + +/// Layout's composer override ([`LayoutOptions::composer`]) decides the shaping too. +#[test] +fn the_layout_composer_override_decides_the_shaping() { + let db = db(); + let t = object(DEVANAGARI, Composer::EveryLine); + let with = |c| layout_with(&db, &t, &LayoutOptions { composer: Some(c), ..Default::default() }).glyphs.len(); + assert_eq!((with(Composer::SingleLine), with(Composer::StandardSingleLine)), (3, 4)); +} + +/// Standard composers break lines as the ME&SA ones do (`is_single_line`), and old files' composers +/// keep their meaning. +#[test] +fn composers_split_into_breaking_and_shaping() { + use Composer::*; + for (c, single, shapes) in + [(SingleLine, true, true), (EveryLine, false, true), (StandardSingleLine, true, false), (StandardEveryLine, false, false)] + { + assert_eq!((c.is_single_line(), c.shapes_complex_scripts()), (single, shapes), "{c:?}"); + assert_eq!(c.with_breaking(single), c, "{c:?}"); + assert_eq!( + (c.with_breaking(!single).is_single_line(), c.with_breaking(!single).shapes_complex_scripts()), + (!single, shapes), + "{c:?} toggled" + ); + } + assert_eq!(Composer::default(), EveryLine, "new type and old documents are ME&SA Every-line"); +} + +/// A joiner or non-joiner has no glyph in most fonts and the shaper hides it; a Standard composer +/// draws it as no width at all, not as a missing-glyph box. +#[test] +fn standard_composers_give_format_characters_no_width() { + let db = db(); + let text = "क\u{200C}क"; + for composer in [Composer::StandardSingleLine, Composer::StandardEveryLine] { + let l = layout(&db, &object(text, composer)); + let widths: Vec = l.glyphs.iter().map(|g| g.advance).collect(); + assert_eq!(widths.len(), 3, "{composer:?}: {widths:?}"); + assert_eq!(widths[1], 0.0, "{composer:?}: the non-joiner takes no room: {widths:?}"); + assert!(widths[0] > 0.0 && widths[2] > 0.0, "{composer:?}: {widths:?}"); + // The test font maps no space: the control must still draw nothing (not glyph 0's outline). + assert!(l.glyphs[1].outline.elements().is_empty(), "{composer:?}: the non-joiner has no ink"); + } +} diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 0765b88aaf..b31b597ce7 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -55,6 +55,51 @@ fn composite_runs_split_by_class() { assert!(db.composite_split(&TextObject::point(Point::ZERO, "Ab", style("Inter", 12.0))).is_none()); } +/// A custom character set takes the characters it lists ahead of their class, the later of two +/// sets wins a character both list, and the set's part sizes, raises and scales them. The fonts +/// used for the text, how it is covered and the face of one character follow the sets too. +#[test] +fn custom_character_sets_take_their_characters_first() { + use vectorcraft_doc::{CompositePart, CustomCharSet}; + let db = FontDb::with_font_dirs(vec![]); + let mut f = mixed("Custom"); + let mut part = CompositePart::new("Inter", "Regular"); + part.size = 150.0; + part.baseline = -10.0; + part.h_scale = 90.0; + let mut first = CustomCharSet::new("Letters", CompositePart::new("Source Sans 3", "Regular")); + first.characters = "Bか".into(); + let mut second = CustomCharSet::new("B only", part); + second.characters = "B".into(); + f.custom = vec![first, second]; + db.set_composite_font(&f); + let t = TextObject::point(Point::ZERO, "ABか1", style("Custom", 20.0)); + let (split, from) = db.composite_split(&t).expect("split"); + let pieces: Vec<(&str, &str)> = split.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str())).collect(); + assert_eq!(pieces, [("A", "JetBrains Mono"), ("B", "Inter"), ("か", "Source Sans 3"), ("1", "Inter")]); + assert_eq!(from, [0, 0, 0, 0]); + let b = &split.runs[1].style; + assert_eq!((b.size, b.baseline_shift, b.h_scale, b.v_scale), (30.0, -2.0, 90.0, 100.0)); + // か is in a set, so the Kana part's Scale at Center does not apply to it. + let ka = &split.runs[2].style; + assert_eq!((ka.v_scale, ka.baseline_shift), (100.0, 0.0)); + assert_eq!(db.composite_face("Custom", 'B').map(|(_, id)| id), Some(face_id(&db, "Inter"))); + let fonts = db.composite_fonts_for("Custom", "BA").unwrap(); + assert_eq!(fonts, [("Inter".to_string(), "Regular".to_string()), ("JetBrains Mono".to_string(), "Regular".to_string())]); + let l = layout(&db, &t); + let ids: Vec = l.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(ids[1], face_id(&db, "Inter")); + // A changed set is a changed definition: lending it lays the type out again. + let mut changed = f.clone(); + changed.custom[1].characters = "A".into(); + let g0 = db.generation(); + let lent = db.lend_composite_fonts(&[changed]); + assert_ne!(db.generation(), g0); + let (split, _) = db.composite_split(&t).expect("split"); + assert_eq!(split.runs[0].style.font_family, "Inter"); + drop(lent); +} + /// Laid out, each character is drawn in its class's font, sized by its part, and the glyphs still /// name the run of the text they belong to. #[test] diff --git a/crates/text/src/tests/hang.rs b/crates/text/src/tests/hang.rs index 5ccb79a5ca..ba978147bf 100644 --- a/crates/text/src/tests/hang.rs +++ b/crates/text/src/tests/hang.rs @@ -293,3 +293,38 @@ fn burasagari_counts_the_hung_start() { assert!((quote.origin.x + quote.advance).abs() < EPS, "the quote hangs: {:?}", quote.origin); assert!((comma.origin.x + comma.advance - 100.0).abs() < EPS, "、 inside, on the edge: {:?} {}", comma.origin, comma.advance); } + +/// A right-to-left paragraph draws a burasagari full stop inside its line, so the line breaks where +/// it fits with the stop: in a frame too narrow for the whole line by half the stop, the line ends +/// after the first word and nothing is drawn past the frame. +#[test] +fn right_to_left_burasagari_keeps_the_line_inside_the_frame() { + use vectorcraft_doc::{Burasagari, Kinsoku, ParaDirection}; + const EPS: f64 = 1e-6; + let text = "\u{05D0}\u{05D1} \u{05D2}\u{05D3}\u{3002}"; + let lay = |width: f64| { + let mut t = area(text, 20.0, Rect::new(0.0, 0.0, width, 400.0), Justify::Auto); + t.para.direction = Some(ParaDirection::RightToLeft); + t.para.burasagari = Burasagari::Standard; + t.para.kinsoku = Kinsoku::Hard; + let l = layout(db(), &t); + (t, l) + }; + let (_, wide) = lay(1000.0); + assert_eq!(wide.lines.len(), 1); + let full: f64 = wide.glyphs.iter().map(|g| g.advance).sum(); + let Some(stop) = wide.glyphs.iter().find(|g| g.byte == text.len() - '\u{3002}'.len_utf8()) else { + panic!("。 is set as a glyph of its own"); + }; + let (t, l) = lay(full - stop.advance / 2.0); + assert!(!l.overflow); + assert_eq!(l.lines.len(), 2, "the line ends after the first word"); + for (li, gs) in inked(&t, &l) { + // A right-to-left line's trailing space is set at its left, outside the aligned text. + for g in gs.into_iter().filter(|g| !is_space(ch(&t, g))) { + let c = ch(&t, g); + assert!(g.origin.x >= li.avail.0 - EPS, "{c:?} starts at {} before {}", g.origin.x, li.avail.0); + assert!(g.origin.x + g.advance <= li.avail.1 + EPS, "{c:?} ends at {} past {}", g.origin.x + g.advance, li.avail.1); + } + } +} diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index a75821e696..0d9566e5e5 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -1457,7 +1457,7 @@ fn touching_contours_keep_their_parts_and_holes() { #[test] fn an_open_subpath_closes_straight_whether_or_not_contours_meet() { let open = vectorcraft_geom::SubPath { - anchors: vec![ + anchors: vectorcraft_geom::anchors![ vectorcraft_geom::Anchor { p: Point::new(0.0, 0.0), h_in: Point::new(0.0, -50.0), @@ -1505,7 +1505,7 @@ fn flat_far_and_too_large_merges() { assert!(close(area(&cap), 200.0, 1e-3), "joined, not lost"); // Handles count too: a lobe whose handles reach 1.3e8 widens the boolean's snapping to 1.85e-6. let lobe = vectorcraft_geom::SubPath { - anchors: vec![ + anchors: vectorcraft_geom::anchors![ vectorcraft_geom::Anchor { p: Point::new(0.0, 1000.0), h_in: Point::new(0.0, 1000.0), @@ -1604,7 +1604,7 @@ fn meets_within_twice_the_boolean_tolerance() { // What the boolean is given decides the tolerance: an open subpath's end handles (closed // straight) and a one-anchor subpath (skipped) don't widen it. let ends = vectorcraft_geom::SubPath { - anchors: vec![ + anchors: vectorcraft_geom::anchors![ vectorcraft_geom::Anchor { p: Point::new(0.0, 0.0), h_in: Point::new(1e9, 0.0), @@ -1620,7 +1620,8 @@ fn meets_within_twice_the_boolean_tolerance() { ], closed: false, }; - let speck = vectorcraft_geom::SubPath { anchors: vec![vectorcraft_geom::Anchor::corner(Point::new(1e9, 1e9))], closed: true }; + let speck = + vectorcraft_geom::SubPath { anchors: vectorcraft_geom::anchors![vectorcraft_geom::Anchor::corner(Point::new(1e9, 1e9))], closed: true }; let quiet = PathData::new(vec![ends, speck]); assert_eq!(rings::touch(&rings::closed_straight(&quiet)), 1e-6); // A closing-side handle at either end alone is enough to close the subpath straight. @@ -1653,7 +1654,7 @@ fn the_merged_rings_are_turned_as_a_whole() { #[test] fn a_one_anchor_subpath_builds_nothing() { let loop_ = vectorcraft_geom::SubPath { - anchors: vec![vectorcraft_geom::Anchor { + anchors: vectorcraft_geom::anchors![vectorcraft_geom::Anchor { p: Point::new(0.0, 0.0), h_in: Point::new(-20.0, 30.0), h_out: Point::new(20.0, 30.0), diff --git a/crates/tools/src/addons/extend_path.rs b/crates/tools/src/addons/extend_path.rs index 2269396f90..816000ac9f 100644 --- a/crates/tools/src/addons/extend_path.rs +++ b/crates/tools/src/addons/extend_path.rs @@ -178,10 +178,10 @@ impl Tool for ExtendPathTool { let (k, n) = (end.sp.anchors.len(), ext.anchors.len()); // From the old end on; a stretched last curve (no anchor added) shows whole. let from = if n > k { k - 1 } else { n.saturating_sub(2) }; - let tail = if end.start { ext.anchors.get(..n - from) } else { ext.anchors.get(from..) }; + let tail = if end.start { ext.anchors.get_range(..n - from) } else { ext.anchors.get_range(from..) }; if let Some(tail) = tail { let mut bp = vectorcraft_geom::BezPath::new(); - SubPath::new(tail.to_vec(), false).to_bezpath_into(&mut bp); + SubPath::new(tail, false).to_bezpath_into(&mut bp); out.push(Overlay::Path { path: bp, color: GREY, width: 1.0, dashed: true }); } } diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index c25ab8f670..43269ed580 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -478,9 +478,9 @@ impl SmartPenTool { } else { straighten_segment(&mut sp, prev); } - let tail = sp.anchors.get(prev..)?; + let tail = sp.anchors.get_range(prev..)?; let mut bp = BezPath::new(); - SubPath::new(tail.to_vec(), false).to_bezpath_into(&mut bp); + SubPath::new(tail, false).to_bezpath_into(&mut bp); Some(bp) } } diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 235715a0b6..bdb1ca2e7b 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,7 +1,7 @@ use super::cache::RegionCache; use super::lookup::{ - connected_edges, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, lp_stroke_paint, node_outline, visible_edge_near, - visible_face_at, + connected_edges, edges, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, lp_stroke_paint, node_outline, + visible_edge_near, visible_face_at, }; use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ @@ -711,8 +711,14 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); /// with Shift held at the press, each of them leaves the selection if it is selected and joins it /// otherwise. A double-click on a face selects it and the faces of its group that are not parted /// from it by a stroked edge; on an edge, it selects the edge and the edges joined to it end to end -/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). With Shift held, each of them -/// leaves the selection if it is selected and joins it otherwise. +/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). A triple-click on a face +/// selects every face of its group with that face's fill; on an edge, every edge of its group with +/// that edge's stroke paint ([`LivePaintSelectionTool::same_paint_at`]). With Shift held, each part a +/// double-click reaches leaves the selection if it is selected and joins it otherwise, and after a +/// Shift-triple-click each part with the clicked part's paint is toggled from the selection there +/// was before the three clicks. Clicks of the three made without Shift select as they do alone, so +/// when Shift is first held at a later click, the parts are toggled from the selection there was +/// before that click. /// A click and a marquee change the selection on release, so Escape between the press /// and the release (which removes the marquee) leaves the selection as it was. The tool does not /// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer @@ -733,6 +739,33 @@ pub struct LivePaintSelectionTool { width: f64, /// The drag in progress, from the press to the release. drag: Option, + /// The part each of the last two clicks toggled with Shift held, the latest last (None for a + /// click that toggled nothing). A click or marquee without Shift clears both. + recent: [Option; 2], + /// The parts the last double-click, the two clicks before it and the Shift-clicks after it + /// toggled, each listed once when toggled an odd number of times. A Shift-triple-click toggles + /// them back as it toggles the parts with the clicked part's paint, so the selection ends as it + /// was before the clicks with those parts toggled. + toggled: Vec, +} + +/// `a` (which lists each id once) with each id of `b` toggled in turn: an id `b` lists an odd number +/// of times is removed when `a` holds it and added at the end otherwise, in `b`'s order. +fn toggle_ids(a: &mut Vec, b: &[NodeId]) { + use std::collections::HashSet; + let mut odd = HashSet::new(); + for id in b { + if !odd.insert(*id) { + odd.remove(id); + } + } + let held: HashSet = a.iter().copied().collect(); + a.retain(|x| !odd.contains(x)); + for id in b { + if !held.contains(id) && odd.remove(id) { + a.push(*id); + } + } } /// A press and drag of the Live Paint Selection tool. @@ -770,6 +803,8 @@ impl Default for LivePaintSelectionTool { color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH, drag: None, + recent: [None; 2], + toggled: vec![], } } } @@ -812,6 +847,25 @@ impl LivePaintSelectionTool { let Some(group) = cx.doc.node(gid) else { return vec![] }; if edge { connected_edges(group, part.id, &lp_stroke_paint(part)) } else { flood_faces(group, part.id) } } + + /// What a triple-click at `p` selects, Alt held or not: on the face a click there picks + /// ([`LivePaintSelectionTool::picks_edge`]), that face and every other face of its group that is + /// not hidden and has its fill; on the edge a click picks, that edge and every other edge of its + /// group that is not hidden and draws with the stroke paint it draws with ([`lp_stroke_paint`]). + /// The part under the pointer comes first. Empty where a click selects no face or edge. + fn same_paint_at(&self, cx: &ToolContext, p: Point, alt: bool) -> Vec { + let Some(edge) = self.picks_edge(alt) else { return vec![] }; + let Some((gid, Some(part))) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)) else { return vec![] }; + let Some(group) = cx.doc.node(gid) else { return vec![] }; + let others: Vec = if edge { + let like = lp_stroke_paint(part); + edges(group).into_iter().filter(|e| e.visible && e.id != part.id && lp_stroke_paint(e) == like).map(|e| e.id).collect() + } else { + let like = part.appearance.fill_paint(); + faces(group).into_iter().filter(|f| f.visible && f.id != part.id && f.appearance.fill_paint() == like).map(|f| f.id).collect() + }; + std::iter::once(part.id).chain(others).collect() + } } /// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and @@ -918,6 +972,17 @@ impl Tool for LivePaintSelectionTool { let d = LpDrag { cur: p, ..d }; let Some(r) = d.marquee(cx) else { // A click selects what the press picked. + // A click without Shift replaces the selection, so what earlier clicks toggled + // no longer counts. + if d.shift { + self.recent = [self.recent[1], d.hit]; + if let Some(id) = d.hit { + toggle_ids(&mut self.toggled, &[id]); + } + } else { + self.recent = [None; 2]; + self.toggled.clear(); + } return match (d.hit, d.shift) { (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], @@ -925,6 +990,8 @@ impl Tool for LivePaintSelectionTool { (None, false) => vec![Action::Exec("select.none".into(), json!({}))], }; }; + self.recent = [None; 2]; + self.toggled.clear(); let ids = lp_parts_in(cx, r, self.fills, self.strokes); match (ids.is_empty(), d.shift) { (true, true) => vec![], @@ -937,15 +1004,36 @@ impl Tool for LivePaintSelectionTool { // The two clicks before it already selected the part under the pointer (or, with // Shift, toggled it twice); the double-click selects the part with what it reaches. let ids = self.reach_at(cx, p, ev.mods.alt); + self.toggled.clear(); + if ev.mods.shift { + let clicks: Vec = self.recent.iter().flatten().copied().collect(); + toggle_ids(&mut self.toggled, &clicks); + toggle_ids(&mut self.toggled, &ids); + } match (ids.is_empty(), ev.mods.shift) { (true, _) => vec![], (false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))], (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } - // The tool takes no action on a triple-click; its third press and release select as a - // click does. - PointerKind::TripleClick => vec![], + PointerKind::TripleClick => { + // The clicks before it already selected the part under the pointer; the + // triple-click selects the part with every part of its group that has its paint. + // With Shift held, the double-click and the third click toggled parts too, and the + // triple-click toggles those back. + let ids = self.same_paint_at(cx, p, ev.mods.alt); + let toggled = std::mem::take(&mut self.toggled); + self.recent = [None; 2]; + if ids.is_empty() { + return vec![]; + } + if !ev.mods.shift { + return vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))]; + } + let mut flip = ids; + toggle_ids(&mut flip, &toggled); + if flip.is_empty() { vec![] } else { vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&flip) }))] } + } } } @@ -963,6 +1051,8 @@ impl Tool for LivePaintSelectionTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.drag = None; + self.recent = [None; 2]; + self.toggled.clear(); vec![] } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 66db1e550c..75e15da82d 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -2573,20 +2573,167 @@ fn live_paint_selection_double_click_selects_connected_faces_or_edges() { assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty()); } +/// A triple-click on a face selects every face of its group with that face's fill, joined to it or +/// not; on an edge (Alt), every edge of its group that draws with that edge's stroke paint. Shift +/// toggles them. Hidden parts, other groups and parts with another paint are left out. #[test] -fn live_paint_selection_triple_click_adds_nothing_to_its_clicks() { +fn live_paint_selection_triple_click_selects_every_part_of_the_same_paint() { let mut d = Document::new(500.0, 500.0); - let (_, [a, ..], _) = live_strip(&mut d); + let (_, [a, b, c], [ab, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d); + let (other, [_, other_top, ..]) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0)); + let other_face = faces(d.node(other).unwrap())[0].id; + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + // A and C are red and B is blue; the other group's face is red and its top edge black, too. + for (id, fill) in [(a, &red), (b, &blue), (c, &red), (other_face, &red)] { + d.node_mut(id).unwrap().appearance.set_fill(fill.clone()); + } + d.node_mut(other_top).unwrap().appearance.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + let s = Selection::default(); + let p = paint(); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + let tri = |d: &Document, t: &mut LivePaintSelectionTool, x: f64, y: f64, m: Mods| { + let cx = cx(d, &s, &p); + t.pointer(&cx, &ev(PointerKind::TripleClick, x, y).with_mods(m)) + }; + let mut t = LivePaintSelectionTool::default(); + // A face: every face of its group with its fill, even across the stroked edge BC; the clicked face first. + assert_eq!(tri(&d, &mut t, 125.0, 25.0, Mods::default()), set(&[c, a])); + assert_eq!(tri(&d, &mut t, 75.0, 25.0, Mods::default()), set(&[b])); + // An edge: every edge of its group that draws with its stroke paint, joined to it or not. + assert_eq!(tri(&d, &mut t, 25.0, 1.0, alt), set(&[top_a, top_b, bottom])); + assert_eq!(tri(&d, &mut t, 125.0, 1.0, alt), set(&[top_c, bc])); + assert_eq!(tri(&d, &mut t, 50.0, 25.0, alt), set(&[ab])); + // With Shift held, the parts leave the selection or join it. + assert_eq!(tri(&d, &mut t, 25.0, 25.0, shift), toggle(&[a, c])); + assert_eq!(tri(&d, &mut t, 125.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }), toggle(&[top_c, bc])); + // Away from the group, a triple-click adds nothing to what its clicks did. + assert!(tri(&d, &mut t, 400.0, 400.0, Mods::default()).is_empty()); + // A hidden face of the same fill is left out. + d.node_mut(c).unwrap().visible = false; + assert_eq!(tri(&d, &mut t, 25.0, 25.0, Mods::default()), set(&[a])); + // On the other group, a triple-click selects that group's parts alone. + assert_eq!(tri(&d, &mut t, 250.0, 50.0, Mods::default()), set(&[other_face])); + assert_eq!(tri(&d, &mut t, 250.0, 1.0, alt), set(&[other_top])); + // Select Fills off: the triple-click picks the edge nearest the pointer; both off: nothing. + t.set_option("selectFills", &json!(false)); + assert_eq!(tri(&d, &mut t, 25.0, 1.0, Mods::default()), set(&[top_a, top_b, bottom])); + t.set_option("selectStrokes", &json!(false)); + assert!(tri(&d, &mut t, 25.0, 25.0, Mods::default()).is_empty()); + assert!(tri(&d, &mut t, 25.0, 1.0, alt).is_empty()); +} + +/// The whole event series of a triple-click (three presses and releases, the double-click after +/// the second release and the triple-click after the third), applied to a selection: without +/// Shift the selection ends as the parts with the clicked part's paint, and with Shift as the +/// selection from before the clicks with those parts toggled, whatever the double-click reached. +#[test] +fn live_paint_selection_triple_click_series_ends_with_the_same_paint_parts() { + let mut d = Document::new(500.0, 500.0); + let (_, [a, b, c], [_, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + // A and C are red and B is blue; a double-click on A also reaches B across the unstroked edge AB. + for (id, fill) in [(a, &red), (b, &blue), (c, &red)] { + d.node_mut(id).unwrap().appearance.set_fill(fill.clone()); + } let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); + let apply = |sel: &mut Vec, acts: Vec| { + for act in acts { + let Action::Exec(cmd, args) = act else { panic!("{act:?}") }; + let ids: Vec = match (args.get("ids"), args.get("id")) { + (Some(v), _) => v.as_array().unwrap().iter().map(|i| i.as_u64().unwrap()).collect(), + (None, Some(i)) => vec![i.as_u64().unwrap()], + (None, None) => vec![], + }; + match cmd.as_str() { + "select.set" => *sel = ids, + "select.none" => sel.clear(), + "select.toggle" => { + for i in ids { + match sel.iter().position(|x| *x == i) { + Some(k) => { + sel.remove(k); + } + None => sel.push(i), + } + } + } + other => panic!("{other}"), + } + } + }; + // The first click at `first`, every later event at (`x`, `y`). + let series = |t: &mut LivePaintSelectionTool, sel: &mut Vec, first: (f64, f64), x: f64, y: f64, m: Mods| { + for kind in [PointerKind::Down, PointerKind::Up] { + apply(sel, t.pointer(&cx, &ev(kind, first.0, first.1).with_mods(m))); + } + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick, PointerKind::Down, PointerKind::Up, PointerKind::TripleClick] { + apply(sel, t.pointer(&cx, &ev(kind, x, y).with_mods(m))); + } + }; + let sorted = |ids: &[NodeId]| { + let mut v: Vec = ids.iter().map(|i| i.0).collect(); + v.sort_unstable(); + v + }; + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; let mut t = LivePaintSelectionTool::default(); - // The third click's press and release select the face under the pointer, as a click does. - assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 25.0, 25.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [a.0] }))]); - for m in [Mods::default(), Mods { alt: true, ..Default::default() }, Mods { shift: true, ..Default::default() }] { - assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 25.0).with_mods(m)).is_empty()); - assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 1.0).with_mods(m)).is_empty()); + for (start, first, x, y, m, want) in [ + (vec![], (25.0, 25.0), 25.0, 25.0, Mods::default(), sorted(&[a, c])), + (vec![top_c.0], (25.0, 25.0), 25.0, 25.0, Mods::default(), sorted(&[a, c])), + (vec![], (25.0, 25.0), 25.0, 25.0, shift, sorted(&[a, c])), + (vec![top_c.0, c.0], (25.0, 25.0), 25.0, 25.0, shift, sorted(&[a, top_c])), + (vec![], (25.0, 1.0), 25.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }, sorted(&[top_a, top_b, bottom])), + (vec![bc.0], (125.0, 1.0), 125.0, 1.0, alt, sorted(&[top_c, bc])), + // The first click on A and the rest on B, beside it: B alone has B's fill. + (vec![], (49.0, 25.0), 51.0, 25.0, shift, sorted(&[b])), + (vec![c.0], (49.0, 25.0), 51.0, 25.0, shift, sorted(&[b, c])), + ] { + let mut sel = start.clone(); + series(&mut t, &mut sel, first, x, y, m); + sel.sort_unstable(); + assert_eq!(sel, want, "from {start:?} at {first:?} then ({x}, {y}) with {m:?}"); } + // Shift first held at the third click: the two clicks and the double-click select A and B, and + // the parts with A's fill are toggled from that selection. + let mut sel = vec![top_c.0]; + for (kind, m) in [ + (PointerKind::Down, Mods::default()), + (PointerKind::Up, Mods::default()), + (PointerKind::Down, Mods::default()), + (PointerKind::Up, Mods::default()), + (PointerKind::DoubleClick, Mods::default()), + (PointerKind::Down, shift), + (PointerKind::Up, shift), + (PointerKind::TripleClick, shift), + ] { + apply(&mut sel, t.pointer(&cx, &ev(kind, 25.0, 25.0).with_mods(m))); + } + sel.sort_unstable(); + assert_eq!(sel, sorted(&[b, c])); + // A Shift-click on C, then a click on A without Shift, which selects A alone, and Shift held + // from the double-click on: the parts with A's fill are toggled from that selection. + let mut sel = vec![]; + for (kind, x, m) in [ + (PointerKind::Down, 125.0, shift), + (PointerKind::Up, 125.0, shift), + (PointerKind::Down, 25.0, Mods::default()), + (PointerKind::Up, 25.0, Mods::default()), + (PointerKind::DoubleClick, 25.0, shift), + (PointerKind::Down, 25.0, shift), + (PointerKind::Up, 25.0, shift), + (PointerKind::TripleClick, 25.0, shift), + ] { + apply(&mut sel, t.pointer(&cx, &ev(kind, x, 25.0).with_mods(m))); + } + assert_eq!(sel, vec![c.0]); } #[test] diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 69230dc050..d6620894ed 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -188,6 +188,8 @@ fn snapped(d: Vec2, unsnapped: Vec2, shift: bool, guides: &[Overlay]) -> bool { pub struct DirectSelectionTool { group: bool, state: State, + /// How far the move's last preview carried the anchors or the objects ([`Tool::readout`]). + moved: Option, /// The spine point last clicked (blend, anchor): its handles show and can be dragged. spine: Option<(NodeId, usize)>, /// Other points of that spine picked with it (Shift-click adds or takes one away); dragging @@ -251,6 +253,7 @@ impl DirectSelectionTool { Self { group, state: State::Idle, + moved: None, spine: None, spine_more: vec![], mesh: MeshEdit::default(), @@ -733,6 +736,10 @@ impl Tool for DirectSelectionTool { fn busy(&self) -> bool { !matches!(self.state, State::Idle) || self.guide.busy() } + fn readout(&self, _cx: &ToolContext) -> Option { + let moving = matches!(self.state, State::MoveAnchors { began: true, .. } | State::MoveObject { began: true, .. }); + self.moved.filter(|_| moving).map(crate::Readout::drag) + } fn options(&self) -> Value { self.mesh.options() } @@ -1486,6 +1493,7 @@ impl DirectSelectionTool { (d, self.guides) = (q - grab, guides); } self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + self.moved = Some(d); out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); out } @@ -1538,6 +1546,7 @@ impl DirectSelectionTool { (d, self.guides) = (q - held, guides); } self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + self.moved = Some(d); out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y}))); return out; } @@ -1553,6 +1562,7 @@ impl DirectSelectionTool { (d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) }; } self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); + self.moved = Some(d); out.push(Action::Preview( "object.transform".into(), json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}), diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index efc68132a1..0dc599444f 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -547,7 +547,7 @@ fn simplify(sp: &mut SubPath, tol: f64, region: Rect) { None => out[0].h_in = h_in, } } - sp.anchors = out; + sp.anchors = out.into(); } /// Apply a liquify stroke (its dabs, at the brush's intensity) to `path`. `salt` decorrelates diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 72cb02b6db..e6a15b5242 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -378,9 +378,11 @@ impl PerspectiveGrid { if (self.origin[1] - self.horizon).abs() < 1e-6 { return Err("ground level must differ from the horizon".into()); } - // A moved plane stays in front of the viewer. - let behind = |p: Plane| self.axes(p).is_some_and(|[.., n]| 1.0 + self.offset(p) * self.axis_col(n)[2] <= 1e-9); - if Plane::ALL.iter().any(|p| !self.offset(*p).is_finite() || self.offset(*p).abs() > MAX_DEPTH || behind(*p)) { + // A moved plane stays in front of the viewer, both where Automatic Plane Positioning holds + // it and in its own place, which it goes back to. + let behind = |p: Plane, d: f64| self.axes(p).is_some_and(|[.., n]| 1.0 + d * self.axis_col(n)[2] <= 1e-9); + let bad = |p: Plane, d: f64| !d.is_finite() || d.abs() > MAX_DEPTH || behind(p, d); + if Plane::ALL.iter().any(|p| bad(*p, self.offset(*p)) || bad(*p, self.placed_offset(*p))) { return Err("a plane is moved too far (behind the viewer)".into()); } self.validate_definition() diff --git a/crates/tools/src/distort/perspective/infer.rs b/crates/tools/src/distort/perspective/infer.rs index 26faa97747..e5f9fd1a8d 100644 --- a/crates/tools/src/distort/perspective/infer.rs +++ b/crates/tools/src/distort/perspective/infer.rs @@ -131,17 +131,32 @@ impl PerspectiveGrid { for plane in self.shown_planes() { let depth = self.offset(plane); let Some(h) = self.homography_at(plane, depth) else { continue }; - let Some(q) = h.inverse().and_then(|hi| hi.apply(p)) else { continue }; + let Some(hi) = h.inverse() else { continue }; let Some(((su, nu), (sv, nv))) = self.line_steps(plane) else { continue }; - // The drawn lines nearest the pointer along each axis, and their neighbors: the - // projection skews and shrinks the cells, so the intersection nearest on the page can be - // one over from the nearest in the plane. - let near = |v: f64, step: f64, last: usize| { - let i = (v / step).round().clamp(0.0, last as f64) as i64; - (i - 2..=i + 2).filter(move |k| (0..=last as i64).contains(k)).map(move |k| k as f64 * step) + // On each drawn line, the intersections either side of the point of the line nearest + // the pointer on the page. The projection keeps the order of points along a line, so + // the nearest intersection on that line is one of those two, however skewed the cells. + let mut candidates = Vec::with_capacity(2 * (nu + nv + 2)); + let mut along = |line: &dyn Fn(f64) -> Point, step: f64, last: usize, coord: fn(Point) -> f64| { + let (Some(a), Some(b)) = (h.apply(line(0.0)), h.apply(line(last as f64 * step))) else { return }; + let ab = b - a; + let len2 = ab.x * ab.x + ab.y * ab.y; + let t = if len2 > 0.0 { (((p - a).x * ab.x + (p - a).y * ab.y) / len2).clamp(0.0, 1.0) } else { 0.0 }; + let Some(at) = hi.apply(a + ab * t).map(coord).filter(|v| v.is_finite()) else { return }; + let k = (at / step).floor().clamp(0.0, last as f64); + for k in [k, (k + 1.0).min(last as f64)] { + candidates.push(line(k * step)); + } }; - for (u, v) in near(q.x, su, nu).flat_map(|u| near(q.y, sv, nv).map(move |v| (u, v))) { - let q = Point::new(u, v); + for i in 0..=nu { + let u = i as f64 * su; + along(&|v| Point::new(u, v), sv, nv, |q| q.y); + } + for i in 0..=nv { + let v = i as f64 * sv; + along(&|u| Point::new(u, v), su, nu, |q| q.x); + } + for q in candidates { let Some(d) = h.apply(q).map(|at| at.distance(p)) else { continue }; if d <= tol && best.is_none_or(|b| d < b.0) @@ -268,6 +283,33 @@ mod tests { assert!(g.infer_at(&d, past, 3.0, PlaneTargets::ALL).is_none()); } + #[test] + fn a_held_plane_still_checks_its_own_place() { + // Held at x = 0, the left wall's own place must stay in front of the viewer too: it goes + // back there once positioning ends. + let g = PerspectiveGrid { plane: Plane::Left, inferred: Some([0.0; 3]), ..grid() }; + assert!(g.merged(&json!({"leftOffset": -800})).is_err()); + assert!(PerspectiveGrid { inferred: None, ..g.clone() }.merged(&json!({"leftOffset": -800})).is_err()); + let moved = g.merged(&json!({"leftOffset": 40})).unwrap(); + assert_eq!((moved.placed_offset(Plane::Left), moved.offset(Plane::Left), moved.inferred), (40.0, 0.0, Some([0.0; 3]))); + } + + #[test] + fn the_nearest_intersection_on_the_page_is_found_on_a_skewed_plane() { + // The left vanishing point next to the origin skews the left wall's cells hard: the + // intersection nearest on the page lies many cells from the pointer's inverse. + let g = PerspectiveGrid { vp_left: 399.0, plane: Plane::Left, inferred: Some([0.0; 3]), ..grid() }; + let d = Document::new(800.0, 600.0); + let gridlines = PlaneTargets { anchors: false, gridlines: true }; + let p = Point::new(400.0, 219.761194); + let want = Point::new(10.0 * g.cell, 10.0 * g.cell); + assert!((g.to_page(Plane::Left, want).unwrap() - p).hypot() < 0.5); + let (_, at) = g.infer_at(&d, p, 6.0, gridlines).unwrap(); + for (got, want) in at.iter().zip([0.0, want.y, want.x]) { + assert!((got - want).abs() < 1e-6, "{at:?}"); + } + } + #[test] fn a_hidden_path_inside_art_in_perspective_has_no_anchor_to_take() { let g = PerspectiveGrid { plane: Plane::Right, ..grid() }; diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index d1a2672402..fee24c340b 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -1,8 +1,10 @@ //! Flare, Reshape, Shaper and Graph tools. //! //! Flare: drag sets the center and its size and turns the rays as the pointer moves up or down -//! (Shift holds their turn; click without dragging opens the Flare Tool Options), then a click -//! sets the end point of the rings; Space held during either drag moves the flare. The rest comes +//! (Shift holds their turn, ↑/↓ add or remove rays and Ctrl/Cmd keeps the center's size while the +//! drag sizes the halo; click without dragging opens the Flare Tool Options), then a click sets the +//! end point of the rings (↑/↓ add or remove rings and ~ places them anew); Space held during +//! either drag moves the flare. The rest comes //! from the tool's options (the Flare Tool Options, which last). Reshape: a click on a selected //! path sets a focal point (adding an anchor on a segment), Shift-click adds or removes more, and //! a drag from a focal point moves them all; the engine's `path.reshape` stretches the selected @@ -57,8 +59,19 @@ pub struct FlareTool { /// The pointer at the last press or drag, from which Space held moves the flare. last: Point, began: bool, - /// Ray count changed with ↑/↓ during the gesture (None = the options' count). + /// Ray count changed with ↑/↓ during the center drag (None = the options' count). rays: Option, + /// Ring count changed with ↑/↓ while the rings' end point is placed (None = the options' count). + rings: Option, + /// The rings' placement ~ chose (`shape.flare`'s `ringSeed`; None = the placement `seed` gives). + ring_seed: Option, + /// Whether backquote (the ~ key) was held at the last pointer event. + quote: bool, + /// Ctrl/Cmd held in the center drag: the center's diameter, the Halo growth (%) and the + /// pointer's distance from the center when the key went down. + held: Option<(f64, f64, f64)>, + /// The Halo growth (%) the drag set while Ctrl/Cmd held the center (None = the options'). + growth: Option, /// The rays' turn (`shape.flare`'s `rotation`, degrees) the center drag set. turn: f64, /// The options' values, in [`FLARE_OPTIONS`] order. @@ -77,6 +90,11 @@ impl Default for FlareTool { last: Point::ZERO, began: false, rays: None, + rings: None, + ring_seed: None, + quote: false, + held: None, + growth: None, turn: 0.0, values: FLARE_OPTIONS.map(|(_, default, _, _)| default), rays_on: true, @@ -123,9 +141,27 @@ fn ray_turn(turn: f64, dy: f64, shift: bool) -> f64 { (turn + dy * RAY_DEGREES_PER_POINT).rem_euclid(360.0) } +/// The ring placement ~ chooses after `last`, with the pointer at `at`: a new number below 2^53, so +/// a JSON client reads it exactly, and never `last` again. +fn next_ring_seed(last: Option, at: Point) -> u64 { + let mut x = last.unwrap_or(0x9e37_79b9_7f4a_7c15) ^ at.x.to_bits() ^ at.y.to_bits().rotate_left(29); + loop { + // SplitMix64. + x = x.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = x; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + let seed = (z ^ (z >> 31)) & ((1 << 53) - 1); + if Some(seed) != last { + return seed; + } + } +} + impl FlareTool { /// `shape.flare`'s params: the options, with the dragged center and diameter, the rays' turn, - /// the ray count ↑/↓ chose and the rings' end point once clicked. + /// the ray and ring counts ↑/↓ chose, the Halo growth Ctrl/Cmd set, the ring placement ~ chose + /// and the rings' end point once placed. fn params(&self, c: Point, diameter: f64, end: Option) -> Value { let mut v = flare_params(&self.options(), c); v["diameter"] = json!(diameter.max(1.0)); @@ -133,12 +169,62 @@ impl FlareTool { if let Some(r) = self.rays.filter(|_| self.rays_on) { v["rays"] = json!(r); } + if let Some(r) = self.rings.filter(|_| self.rings_on) { + v["rings"] = json!(r); + } + if let Some(g) = self.growth { + v["growth"] = json!(g); + } + if let Some(s) = self.ring_seed.filter(|_| self.rings_on) { + v["ringSeed"] = json!(s); + } if let Some(e) = end { v["x2"] = json!(e.x); v["y2"] = json!(e.y); } v } + + /// Flare Tool Options value `key` as the tool has it. + fn option(&self, key: &str) -> f64 { + FLARE_OPTIONS.iter().position(|(k, ..)| *k == key).map_or(0.0, |i| self.values[i]) + } + + /// Count `now` (else the options' `key`) one up or down, within the options' range for `key`. + fn step_count(&self, key: &str, now: Option, up: bool) -> u64 { + let most = FLARE_OPTIONS.iter().find(|(k, ..)| *k == key).map_or(0.0, |o| o.3) as u64; + let r = now.unwrap_or(self.option(key) as u64); + if up { (r + 1).min(most) } else { r.saturating_sub(1).min(most) } + } + + /// Ctrl/Cmd in the center drag, with the pointer `dist` from the center after `before` at the + /// last drag: once the flare has begun, the center keeps the diameter it showed when the key + /// went down, and the halo's radius keeps its ratio to the pointer's distance through the Halo + /// growth, within the options' 0 to 300 %. Letting the key go sizes the center with the drag + /// again, and the flare keeps the new Halo growth. The key is pressed while dragging: a press + /// made with it already held goes to the selection tool the session lends for Ctrl/Cmd. + fn hold_center(&mut self, before: f64, dist: f64, cmd: bool) { + match (cmd, self.held) { + (true, None) if self.began && before > 0.0 => { + let growth = self.growth.unwrap_or_else(|| self.option("growth")); + self.held = Some((2.0 * before, growth, before)); + } + (false, Some(_)) => self.held = None, + _ => {} + } + if let Some((_, growth, at)) = self.held { + let most = FLARE_OPTIONS.iter().find(|(k, ..)| *k == "growth").map_or(0.0, |o| o.3); + let g = (100.0 + growth) * dist / at - 100.0; + if g.is_finite() { + self.growth = Some(g.clamp(0.0, most)); + } + } + } + + /// The center's diameter with the pointer `dist` from it: twice that, unless Ctrl/Cmd holds it. + fn center_diameter(&self, dist: f64) -> f64 { + self.held.map_or(2.0 * dist, |(d, ..)| d) + } } impl Tool for FlareTool { @@ -150,34 +236,47 @@ impl Tool for FlareTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.hover = ev.pos; + // ~ pressed while the rings' end point is placed places the rings anew (Rings on). + let quote_pressed = ev.backquote && !self.quote; + self.quote = ev.backquote; + if quote_pressed && self.rings_on && self.placed.is_some() && matches!(ev.kind, PointerKind::Drag | PointerKind::Move | PointerKind::Up) { + self.ring_seed = Some(next_ring_seed(self.ring_seed, ev.pos)); + } match ev.kind { PointerKind::Down => { if self.placed.is_none() { self.drag = Some((ev.pos, ev.pos)); self.rays = None; + self.rings = None; + self.ring_seed = None; + self.held = None; + self.growth = None; self.turn = 0.0; } self.last = ev.pos; vec![] } PointerKind::Drag => { - if let Some((mut c, _)) = self.drag { + if let Some((mut c, p)) = self.drag { // Space held moves the center with the pointer, so the size and the turn stay. + let before = p.distance(c); let dy = ev.pos.y - self.last.y; crate::shape::space_moves(&mut c, &mut self.last, ev, self.began); self.drag = Some((c, ev.pos)); if !ev.mods.space { self.turn = ray_turn(self.turn, dy, ev.mods.shift); } - if !self.began && ev.pos.distance(c) >= cx.tol(3.0) { - self.began = true; - return vec![ - Action::Begin("Flare".into()), - Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None)), - ]; + let dist = ev.pos.distance(c); + let begins = !self.began && dist >= cx.tol(3.0); + self.began |= begins; + // With the key held from the start, the center keeps the first size the flare shows. + self.hold_center(if begins { dist } else { before }, dist, ev.mods.cmd); + let preview = Action::Preview("shape.flare".into(), self.params(c, self.center_diameter(dist), None)); + if begins { + return vec![Action::Begin("Flare".into()), preview]; } if self.began { - return vec![Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None))]; + return vec![preview]; } } else if let Some((mut c, d)) = self.placed { // Space held moves the whole flare, rings' end point included. @@ -196,7 +295,8 @@ impl Tool for FlareTool { let Some((c, p)) = self.drag.take() else { return vec![] }; if self.began { // Keep the interaction open: the next click places the end point. - self.placed = Some((c, 2.0 * p.distance(c))); + self.placed = Some((c, self.center_diameter(p.distance(c)))); + self.held = None; vec![] } else { vec![Action::Dialog("flare".into(), json!({ "x": c.x, "y": c.y }))] @@ -212,19 +312,28 @@ impl Tool for FlareTool { } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - // ↑/↓ while drawing add/remove rays. - if matches!(key, ToolKey::Up | ToolKey::Down) && self.began && self.rays_on { - let r = self.rays.unwrap_or_else(|| self.options()["rays"].as_u64().unwrap_or(0)); - self.rays = Some(if key == ToolKey::Up { (r + 1).min(50) } else { r.saturating_sub(1) }); + // ↑/↓ add/remove rays in the center drag and rings while the rings' end point is placed. + if matches!(key, ToolKey::Up | ToolKey::Down) && self.began { + let up = key == ToolKey::Up; let preview = match (self.drag, self.placed) { - (Some((c, p)), _) => self.params(c, 2.0 * p.distance(c), None), - (_, Some((c, d))) => self.params(c, d, Some(self.hover)), + (Some((c, p)), _) if self.rays_on => { + self.rays = Some(self.step_count("rays", self.rays, up)); + self.params(c, self.center_diameter(p.distance(c)), None) + } + (_, Some((c, d))) if self.rings_on => { + self.rings = Some(self.step_count("rings", self.rings, up)); + self.params(c, d, Some(self.hover)) + } _ => return vec![], }; return vec![Action::Preview("shape.flare".into(), preview)]; } if key == ToolKey::Escape && self.busy() { self.rays = None; + self.rings = None; + self.ring_seed = None; + self.held = None; + self.growth = None; self.drag = None; self.placed = None; let began = std::mem::take(&mut self.began); @@ -235,12 +344,14 @@ impl Tool for FlareTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { // Switching away after the centre drag keeps the flare with the rings its options give. self.drag = None; + self.held = None; if self.placed.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] } } fn overlays(&self, _cx: &ToolContext) -> Vec { match (self.drag, self.placed) { (Some((c, p)), _) => { - vec![Overlay::Path { path: ellipse_path(c, p.distance(c), p.distance(c), 0.0), color: BLUE, width: 1.0, dashed: false }] + let r = self.center_diameter(p.distance(c)) / 2.0; + vec![Overlay::Path { path: ellipse_path(c, r, r, 0.0), color: BLUE, width: 1.0, dashed: false }] } (_, Some((c, _))) => vec![Overlay::Line { a: c, b: self.hover, color: BLUE, dashed: true }], _ => vec![], @@ -839,6 +950,131 @@ mod tests { assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(120.0), Some(110.0), Some(340.0))); } + /// ↑/↓ change the rays in the center drag and the rings while the rings' end point is placed, + /// within the options' 0 to 50; with Rings off they leave the second drag alone. + #[test] + fn flare_arrows_change_the_rays_then_the_rings() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let counts = |v: &Value| (v["rays"].as_u64(), v["rings"].as_u64()); + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0)); + assert_eq!(counts(&flare_preview(&t.key(&cx, ToolKey::Up, Mods::default()))), (Some(16), Some(10))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 320.0, 300.0)); + assert_eq!(counts(&flare_preview(&t.key(&cx, ToolKey::Up, Mods::default()))), (Some(16), Some(11))); + t.key(&cx, ToolKey::Down, Mods::default()); + assert_eq!(counts(&flare_preview(&t.key(&cx, ToolKey::Down, Mods::default()))), (Some(16), Some(9))); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 330.0, 300.0)); + assert_eq!(a.last(), Some(&Action::Commit)); + assert_eq!(counts(&flare_preview(&a)), (Some(16), Some(9))); + // The next flare starts from the options' counts; the counts stop at 0 and 50. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + let mut v = Value::Null; + for _ in 0..12 { + v = flare_preview(&t.key(&cx, ToolKey::Down, Mods::default())); + } + assert_eq!(counts(&v), (Some(15), Some(0))); + for _ in 0..60 { + v = flare_preview(&t.key(&cx, ToolKey::Up, Mods::default())); + } + assert_eq!(counts(&v), (Some(15), Some(50))); + t.key(&cx, ToolKey::Escape, Mods::default()); + // Rings off: the arrows do nothing in the second drag. + t.set_option("ringsOn", &json!(false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + assert!(t.key(&cx, ToolKey::Up, Mods::default()).is_empty()); + } + + /// ~ (backquote) pressed while the rings' end point is placed gives the rings a new placement + /// each time, which the flare drawn keeps; holding it, the center drag, Rings off and the next + /// flare give none. + #[test] + fn flare_tilde_places_the_rings_anew() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let quote = |kind, x, y, held| PointerEvent { backquote: held, ..PointerEvent::new(kind, x, y) }; + let seed = |a: &[Action]| flare_preview(a).get("ringSeed").and_then(Value::as_u64); + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + // In the center drag ~ does nothing. + assert_eq!(seed(&t.pointer(&cx, "e(PointerKind::Drag, 140.0, 100.0, true))), None); + t.pointer(&cx, "e(PointerKind::Drag, 141.0, 100.0, false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 141.0, 100.0)); + assert_eq!(seed(&t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 200.0, 200.0))), None); + let first = seed(&t.pointer(&cx, "e(PointerKind::Move, 200.0, 200.0, true))); + assert!(first.is_some_and(|s| s < 1 << 53)); + // Held, it places them once; pressed again, anew. + assert_eq!(seed(&t.pointer(&cx, "e(PointerKind::Move, 210.0, 200.0, true))), first); + t.pointer(&cx, "e(PointerKind::Down, 210.0, 200.0, false)); + t.pointer(&cx, "e(PointerKind::Drag, 220.0, 200.0, false)); + let second = seed(&t.pointer(&cx, "e(PointerKind::Drag, 220.0, 200.0, true))); + assert!(second.is_some() && second != first); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 200.0)); + assert_eq!((a.last(), seed(&a)), (Some(&Action::Commit), second)); + // The next flare starts without one, and with Rings off ~ does not change the flare. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(seed(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0))), None); + t.set_option("ringsOn", &json!(false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + assert_eq!(seed(&t.pointer(&cx, "e(PointerKind::Move, 200.0, 200.0, true))), None); + assert_eq!(t.ring_seed, None); + } + + /// Ctrl/Cmd in the center drag keeps the center's diameter as the last drag showed it, and the + /// drag sizes the halo through the Halo growth (0 to 300 %); let go, the drag sizes the center + /// again and the flare keeps the new growth. + #[test] + fn flare_ctrl_keeps_the_center_and_the_drag_sizes_the_halo() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let cmd = Mods { cmd: true, ..Default::default() }; + let sized = |a: &[Action]| { + let v = flare_preview(a); + (v["diameter"].as_f64().unwrap_or(0.0), v["growth"].as_f64().unwrap_or(0.0)) + }; + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0))), (80.0, 20.0)); + // The key goes down with the pointer's next move: the center stays as the last drag showed it. + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 100.0).with_mods(cmd))), (80.0, 140.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(cmd))), (80.0, 0.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 900.0, 100.0).with_mods(cmd))), (80.0, 300.0)); + assert_eq!(sized(&t.key(&cx, ToolKey::Up, Mods::default())), (80.0, 300.0)); + // Let go: the center follows the drag again and the growth stays. + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0))), (100.0, 300.0)); + // Pressed again, the hold starts from the new center and growth. + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 125.0, 100.0).with_mods(cmd))), (100.0, 100.0)); + // A release with the key held places the held center; the second drag draws with it. + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 125.0, 100.0).with_mods(cmd)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 330.0)); + assert_eq!((a.last(), sized(&a)), (Some(&Action::Commit), (100.0, 100.0))); + // With the key held from the press, the center keeps the first size the flare shows; the + // next flare starts from the options' growth. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 110.0).with_mods(cmd))), (20.0, 20.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 120.0).with_mods(cmd))), (20.0, 140.0)); + } + #[test] fn reshape_drag_honors_measurement_labels() { use crate::testutil::*; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 9edf18e84d..7a5650a25b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -57,10 +57,12 @@ pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info}; mod context; mod cursor; mod input; +mod readout; pub use context::{GlyphSnap, MarqueeMode, PaintDefaults, PixelOp, PixelSnap, ScreenFrame, ToolContext}; pub use cursor::Cursor; pub use input::{Action, Mods, PenPose, PointerEvent, PointerKind, ToolKey}; +pub use readout::Readout; /// Visual feedback drawn by the UI in screen space (coordinates here are document space). #[derive(Clone, Debug, PartialEq)] @@ -265,6 +267,12 @@ pub trait Tool: Send { fn wants_rehover(&self) -> bool { false } + /// What the Info panel shows for the tool as it is now: the drag in progress (a move of the + /// selection, a gradient vector, a Scale, Rotate, Reflect or Shear tool drag), or the + /// Paintbrush's brush. None for a tool with nothing of its own to show. + fn readout(&self, _cx: &ToolContext) -> Option { + None + } /// Called when the user switches away (finish pending work). fn deactivate(&mut self, _cx: &ToolContext) -> Vec { vec![] diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 32c91318e6..badb8193a7 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -607,7 +607,7 @@ fn a_drag_from_the_first_anchor_always_closes() { fn alt_mid_close_keeps_the_turned_outgoing_handle() { use vectorcraft_geom::{Anchor, SubPath}; let anchors = vec![Anchor::smooth(Point::new(100.0, 300.0), Point::new(150.0, 300.0)), Anchor::corner(Point::new(200.0, 200.0))]; - let (mut d, id, s) = drawing(SubPath { anchors, closed: false }); + let (mut d, id, s) = drawing(SubPath::new(anchors, false)); let p = paint(); let mut t = PenTool { drawing: true, ..PenTool::default() }; assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), close_press(id, Point::new(100.0, 300.0))); @@ -634,7 +634,7 @@ fn alt_mid_close_keeps_the_turned_outgoing_handle() { fn space_moves_the_closing_anchor() { use vectorcraft_geom::{Anchor, SubPath}; let anchors = vec![Anchor::smooth(Point::new(100.0, 300.0), Point::new(150.0, 300.0)), Anchor::corner(Point::new(200.0, 200.0))]; - let (mut d, id, s) = drawing(SubPath { anchors, closed: false }); + let (mut d, id, s) = drawing(SubPath::new(anchors, false)); let p = paint(); let mut t = PenTool { drawing: true, ..PenTool::default() }; assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), close_press(id, Point::new(100.0, 300.0))); diff --git a/crates/tools/src/readout.rs b/crates/tools/src/readout.rs new file mode 100644 index 0000000000..36fa7d6e85 --- /dev/null +++ b/crates/tools/src/readout.rs @@ -0,0 +1,76 @@ +//! What the tool in use gives the Info panel to show ([`crate::Tool::readout`]). + +use serde_json::{Value, json}; +use vectorcraft_geom::{Point, Vec2}; + +/// The Info panel's values for the tool in use. Lengths and points are in document units, and +/// angles are in degrees as the matching command takes them: counter-clockwise for +/// `object.rotate`'s `angle` and `object.reflect`'s `axis`, and `object.shear`'s `angle`. +#[derive(Clone, Debug, PartialEq)] +pub enum Readout { + /// A drag that moves the selection or draws a gradient vector: how far it has gone in x and y. + Drag { dx: f64, dy: f64 }, + /// A Scale tool drag: the horizontal and vertical scale (1 leaves the size as it is). + Scale { sx: f64, sy: f64 }, + /// A Rotate tool drag: where the center of the selection's bounding box is, and the angle + /// turned. + Rotate { center: Point, angle: f64 }, + /// A Reflect tool drag: where the center of the selection's bounding box is, and the angle of + /// the axis. + Reflect { center: Point, angle: f64 }, + /// A Shear tool drag: where the center of the selection's bounding box is, the angle of the + /// shear axis (0 horizontal, 90 vertical) and the shear angle. + Shear { center: Point, axis: f64, angle: f64 }, + /// The Paintbrush: the brush it paints with, if any. + Brush { name: Option }, +} + +impl Readout { + /// The readout of a drag by `d`. + pub fn drag(d: Vec2) -> Self { + Readout::Drag { dx: d.x, dy: d.y } + } + + /// The length of a [`Readout::Drag`] and its angle, as the Measure tool gives them. + pub fn distance_angle(&self) -> Option<(f64, f64)> { + match *self { + Readout::Drag { dx, dy } => { + let (dist, angle, ..) = crate::xform::measure(Point::ORIGIN, Point::new(dx, dy)); + Some((dist, angle)) + } + _ => None, + } + } + + /// The readout as `info.readout` reports it. + pub fn to_json(&self) -> Value { + let at = |p: Point| json!([p.x, p.y]); + match self { + Readout::Drag { dx, dy } => { + let (distance, angle) = self.distance_angle().unwrap_or_default(); + json!({ "kind": "drag", "dx": dx, "dy": dy, "distance": distance, "angle": angle }) + } + Readout::Scale { sx, sy } => json!({ "kind": "scale", "width": sx * 100.0, "height": sy * 100.0 }), + Readout::Rotate { center, angle } => json!({ "kind": "rotate", "center": at(*center), "angle": angle }), + Readout::Reflect { center, angle } => json!({ "kind": "reflect", "center": at(*center), "angle": angle }), + Readout::Shear { center, axis, angle } => json!({ "kind": "shear", "center": at(*center), "axis": axis, "angle": angle }), + Readout::Brush { name } => json!({ "kind": "brush", "brush": name }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_drag_gives_its_length_and_its_angle_up_the_page() { + let r = Readout::drag(Vec2::new(30.0, -40.0)); + let (d, a) = r.distance_angle().unwrap(); + assert!((d - 50.0).abs() < 1e-9 && (a - 53.13010235415598).abs() < 1e-9, "{d} {a}"); + assert_eq!(r.to_json()["dx"], 30.0); + assert_eq!(Readout::Scale { sx: 1.5, sy: 0.5 }.to_json(), json!({"kind": "scale", "width": 150.0, "height": 50.0})); + assert_eq!(Readout::Scale { sx: 1.0, sy: 1.0 }.distance_angle(), None); + assert_eq!(Readout::Brush { name: None }.to_json(), json!({"kind": "brush", "brush": null})); + } +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index f63be1193e..8a755395dc 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -172,6 +172,8 @@ pub struct SelectionTool { /// The params of the move's last preview: in Shaper's Construction mode the move ends with /// them and `releaseOutside` ([`construction`]). last_move: Option, + /// How far the move's last preview carried the selection ([`Tool::readout`]). + moved: Option, /// Held state used for the most recent bounding-box preview. last_backquote: bool, /// Pointer position used for the most recent bounding-box preview. @@ -898,6 +900,10 @@ impl Tool for SelectionTool { matches!(self.state, State::Moving { began: true, .. }) } + fn readout(&self, _cx: &ToolContext) -> Option { + self.moved.filter(|_| self.moving()).map(crate::Readout::drag) + } + fn transforming(&self) -> bool { match &self.state { State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true, @@ -1753,6 +1759,7 @@ impl SelectionTool { params["patterns"] = json!(true); } self.last_move = Some(params.clone()); + self.moved = Some(d); out.push(Action::Preview("object.transform".into(), params)); out } diff --git a/crates/tools/src/symbolism.rs b/crates/tools/src/symbolism.rs index 580e61d4fd..ba00ec4125 100644 --- a/crates/tools/src/symbolism.rs +++ b/crates/tools/src/symbolism.rs @@ -299,6 +299,9 @@ impl Tool for BrushPaintTool { fn busy(&self) -> bool { self.inner.busy() } + fn readout(&self, cx: &ToolContext) -> Option { + Some(crate::Readout::Brush { name: self.brush_name(cx) }) + } fn deactivate(&mut self, cx: &ToolContext) -> Vec { let acts = self.inner.deactivate(cx); self.map(cx, acts) diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 5a3565f3a9..117df38463 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -545,6 +545,12 @@ impl Tool for GradientTool { self.gesture.as_ref().is_some_and(|g| g.began) || self.free.as_ref().is_some_and(freeform::Gesture::began) } + /// The vector a drag on the art has drawn so far. + fn readout(&self, _cx: &ToolContext) -> Option { + let (start, end) = self.gesture.as_ref().filter(|g| g.began)?.vector?; + Some(crate::Readout::drag(end - start)) + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { // A freeform gradient: its points instead of the bar. match (ev.kind, &mut self.free) { diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 1cbf1cac53..16a1b93a8a 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -20,7 +20,7 @@ use super::target_overlays; use crate::bbox::rotate_for_drag; use crate::guides::{PointSnap, Targets, snap_pick}; use crate::select::{matrix_json, selection_bounds, selection_box}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Readout, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TransformKind { @@ -98,6 +98,8 @@ pub struct TransformTool { origin: Option<(Point, Vec)>, drag: Option, measure: Option<(Point, String)>, + /// What the Info panel shows for the drag in progress. + readout: Option, /// Snap feedback while hovering ("anchor" / "center" label), and for the dragged pointer. hover: Vec, /// What the dragged pointer snaps to, gathered when the drag begins. @@ -126,7 +128,7 @@ pub fn reflect_matrix(o: Point, theta: f64) -> Affine { impl TransformTool { pub fn new(kind: TransformKind) -> Self { - Self { kind, origin: None, drag: None, measure: None, hover: vec![], snap: None } + Self { kind, origin: None, drag: None, measure: None, readout: None, hover: vec![], snap: None } } /// The effective reference point: the custom one if it belongs to the current selection, @@ -154,12 +156,44 @@ impl TransformTool { /// and Shift's 45° steps start from the box's axes. A Reflect axis drawn by the drag is the /// line the pointer draws on the page. pub fn matrix_in(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { + let (m, a, b) = self.solve(angle, o, start, p, shift, bounds); + (m, self.label(a, b)) + } + + /// The canvas label for the two values [`Self::solve`] gives. + fn label(&self, a: f64, b: f64) -> String { + match self.kind { + TransformKind::Rotate => format!("{:.1}°", -a), + TransformKind::Reflect => format!("{a:.1}°"), + TransformKind::Scale => format!("W: {:.1}%\nH: {:.1}%", a * 100.0, b * 100.0), + TransformKind::Shear => format!("{:.1}°", if a == 0.0 { b } else { -b }), + } + } + + /// What the Info panel shows for the transform `m` of a selection with `bounds`, with the two + /// values [`Self::solve`] gives. + fn readout_of(&self, m: Affine, a: f64, b: f64, bounds: Rect) -> Readout { + let center = m * bounds.center(); + match self.kind { + TransformKind::Rotate => Readout::Rotate { center, angle: a }, + TransformKind::Reflect => Readout::Reflect { center, angle: a }, + TransformKind::Scale => Readout::Scale { sx: a, sy: b }, + TransformKind::Shear => Readout::Shear { center, axis: a, angle: b }, + } + } + + /// [`TransformTool::matrix_in`]'s transform, with the two values of its readout: the angle + /// turned, in degrees counter-clockwise as `object.rotate` takes it (Rotate), the angle of the + /// axis in degrees counter-clockwise (Reflect), the scale along the box's width and height + /// (Scale), or the angle of the shear axis (0 horizontal, 90 vertical) and the shear angle as + /// `object.shear` takes it (Shear). + fn solve(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, f64, f64) { let frame = OrientedBox { angle, rect: Rect::ZERO }; let local = |v: Vec2| frame.to_doc().inverse() * v.to_point(); match self.kind { TransformKind::Rotate => { let (a, deg) = rotate_for_drag(o, start, p, shift); - (a, format!("{:.1}°", -deg)) + (a, deg, 0.0) } TransformKind::Reflect => { let mut v = p - o; @@ -169,7 +203,7 @@ impl TransformTool { v = (frame.to_doc() * vectorcraft_geom::constrain_angle(local(v).to_vec2(), 45.0).to_point()).to_vec2(); } let theta = v.y.atan2(v.x); - (reflect_matrix(o, theta), format!("{:.1}°", vectorcraft_geom::normalize_deg(-theta.to_degrees()))) + (reflect_matrix(o, theta), vectorcraft_geom::normalize_deg(-theta.to_degrees()), 0.0) } TransformKind::Scale => { let d0 = local(start - o).to_vec2(); @@ -184,7 +218,7 @@ impl TransformTool { } let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s }; let (sx, sy) = (clamp(sx), clamp(sy)); - (about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) + (about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), sx, sy) } TransformKind::Shear => { // The shear axis follows the dominant drag direction. Shift keeps the drag on that @@ -223,7 +257,7 @@ impl TransformTool { }; } } - (m, format!("{:.1}°", (-k.atan()).to_degrees())) + if horizontal { (m, 0.0, (-k.atan()).to_degrees()) } else { (m, 90.0, k.atan().to_degrees()) } } } } @@ -231,6 +265,7 @@ impl TransformTool { /// Ends the drag, its snapping and readouts with it. fn end_drag(&mut self) -> Option { self.measure = None; + self.readout = None; self.snap = None; self.hover.clear(); self.drag.take() @@ -250,6 +285,10 @@ impl Tool for TransformTool { self.drag.is_some_and(|d| d.began) } + fn readout(&self, _cx: &ToolContext) -> Option { + self.readout.clone() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; match ev.kind { @@ -294,10 +333,11 @@ impl Tool for TransformTool { } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); self.hover = guides; - let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds); + let (m, a, b) = self.solve(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds); (d.last, d.pointer, d.shift) = (m, p, ev.mods.shift); self.drag = Some(d); - self.measure = Some((p, text)); + self.measure = Some((p, self.label(a, b))); + self.readout = Some(self.readout_of(m, a, b, d.bounds)); out.push(preview(m, ev.mods.alt, ev.backquote)); out } diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 0d99a2acfc..b489e4dd82 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -57,6 +57,7 @@ mod logo; mod mosaic; mod opaque; mod quantize; +mod seam; mod shapes; mod smooth; mod subpixel; @@ -71,6 +72,7 @@ pub use gradient::TracedGradient; pub use mosaic::mosaic; pub use opaque::{ALPHA_MAX_DECODE_BYTES, OUTLINE_MAX_ANCHORS, OUTLINE_MAX_PIXELS, decode_alpha, opaque_outline}; pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize}; +pub use seam::{SEAM_WIDTH, seam_fills}; pub use shapes::TracedShape; use subpixel::Crack; diff --git a/crates/trace/src/quantize.rs b/crates/trace/src/quantize.rs index 9a7031256a..a54fdfacc7 100644 --- a/crates/trace/src/quantize.rs +++ b/crates/trace/src/quantize.rs @@ -13,7 +13,7 @@ pub(crate) use cut_edge::{continuous_tone, settle_cut_edges}; pub use denoise::denoise; pub(crate) use denoise::denoise_keeping; pub use palette::FULL_TONE_MAX; -use palette::{Bin, HistKey, TracedAs, Used, color_palette, flat_colors, kmeans_1d, labs, nearest_1d, nearest_lab, nearest_library}; +use palette::{Bin, HistKey, TracedAs, Used, color_palette, flat_count, flat_palette, kmeans_1d, labs, nearest_1d, nearest_lab, nearest_library}; #[cfg(test)] pub(crate) use rare::kept_rare; use rare::{rare_colours, refine_around}; @@ -126,12 +126,18 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized // Automatic on flat art: colours too rare for its count, but distinct and drawn as // regions of their own, are added (see `rare_colours`). let detail = (params.color_detail / 100.0).clamp(0.0, 1.0); - let flat_auto = params.palette == Palette::Automatic && flat_colors(from, detail).is_some(); + let flat = if params.palette == Palette::Automatic { flat_count(from, detail) } else { None }; + let flat_auto = flat.is_some(); let mut rare: Vec<[u8; 3]> = vec![]; - // The picture with `colors` colours asked for; fewer come back when it has fewer, or - // when noise made several of one (see `merge_mixed`). - let mut reduce = |colors: u32| { - let mut centers = color_palette(from, &TraceParams { colors, ..params.clone() }); + // The picture in the colors `params` asks for, or in one color per clean level when + // `levels` is given; fewer come back when it has fewer, or when noise made several of + // one (see `merge_mixed`). Also whether `merge_mixed` merged any. + let mut reduce = |levels: Option| { + rare.clear(); + let mut centers = match levels { + Some(n) => flat_palette(from, n), + None => color_palette(from, params), + }; // The anti-aliasing beside a rare color along the cut edge, with the indexes of // that color and of the color on the other side of it (see `rare_colours`). let mut traced_as: Vec = vec![]; @@ -168,9 +174,24 @@ pub(crate) fn quantize_keeping(img: &Raster, params: &TraceParams) -> (Quantized let palette = centers.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect(); let q = compact(labels, palette); // A swatch library's colours are used as they are. - if library { q } else { merge_mixed(q, img.width as usize, img.height as usize, &rare) } + if library { + return (q, false); + } + let before = q.palette.len(); + let q = merge_mixed(q, img.width as usize, img.height as usize, &rare); + let merged = q.palette.len() < before; + (q, merged) + }; + // Clean levels (see `flat_count`) each take a color unless `merge_mixed` finds two of + // them mixed (dithering, or a thin region of a level beside a close one): such a + // picture takes the colors median cut counts, with rare colors added. + let q = match flat { + Some((_, Some(levels))) => match reduce(Some(levels)) { + (q, false) => q, + (_, true) => reduce(None).0, + }, + _ => reduce(None).0, }; - let q = reduce(params.colors); let q = if settles_wide(params) { settled_wide(q, img, &own, false) } else { settled(q, img, &own) }; rare.retain(|c| q.palette.contains(c)); (q, rare) diff --git a/crates/trace/src/quantize/palette.rs b/crates/trace/src/quantize/palette.rs index 818aa62e18..43a1954cd7 100644 --- a/crates/trace/src/quantize/palette.rs +++ b/crates/trace/src/quantize/palette.rs @@ -158,7 +158,7 @@ pub(super) fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3] Palette::Limited => limited(), Palette::FullTone => full_tone(), Palette::Automatic => match flat_colors(used, detail) { - Some(n) => kmeans_rgb(used, n, 0.0), + Some(n) => flat_palette(used, n), None => full_tone(), }, Palette::DocumentLibrary if !params.swatches.is_empty() => library_colors(used, ¶ms.swatches, k), @@ -172,9 +172,20 @@ pub(super) fn color_palette(used: &[Used], params: &TraceParams) -> Vec<[f64; 3] /// noise aside): the bins holding 90% of the pixels are few, and each colour's pixels sit mostly /// in one bin. `detail` (0–1) lets smaller colour areas count as colours of their own. pub(super) fn flat_colors(used: &[Used], detail: f64) -> Option { + flat_count(used, detail).map(|(n, _)| n) +} + +/// Automatic's palette of `n` colors for flat art. +pub(super) fn flat_palette(used: &[Used], n: usize) -> Vec<[f64; 3]> { + kmeans_rgb(used, n, 0.0) +} + +/// [`flat_colors`], and how many clean levels the picture is made of when it is made of them (see +/// [`clean_levels`]). +pub(super) fn flat_count(used: &[Used], detail: f64) -> Option<(usize, Option)> { let total: f64 = used.iter().map(|u| u.2).sum(); if total <= 0.0 { - return Some(2); + return Some((2, None)); } let mut by_count: Vec<&Used> = used.iter().collect(); by_count.sort_by(|a, b| b.2.total_cmp(&a.2)); @@ -195,12 +206,38 @@ pub(super) fn flat_colors(used: &[Used], detail: f64) -> Option { let colours: Vec<&ColorBox> = boxes.iter().filter(|b| b.weight >= min_share).collect(); let weight: f64 = colours.iter().map(|b| b.weight).sum(); let peaks: f64 = colours.iter().map(|b| b.peak).sum(); - (weight >= 0.9 * total && peaks >= 0.5 * weight && colours.len() <= 32).then_some(colours.len().max(2)) + if weight < 0.9 * total || peaks < 0.5 * weight || colours.len() > 32 { + return None; + } + Some((colours.len().max(2), clean_levels(used, min_share))) } /// The most histogram bins flat art's main colours spread over (a few bins per colour). const FLAT_BINS: usize = 96; +/// The most colors [`clean_levels`] counts. +const CLEAN_LEVELS: usize = 16; + +/// How far apart (levels of some channel) the mean colors of two bins must lie for +/// [`clean_levels`] to count them as two colors. +const LEVELS_APART: f64 = 12.0; + +/// Flat art of 2 to [`CLEAN_LEVELS`] clean colors, one histogram bin each: the number of bins, +/// when each holds at least `min_share` pixels and every two of them lie at least [`LEVELS_APART`] +/// apart on some channel; otherwise `None`. Median cut keeps two colors less than 24 levels apart +/// in one box, so evenly spaced levels 12 to 24 apart would otherwise count as fewer colors. +/// Noise adds bins closer than that, or more than 16. An anti-aliased edge's blended pixels between +/// colors more than 20 levels apart are not among `used` (see `own_colours`), and the steps of a +/// blend between closer colors are less than [`LEVELS_APART`], so such pictures are counted by +/// median cut. +fn clean_levels(used: &[Used], min_share: f64) -> Option { + if !(2..=CLEAN_LEVELS).contains(&used.len()) || used.iter().any(|u| u.2 < min_share) { + return None; + } + let apart = |a: [f64; 3], b: [f64; 3]| (0..3).any(|ch| (a[ch] - b[ch]).abs() >= LEVELS_APART); + used.iter().enumerate().all(|(i, a)| used[i + 1..].iter().all(|b| apart(a.1, b.1))).then_some(used.len()) +} + /// Document Library: the `k` swatches (at most) that the most pixels are nearest to (see /// [`nearest_library`]). fn library_colors(used: &[Used], swatches: &[[u8; 3]], k: usize) -> Vec<[f64; 3]> { @@ -438,6 +475,53 @@ mod tests { assert_eq!(palette(Palette::Automatic, 16), automatic); } + /// `colors`, each with `n` pixels, as histogram bins. + fn bins(colors: impl IntoIterator) -> Vec { + colors.into_iter().enumerate().map(|(i, (c, n))| (i, c, n)).collect() + } + + /// `levels` grays evenly spaced from black to white, 1,200 pixels each. + fn gray_levels(levels: u32) -> Vec<([f64; 3], f64)> { + (0..levels).map(|i| ([f64::from(i * 255 / (levels - 1)); 3], 1200.0)).collect() + } + + /// The clean levels `used` is made of, at the default Color Detail (see [`flat_count`]). + fn clean(used: &[Used]) -> Option { + flat_count(used, (TraceParams::default().color_detail / 100.0).clamp(0.0, 1.0)).and_then(|(_, levels)| levels) + } + + #[test] + fn automatic_counts_every_clean_level_of_flat_art() { + for levels in [2, 3, 10, 12, 14, 16] { + assert_eq!(clean(&bins(gray_levels(levels))), Some(levels as usize), "{levels} grays"); + } + // Sixteen sepia levels, about 13 apart on each channel. + let sepia = (0..16u32).map(|i| { + let t = f64::from(i) / 15.0; + ([45.0 + 205.0 * t, 28.0 + 207.0 * t, 15.0 + 190.0 * t].map(f64::round), 1200.0) + }); + assert_eq!(clean(&bins(sepia)), Some(16), "16 sepia levels"); + // Levels 12 apart are the closest counted. + let close = |apart: f64| bins((0..16).map(|i| ([40.0 + apart * f64::from(i); 3], 1200.0))); + assert_eq!(clean(&close(12.0)), Some(16), "16 grays 12 apart"); + assert_eq!(clean(&close(11.0)), None, "16 grays 11 apart"); + } + + #[test] + fn noise_and_anti_aliasing_add_no_clean_levels() { + // Eight grays 34 apart, each with a second bin 6 levels off it holding as many pixels (noise). + let noisy = gray_levels(8).into_iter().flat_map(|(c, n)| [(c, n), (c.map(|v| (v + 6.0).min(255.0)), n)]); + assert_eq!(clean(&bins(noisy)), None, "eight noisy grays"); + // Eight grays with a few blended pixels halfway between each two (anti-aliasing). + let levels = gray_levels(8); + let blends = levels.windows(2).map(|w| ([(w[0].0[0] + w[1].0[0]) / 2.0; 3], 60.0)); + assert_eq!(clean(&bins(levels.iter().copied().chain(blends))), None, "eight anti-aliased grays"); + // Seventeen levels or more are counted by median cut. + assert_eq!(clean(&bins((0..17).map(|i| ([15.0 * f64::from(i); 3], 1200.0)))), None, "17 grays"); + // A continuous-tone picture has no count at all. + assert_eq!(clean(&ground_and_ramp()), None, "ground and ramp"); + } + #[test] fn a_dark_colored_figure_on_a_nearly_flat_dark_ground_keeps_its_color() { // Dark navy text (a few pixels) on the near-black ground: only cuts between two dark grays diff --git a/crates/trace/src/seam.rs b/crates/trace/src/seam.rs new file mode 100644 index 0000000000..726916e240 --- /dev/null +++ b/crates/trace/src/seam.rs @@ -0,0 +1,277 @@ +//! Abutting with Fills: seam fills, thin strokes under the filled paths along the edges they share. +//! +//! Abutting paths meet edge to edge, so a renderer that anti-aliases each path on its own leaves +//! the pixels along a shared edge partly covered by each path and lets what is behind them show +//! through as a light hairline. A stroke along the edge in the color of the path drawn first, +//! under every path, fills those pixels with that color: drawing the two paths over it then gives +//! each pixel the colors of the two paths in the shares each covers. The paths themselves are not +//! changed, and an edge a path shares with no other path (the image's border, a transparent area, +//! a color left out) gets no stroke. + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use vectorcraft_geom::{Anchor, PathData, Point, SubPath}; + +use super::params::{Method, Mode, TraceParams}; +use super::pipeline::TracedPath; + +/// How wide each seam fill's stroke is, in pixels: it covers every pixel the shared edge passes +/// through, at any angle (a pixel's corner is up to 0.71 px from a line through it). +pub const SEAM_WIDTH: f64 = 1.5; + +/// A segment, the same for the segment and the segment run backwards: two 64-bit hashes of its +/// points' bits, 16 bytes a segment instead of 64. Two different segments with the same key would +/// only give the lower one's color a stroke along a piece it does not share. +type Key = u128; + +fn key(a: &Anchor, b: &Anchor) -> Key { + let bits = |[p, q, r, s]: [Point; 4]| [p.x, p.y, q.x, q.y, r.x, r.y, s.x, s.y].map(f64::to_bits); + let points = bits([a.p, a.h_out, b.h_in, b.p]).min(bits([b.p, b.h_in, a.h_out, a.p])); + let hash = |seed: u8| { + let mut h = DefaultHasher::new(); + (seed, points).hash(&mut h); + h.finish() + }; + (u128::from(hash(0)) << 64) | u128::from(hash(1)) +} + +/// The seam fills of a trace made with `params`: for Abutting with Fills (not Flat Logo), thin +/// strokes to draw under every path of `paths` (bottom-most first). For each color, one path +/// stroked [`SEAM_WIDTH`] pixels wide along the edges where a filled path of that color meets a +/// filled path drawn after it, with `color` and `gradient` that path's; the paths with a gradient +/// get one each. Bottom-most first, as the paths they lie under. The stroke has at most as many +/// anchors as `paths` (each of its segments is a segment of a path), and finding them keeps about +/// 40 bytes for each segment of `paths` while it runs. Empty for any other trace. +pub fn seam_fills(paths: &[TracedPath], params: &TraceParams) -> Vec { + if params.method != Method::Abutting || !params.fills || params.mode == Mode::Logo { + return vec![]; + } + fills(paths) +} + +/// The segments of `sp`: each anchor and the one after it (back to the first, if closed). +fn segments(sp: &SubPath) -> impl Iterator { + let n = sp.anchors.len(); + let count = if sp.closed { n } else { n.saturating_sub(1) }; + (0..count).filter_map(move |k| Some((sp.anchors.get(k)?, sp.anchors.get((k + 1) % n)?))) +} + +/// [`seam_fills`] of Abutting `paths`, whatever the settings. +fn fills(paths: &[TracedPath]) -> Vec { + // For each segment, the first path it is in, and whether another path has it too. + let mut owner: HashMap = HashMap::new(); + for (i, p) in paths.iter().enumerate().filter(|(_, p)| p.stroke.is_none()) { + for sp in &p.path.subpaths { + for (a, b) in segments(sp).filter(|(a, b)| a.p != b.p) { + let e = owner.entry(key(a, b)).or_insert((i, false)); + if e.0 != i { + e.1 = true; + } + } + } + } + // One fill per color (per path, for a gradient), in the order of the first path it lies under. + let mut out: Vec = vec![]; + let mut by_color: HashMap<[u8; 3], usize> = HashMap::new(); + for (i, p) in paths.iter().enumerate().filter(|(_, p)| p.stroke.is_none()) { + let mut runs: Vec = vec![]; + for sp in &p.path.subpaths { + let lower = |a: &Anchor, b: &Anchor| a.p != b.p && owner.get(&key(a, b)) == Some(&(i, true)); + let marks: Vec = segments(sp).map(|(a, b)| lower(a, b)).collect(); + let n = sp.anchors.len(); + if marks.is_empty() { + continue; + } + if sp.closed && marks.iter().all(|&m| m) { + runs.push(sp.clone()); + continue; + } + // Start after an unmarked segment, so that a run across the subpath's first anchor + // stays one run. + let start = if sp.closed { marks.iter().position(|&m| !m).map_or(0, |k| k + 1) } else { 0 }; + let mut run: Vec = vec![]; + for j in 0..marks.len() { + let k = (start + j) % marks.len(); + let (Some(&a), Some(&b)) = (sp.anchors.get(k), sp.anchors.get((k + 1) % n)) else { continue }; + if marks.get(k).copied().unwrap_or(false) { + if run.is_empty() { + run.push(Anchor { h_in: a.p, ..a }); + } + run.push(b); + } else if !run.is_empty() { + runs.push(open_run(std::mem::take(&mut run))); + } + } + if !run.is_empty() { + runs.push(open_run(run)); + } + } + if runs.is_empty() { + continue; + } + let slot = if p.gradient.is_some() { None } else { by_color.get(&p.color).copied() }; + match slot.and_then(|s| out.get_mut(s)) { + Some(fill) => fill.path.subpaths.extend(runs), + None => { + if p.gradient.is_none() { + by_color.insert(p.color, out.len()); + } + out.push(TracedPath { + path: PathData::new(runs), + color: p.color, + pixels: 0, + stroke: Some(SEAM_WIDTH), + shape: None, + gradient: p.gradient.clone(), + square_caps: false, + }); + } + } + } + out +} + +/// An open run of anchors: the last one has no handle out. +fn open_run(mut run: Vec) -> SubPath { + if let Some(last) = run.last_mut() { + last.h_out = last.p; + } + SubPath::new(run, false) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Method, Mode, Raster, TraceParams, trace}; + + fn polygon(rings: &[&[(f64, f64)]], color: u8) -> TracedPath { + let subpaths = rings.iter().map(|r| SubPath::polyline(&r.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), true)).collect(); + TracedPath { path: PathData::new(subpaths), color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None, square_caps: false } + } + + fn points(sp: &SubPath) -> Vec<(f64, f64)> { + sp.anchors.iter().map(|a| (a.p.x, a.p.y)).collect() + } + + /// Each segment of `p`'s subpaths, by [`key`]. + fn keys(p: &TracedPath) -> Vec { + p.path.subpaths.iter().flat_map(|sp| segments(sp).map(|(a, b)| key(a, b)).collect::>()).collect() + } + + #[test] + fn a_shared_edge_gets_a_stroke_in_the_color_of_the_path_drawn_first() { + let left = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10); + let right = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0)]], 20); + let fills = fills(&[left.clone(), right.clone()]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!((fills[0].color, fills[0].stroke, fills[0].gradient.as_ref()), ([10; 3], Some(SEAM_WIDTH), None)); + assert_eq!(fills[0].path.subpaths.len(), 1); + let sp = &fills[0].path.subpaths[0]; + assert!(!sp.closed); + assert_eq!(points(sp), [(2.0, 0.0), (2.0, 2.0)]); + // Drawn the other way round, the stroke takes the other color. + let swapped = super::fills(&[right, left]); + assert_eq!(swapped.len(), 1); + assert_eq!((swapped[0].color, points(&swapped[0].path.subpaths[0])), ([20; 3], vec![(2.0, 2.0), (2.0, 0.0)])); + } + + #[test] + fn an_island_gets_a_closed_stroke_in_the_color_around_it() { + let hole: &[(f64, f64)] = &[(2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0)]; + let around = polygon(&[&[(0.0, 0.0), (6.0, 0.0), (6.0, 6.0), (0.0, 6.0)], hole], 10); + let island = polygon(&[&[(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0)]], 20); + let fills = fills(&[around, island]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!(fills[0].color, [10; 3]); + assert_eq!(fills[0].path.subpaths.len(), 1); + assert!(fills[0].path.subpaths[0].closed); + assert_eq!(points(&fills[0].path.subpaths[0]), hole); + } + + #[test] + fn a_shared_run_across_a_subpaths_first_anchor_stays_one_stroke() { + // The left square starts halfway along the edge it shares; the right one splits that edge + // at the same point. + let left = polygon(&[&[(2.0, 1.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0), (2.0, 0.0)]], 10); + let right = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0), (2.0, 1.0)]], 20); + let fills = fills(&[left, right]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!(fills[0].path.subpaths.len(), 1); + assert_eq!(points(&fills[0].path.subpaths[0]), [(2.0, 0.0), (2.0, 1.0), (2.0, 2.0)]); + } + + #[test] + fn edges_shared_with_no_other_filled_path_get_no_stroke() { + let square = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10); + assert_eq!(fills(std::slice::from_ref(&square)), []); + // A stroked line along the square's edge is not a filled neighbour. + let mut line = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0)]], 20); + line.stroke = Some(1.0); + assert_eq!(fills(&[square, line]), []); + } + + #[test] + fn paths_of_one_color_share_one_stroke_and_gradients_keep_their_own() { + let a = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10); + let b = polygon(&[&[(0.0, 4.0), (2.0, 4.0), (2.0, 6.0), (0.0, 6.0)]], 10); + let c = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 6.0), (2.0, 6.0), (2.0, 4.0), (2.0, 2.0)]], 20); + let fills = fills(&[a, b, c]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!(fills[0].path.subpaths.iter().map(points).collect::>(), [vec![(2.0, 0.0), (2.0, 2.0)], vec![(2.0, 4.0), (2.0, 6.0)]]); + } + + /// Two colors side by side, the edge between them anti-aliased at `x = 10.4`. + fn two_colors() -> Raster { + Raster::from_fn(24, 16, |x, _| { + let share = (10.4 - f64::from(x)).clamp(0.0, 1.0); + let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8; + [mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255] + }) + } + + fn color(method: Method) -> TraceParams { + TraceParams { mode: Mode::Color, colors: 2, method, ignore_white: false, ..TraceParams::default() } + } + + #[test] + fn an_abutting_trace_strokes_the_edges_its_filled_paths_share() { + let params = color(Method::Abutting); + let res = trace(&two_colors(), ¶ms); + assert_eq!(res.paths.len(), 2, "{res:?}"); + let seams = seam_fills(&res.paths, ¶ms); + assert_eq!(seams.len(), 1, "{seams:?}"); + let seam = &seams[0]; + assert_eq!((seam.color, seam.stroke), (res.paths[0].color, Some(SEAM_WIDTH))); + // Every piece of the stroke is a piece of both paths, and it runs from the image's top to + // its bottom along the edge, not along the image's border. + let (lower, upper) = (keys(&res.paths[0]), keys(&res.paths[1])); + assert!(keys(seam).iter().all(|k| lower.contains(k) && upper.contains(k))); + assert!(seam.path.anchor_count() <= res.anchor_count()); + for a in seam.path.subpaths.iter().flat_map(|sp| &sp.anchors) { + assert!((a.p.x - 10.4).abs() < 0.5, "{a:?}"); + } + let ys: Vec = seam.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p.y)).collect(); + assert_eq!((ys.iter().copied().fold(f64::INFINITY, f64::min), ys.iter().copied().fold(f64::NEG_INFINITY, f64::max)), (0.0, 16.0)); + } + + #[test] + fn only_abutting_fills_get_seam_strokes() { + let seams = |img: &Raster, params: &TraceParams| seam_fills(&trace(img, params).paths, params); + let img = two_colors(); + assert_eq!(seams(&img, &color(Method::Overlapping)), []); + assert_eq!(seams(&img, &TraceParams { fills: false, strokes: true, ..color(Method::Abutting) }), []); + // Flat Logo's shapes are stacked, whatever the method says. + let abutting = trace(&img, &color(Method::Abutting)); + assert_eq!(seam_fills(&abutting.paths, &TraceParams { mode: Mode::Logo, ..color(Method::Abutting) }), []); + // A color left out leaves no neighbour: a dark bar on white, with Ignore White. + let bar = Raster::from_fn(24, 16, |x, _| if (8..16).contains(&x) { [0, 0, 0, 255] } else { [255; 4] }); + let bw = TraceParams { mode: Mode::BlackAndWhite, method: Method::Abutting, ignore_white: true, ..TraceParams::default() }; + let res = trace(&bar, &bw); + assert_eq!(res.paths.len(), 1, "{res:?}"); + assert_eq!(seam_fills(&res.paths, &bw), []); + // Both colors traced, the bar's two sides are shared. + let both = seams(&bar, &TraceParams { ignore_white: false, ..bw }); + assert_eq!(both.iter().map(|s| s.path.subpaths.len()).sum::(), 2, "{both:?}"); + } +} diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index 2e24eb80bb..85e9d09778 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -2322,6 +2322,65 @@ fn thin_lines_on_flat_art_of_up_to_sixteen_clean_levels_are_traced() { } } +#[test] +fn automatic_keeps_every_level_of_clean_flat_art_of_up_to_sixteen_levels() { + let tints: [(&str, &[[f64; 3]]); 4] = [("gray", &GRAY_RAMP), ("sepia", &SEPIA), ("blue", &BLUE), ("duotone", &DUOTONE)]; + let params = TraceParams { mode: Mode::Color, palette: Palette::Automatic, ..TraceParams::default() }; + for (name, stops) in tints { + for levels in [2, 10, 12, 14, 16] { + let bands = tinted(&even_gray_bands(levels), stops, 0.0); + let res = trace(&bands, ¶ms); + assert_eq!(res.palette.len(), levels as usize, "{levels} {name} bands: {:?}", res.palette); + for band in 0..levels { + let i = ((band * 20 + 10) * 4) as usize; + let want = [bands.rgba[i], bands.rgba[i + 1], bands.rgba[i + 2]]; + assert!( + res.palette.iter().any(|c| (0..3).all(|k| c[k].abs_diff(want[k]) <= 1)), + "{levels} {name} bands: no {want:?} in {:?}", + res.palette + ); + } + assert_eq!(res.paths.len(), levels as usize, "{levels} {name} bands"); + } + } +} + +#[test] +fn automatic_counts_no_anti_aliased_edge_of_clean_levels_as_a_level() { + // Four gray bands 10 px wide, `apart` levels apart, each edge blended over `width` px: every + // blended column holds 2.5 in 100 of the pixels, more than a color needs to be counted. + let params = TraceParams { mode: Mode::Color, palette: Palette::Automatic, ..TraceParams::default() }; + for apart in [24.0, 40.0, 64.0] { + for width in 1..=4u32 { + let level = |b: u32| 20.0 + apart * f64::from(b); + let img = Raster::from_fn(40, 60, |x, _| { + let (b, j) = (x / 10, x % 10); + let v = if b > 0 && j < width { level(b - 1) + apart * f64::from(j + 1) / f64::from(width + 1) } else { level(b) }; + let v = v.round() as u8; + [v, v, v, 255] + }); + let res = trace(&img, ¶ms); + assert_eq!(res.palette.len(), 4, "levels {apart} apart, edges {width} px: {:?}", res.palette); + } + } +} + +#[test] +fn automatic_merges_a_dithered_close_level_as_noise() { + // A gray ground with 5 to 20 in 100 of its pixels, scattered, 12 levels lighter: two clean + // levels by their histogram, but mixed through each other, so they trace as one color. + let params = TraceParams { mode: Mode::Color, palette: Palette::Automatic, ..TraceParams::default() }; + let hash = |x: u32, y: u32| (x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)).wrapping_mul(2_654_435_761); + for percent in [5, 10, 20] { + let img = Raster::from_fn(120, 120, |x, y| { + let v = if hash(x, y) % 100 < percent { 112 } else { 100 }; + [v, v, v, 255] + }); + let res = trace(&img, ¶ms); + assert_eq!(res.palette.len(), 1, "{percent} in 100 dithered: {:?}", res.palette); + } +} + #[test] fn pictures_without_tones_are_not_continuous_tone() { let photo = tinted(&photo_scene(6.0, true), &SEPIA, 2.0); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 2ea7ac8079..92a942f4c5 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -54,6 +54,9 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] craft-ui = { workspace = true, features = ["native-windows"] } craft-update = { workspace = true } +# The local date and time the status bar's Date and Time readout shows (`chrono::Local`). Already in +# Cargo.lock with the time-zone crates `clock` uses. +chrono = { workspace = true, features = ["clock"] } [target.'cfg(target_arch = "wasm32")'.dependencies] # `now_ms` (src/lib.rs) reads the page's `performance.now()` with this crate in the web build. It is diff --git a/crates/ui-egui/src/addons/shapes.rs b/crates/ui-egui/src/addons/shapes.rs index fae45a190e..b856f15603 100644 --- a/crates/ui-egui/src/addons/shapes.rs +++ b/crates/ui-egui/src/addons/shapes.rs @@ -8,6 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::shapes::EllipseMode; use vectorcraft_tools::addons::box3d::{self, CustomShape}; +use crate::chrome::Fit; use crate::menus::Item; use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; @@ -17,8 +18,10 @@ pub fn menu() -> Vec { } /// Draw context-sensitive shape customization controls in the top Control bar when a shape tool is -/// active. Returns `true` when controls were drawn. -pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { +/// active. Returns `true` when a shape tool is active. Each button, and each label with its field, +/// shows only while the row has room for it and `reserve` after it, and the controls after one left +/// out are left out too. +pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) -> bool { let tool = app.session.tool_id().to_string(); if !matches!(tool.as_str(), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "spiral" | "box3d") { return false; @@ -29,6 +32,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { st.selection.objects.iter().find_map(|&id| st.doc.node(id).and_then(|n| CustomShape::read_for_update(n, active_shape == "spiral"))) }); + let fit = &mut Fit::new(reserve); if tool == "box3d" { for (s_id, lbl, icon) in [ ("box3d", "3D Box", "box"), @@ -38,24 +42,35 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { ("star", "Star", "star"), ("spiral", "Spiral", "tornado"), ] { - if widgets::icon_button(ui, icon, tl!(lbl), active_shape == s_id, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, icon, tl!(lbl), active_shape == s_id, 22.0).clicked() { app.run("tool.setOption", json!({ "key": "shape", "value": s_id })).ok(); } } - crate::chrome::group_separator(ui); + fit.separator(ui); } match active_shape.as_str() { - "ellipse" => draw_ellipse_controls(app, ui, &opts, selected_custom), - "rectangle" | "roundedRectangle" => draw_rectangle_controls(app, ui, &opts, selected_custom), - "polygon" | "star" => draw_polygon_controls(app, ui, &opts, &active_shape, selected_custom), - "spiral" => draw_spiral_controls(app, ui, &opts, selected_custom), - "box3d" => draw_box3d_controls(app, ui, &opts, selected_custom), + "ellipse" => draw_ellipse_controls(app, ui, fit, &opts, selected_custom), + "rectangle" | "roundedRectangle" => draw_rectangle_controls(app, ui, fit, &opts, selected_custom), + "polygon" | "star" => draw_polygon_controls(app, ui, fit, &opts, &active_shape, selected_custom), + "spiral" => draw_spiral_controls(app, ui, fit, &opts, selected_custom), + "box3d" => draw_box3d_controls(app, ui, fit, &opts, selected_custom), _ => return false, } true } +/// Whether `fit` has room in the row `ui` for the label `text` with a field `field` wide after it. +fn field_room(ui: &Ui, fit: &mut Fit, text: &egui::RichText, field: f32) -> bool { + fit.room(ui, &[widgets::text_width(ui, text.clone()), field]) +} + +/// The shape options' Reset button, at least 52 points wide and wider for a longer translated label, +/// while `fit` has room for it. Whether it was clicked. +fn reset_button(ui: &mut Ui, fit: &mut Fit) -> bool { + fit.room(ui, &[widgets::flat_button_fit_width(ui, tl!("Reset"), 52.0)]) && widgets::flat_button_fit(ui, tl!("Reset"), 52.0).clicked() +} + fn apply_shape(app: &mut VectorcraftApp, tool_kvs: &[(&str, Value)], live_patch: Value) { for (k, v) in tool_kvs { app.run("tool.setOption", json!({ "key": *k, "value": v.clone() })).ok(); @@ -65,22 +80,28 @@ fn apply_shape(app: &mut VectorcraftApp, tool_kvs: &[(&str, Value)], live_patch: } } -fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, start_deg: f64, end_deg: f64, mode: &str) { +fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, start_deg: f64, end_deg: f64, mode: &str) { let t = Tokens::get(ui.ctx()); - widgets::field_label(ui, egui::RichText::new(tl!("Start:")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-start"), start_deg, "°", 1, 52.0) { - let next_mode = if mode == "whole" { "pie" } else { mode }; - apply_shape(app, &[("arcStart", json!(v)), ("arcMode", json!(next_mode))], json!({ "startAngle": v, "arcMode": next_mode })); + let start = egui::RichText::new(tl!("Start:")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &start, 52.0) { + widgets::field_label(ui, start); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-start"), start_deg, "°", 1, 52.0) { + let next_mode = if mode == "whole" { "pie" } else { mode }; + apply_shape(app, &[("arcStart", json!(v)), ("arcMode", json!(next_mode))], json!({ "startAngle": v, "arcMode": next_mode })); + } } - widgets::field_label(ui, egui::RichText::new(tl!("End:")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-end"), end_deg, "°", 1, 52.0) { - let next_mode = if mode == "whole" { "pie" } else { mode }; - apply_shape(app, &[("arcEnd", json!(v)), ("arcMode", json!(next_mode))], json!({ "endAngle": v, "arcMode": next_mode })); + let end = egui::RichText::new(tl!("End:")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &end, 52.0) { + widgets::field_label(ui, end); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-end"), end_deg, "°", 1, 52.0) { + let next_mode = if mode == "whole" { "pie" } else { mode }; + apply_shape(app, &[("arcEnd", json!(v)), ("arcMode", json!(next_mode))], json!({ "endAngle": v, "arcMode": next_mode })); + } } for (m_id, tip_label, icon) in [("pie", "Slice", "circle-dot"), ("chord", "Closed", "circle-off"), ("arc", "Arc", "dc-arc"), ("whole", "Open", "dc-ellipse")] { - if widgets::icon_button(ui, icon, tl!(tip_label), mode == m_id, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, icon, tl!(tip_label), mode == m_id, 22.0).clicked() { let (s0, e0) = if m_id == "whole" { (0.0, 0.0) } else if (end_deg - start_deg).abs() < 1e-4 || ((end_deg - start_deg).abs() - 360.0).abs() < 1e-4 { @@ -97,19 +118,19 @@ fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, start_deg: f64, end_ } } -fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { +fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option) { let (start_deg, end_deg, mode) = match selected_custom { Some(CustomShape::Ellipse { pie: (a0, a1), arc_mode, .. }) => (a0, a1, mode_str(arc_mode)), _ => (opts["arcStart"].as_f64().unwrap_or(0.0), opts["arcEnd"].as_f64().unwrap_or(0.0), opts["arcMode"].as_str().unwrap_or("whole")), }; - draw_arc_controls(app, ui, start_deg, end_deg, mode); - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + draw_arc_controls(app, ui, fit, start_deg, end_deg, mode); + if reset_button(ui, fit) { apply_shape(app, &[("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true })); } - crate::chrome::group_separator(ui); + fit.separator(ui); } -fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { +fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option) { let t = Tokens::get(ui.ctx()); let unit = app.session.general_unit(); let sym = opts["cornerSymmetry"].as_bool().unwrap_or(true); @@ -129,19 +150,26 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, end_deg = a1; mode = mode_str(arc_mode); } - if widgets::icon_button(ui, if sym { "link" } else { "unlink" }, tl!("Link"), sym, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, if sym { "link" } else { "unlink" }, tl!("Link"), sym, 22.0).clicked() { let next_sym = !sym; apply_shape(app, &[("cornerSymmetry", json!(next_sym))], if next_sym { json!({ "radius": radii[0] }) } else { json!({ "radii": radii }) }); } if sym { - widgets::field_label(ui, egui::RichText::new(tl!("Radius:")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::num_field(ui, ("cb-shape", "rect-r"), Some(radii[0]), unit, 54.0) { - let r = v.max(0.0); - apply_shape(app, &[("cornerRadius", json!(r)), ("cornerRadii", json!([r, r, r, r]))], json!({ "radius": r })); + let text = egui::RichText::new(tl!("Radius:")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 54.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::num_field(ui, ("cb-shape", "rect-r"), Some(radii[0]), unit, 54.0) { + let r = v.max(0.0); + apply_shape(app, &[("cornerRadius", json!(r)), ("cornerRadii", json!([r, r, r, r]))], json!({ "radius": r })); + } } } else { for (i, lbl) in [(0, "TL"), (1, "TR"), (2, "BR"), (3, "BL")] { - widgets::field_label(ui, egui::RichText::new(lbl).size(11.0).color(t.text_dim)); + let text = egui::RichText::new(lbl).size(11.0).color(t.text_dim); + if !field_room(ui, fit, &text, 44.0) { + break; + } + widgets::field_label(ui, text); if let Some(v) = widgets::num_field(ui, ("cb-shape-r", i), Some(radii[i]), unit, 44.0) { let mut next = radii; next[i] = v.max(0.0); @@ -149,9 +177,9 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, } } } - ui.separator(); - draw_arc_controls(app, ui, start_deg, end_deg, mode); - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + fit.separator(ui); + draw_arc_controls(app, ui, fit, start_deg, end_deg, mode); + if reset_button(ui, fit) { apply_shape( app, &[ @@ -164,10 +192,17 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, json!({ "reset": true }), ); } - crate::chrome::group_separator(ui); + fit.separator(ui); } -fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, active_shape: &str, selected_custom: Option) { +fn draw_polygon_controls( + app: &mut VectorcraftApp, + ui: &mut Ui, + fit: &mut Fit, + opts: &Value, + active_shape: &str, + selected_custom: Option, +) { let t = Tokens::get(ui.ctx()); let mut is_star = if active_shape == "star" { true } else { opts["polyIsStar"].as_bool().unwrap_or(false) }; let mut count = if is_star { opts["points"].as_u64().unwrap_or(5) } else { opts["sides"].as_u64().unwrap_or(6) } as u32; @@ -188,47 +223,57 @@ fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, ac end_deg = a1; mode = mode_str(arc_mode); } - if widgets::icon_button(ui, "dc-polygon", tl!("Polygon"), !is_star, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, "dc-polygon", tl!("Polygon"), !is_star, 22.0).clicked() { apply_shape(app, &[("polyIsStar", json!(false))], json!({ "isStar": false })); } - if widgets::icon_button(ui, "star", tl!("Star"), is_star, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, "star", tl!("Star"), is_star, 22.0).clicked() { apply_shape(app, &[("polyIsStar", json!(true))], json!({ "isStar": true, "spokeRatio": spoke })); } - widgets::field_label(ui, egui::RichText::new(if is_star { tl!("Points:") } else { tl!("Sides:") }).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-corners"), f64::from(count), "", 0, 44.0) { - let c = (v.round() as i64).clamp(3, 1000) as u32; - apply_shape(app, &[("sides", json!(c)), ("points", json!(c))], json!({ "sides": c })); + let text = egui::RichText::new(if is_star { tl!("Points:") } else { tl!("Sides:") }).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 44.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-corners"), f64::from(count), "", 0, 44.0) { + let c = (v.round() as i64).clamp(3, 1000) as u32; + apply_shape(app, &[("sides", json!(c)), ("points", json!(c))], json!({ "sides": c })); + } } - if is_star { - widgets::field_label(ui, egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim)); + let text = egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim); + if is_star && field_room(ui, fit, &text, 46.0) { + widgets::field_label(ui, text); if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-spoke"), spoke, "", 2, 46.0) { let sr = v.clamp(0.01, 1.0); apply_shape(app, &[("starRatio", json!(sr))], json!({ "spokeRatio": sr })); } } - widgets::field_label(ui, egui::RichText::new(tl!("Round")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-round"), rounded, "", 2, 46.0) { - let rnd = v.clamp(-10.0, 10.0); - apply_shape(app, &[("rounded", json!(rnd))], json!({ "rounded": rnd })); + let text = egui::RichText::new(tl!("Round")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 46.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-round"), rounded, "", 2, 46.0) { + let rnd = v.clamp(-10.0, 10.0); + apply_shape(app, &[("rounded", json!(rnd))], json!({ "rounded": rnd })); + } } - widgets::field_label(ui, egui::RichText::new(tl!("Random")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-rand"), randomized, "", 2, 46.0) { - let rand = v.clamp(-10.0, 10.0); - apply_shape(app, &[("randomized", json!(rand))], json!({ "randomized": rand })); + let text = egui::RichText::new(tl!("Random")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 46.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-rand"), randomized, "", 2, 46.0) { + let rand = v.clamp(-10.0, 10.0); + apply_shape(app, &[("randomized", json!(rand))], json!({ "randomized": rand })); + } } - ui.separator(); - draw_arc_controls(app, ui, start_deg, end_deg, mode); - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + fit.separator(ui); + draw_arc_controls(app, ui, fit, start_deg, end_deg, mode); + if reset_button(ui, fit) { apply_shape( app, &[("rounded", json!(0.0)), ("randomized", json!(0.0)), ("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true }), ); } - crate::chrome::group_separator(ui); + fit.separator(ui); } -fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { +fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option) { let t = Tokens::get(ui.ctx()); let (mut turns, mut divergence, mut inner_r) = (opts["turns"].as_f64().unwrap_or(3.0), opts["divergence"].as_f64().unwrap_or(1.0), opts["innerRadius"].as_f64().unwrap_or(0.0)); @@ -237,28 +282,37 @@ fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sel divergence = div_v; inner_r = ir_v; } - widgets::field_label(ui, egui::RichText::new(tl!("Segments")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-turns"), turns, "", 2, 50.0) { - let tv = v.clamp(0.01, 1024.0); - apply_shape(app, &[("turns", json!(tv))], json!({ "turns": tv })); + let text = egui::RichText::new(tl!("Segments")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 50.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-turns"), turns, "", 2, 50.0) { + let tv = v.clamp(0.01, 1024.0); + apply_shape(app, &[("turns", json!(tv))], json!({ "turns": tv })); + } } - widgets::field_label(ui, egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-div"), divergence, "", 2, 50.0) { - let dv = v.clamp(0.0, 1000.0); - apply_shape(app, &[("divergence", json!(dv))], json!({ "divergence": dv })); + let text = egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 50.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-div"), divergence, "", 2, 50.0) { + let dv = v.clamp(0.0, 1000.0); + apply_shape(app, &[("divergence", json!(dv))], json!({ "divergence": dv })); + } } - widgets::field_label(ui, egui::RichText::new(tl!("Inner Radius:")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-inner"), inner_r, "", 2, 50.0) { - let iv = v.clamp(0.0, 0.999); - apply_shape(app, &[("innerRadius", json!(iv))], json!({ "innerRadius": iv })); + let text = egui::RichText::new(tl!("Inner Radius:")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 50.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-inner"), inner_r, "", 2, 50.0) { + let iv = v.clamp(0.0, 0.999); + apply_shape(app, &[("innerRadius", json!(iv))], json!({ "innerRadius": iv })); + } } - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + if reset_button(ui, fit) { apply_shape(app, &[("turns", json!(3.0)), ("divergence", json!(1.0)), ("innerRadius", json!(0.0))], json!({ "reset": true })); } - crate::chrome::group_separator(ui); + fit.separator(ui); } -fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { +fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option) { let t = Tokens::get(ui.ctx()); let (mut ax, mut ay, mut az) = (opts["angleX"].as_f64().unwrap_or(30.0), opts["angleY"].as_f64().unwrap_or(30.0), opts["angleZ"].as_f64().unwrap_or(0.0)); @@ -275,16 +329,20 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele for (lbl, a_key, p_key, a_val, p_val) in [("X:", "angleX", "parallelX", ax, px), ("Y:", "angleY", "parallelY", ay, py), ("Angle:", "angleZ", "parallelZ", az, pz)] { - widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim)); + let text = egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim); + if !field_room(ui, fit, &text, 52.0) { + break; + } + widgets::field_label(ui, text); if let Some(v) = widgets::plain_field(ui, ("cb-box3d", a_key), a_val, "°", 1, 52.0) { apply_shape(app, &[(a_key, json!(v))], json!({ a_key: v })); } - if widgets::icon_button(ui, "dc-line", tl!("Link"), p_val, 20.0).clicked() { + if fit.room(ui, &[20.0]) && widgets::icon_button(ui, "dc-line", tl!("Link"), p_val, 20.0).clicked() { let next = !p_val; apply_shape(app, &[(p_key, json!(next))], json!({ p_key: next })); } } - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + if reset_button(ui, fit) { apply_shape( app, &[ @@ -298,7 +356,7 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele json!({ "reset": true }), ); } - crate::chrome::group_separator(ui); + fit.separator(ui); } fn mode_str(m: EllipseMode) -> &'static str { diff --git a/crates/ui-egui/src/background.rs b/crates/ui-egui/src/background.rs index 806434fa1f..b0bdcc6287 100644 --- a/crates/ui-egui/src/background.rs +++ b/crates/ui-egui/src/background.rs @@ -56,6 +56,10 @@ pub struct Job { pub progress: Option>, /// Continue in Background: the progress dialog no longer shows this job. pub hidden: bool, + /// [`crate::state::UiState::status_sets`] before the job set its status line. + sets: u64, + /// The status line the job set ("