diff --git a/ASSETS.md b/ASSETS.md index 34d75235c6..5658882d48 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -267,6 +267,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/flip-vertical-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/folder-open.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/frame.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | +| `assets/icons/graduation-cap.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Learn panel, Explore tutorials button | | `assets/icons/grid-3x3.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/group.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | | `assets/icons/hand.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | | diff --git a/ROADMAP.md b/ROADMAP.md index b1f75b2735..87aaed1109 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. +- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. @@ -139,6 +140,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. - 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. +- 2026-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 5756600281..c5ef4d5579 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -651,6 +651,7 @@ mod portable { impl Answer { /// The answer to `request` when it couldn't be read: `why`, or no files. + #[cfg(any(test, target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] fn failed(request: Request, why: String) -> Self { match request { Request::Text => Self::Text(Err(why)), diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 50f1792eb8..72d13db08f 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -378,7 +378,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { // Load Actions… refuses a file over its size limit without reading all of it. read_head: Some(Box::new(read_file_head)), write: Some(Box::new(write_file)), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), // Native saves stream into the file as they are encoded. diff --git a/assets/icons/graduation-cap.svg b/assets/icons/graduation-cap.svg new file mode 100644 index 0000000000..fbce25edcc --- /dev/null +++ b/assets/icons/graduation-cap.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 3e262b0764..34f77ec929 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-cad" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader" +description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader and writer" [lints] workspace = true diff --git a/crates/cad/src/hpgl.rs b/crates/cad/src/hpgl.rs index 5016e301f6..9494fbee46 100644 --- a/crates/cad/src/hpgl.rs +++ b/crates/cad/src/hpgl.rs @@ -16,11 +16,18 @@ //! and the art of every page goes on one artboard; each comes back as a warning. Printer escape //! sequences and PJL lines around the instructions are skipped. //! +//! [`export`] writes the visible art over a region as an HP-GL or HP-GL/2 plot, and +//! [`export_regions`] one plot per region with one pen numbering: see [`write`]. +//! //! - `lex`: the bytes as instructions, numbers, labels and PE data. //! - `plot`: the instructions played on the pen, as shapes. +//! - `write`: the writer, paths to pen moves. mod lex; mod plot; +pub mod write; + +pub use write::{HpglOptions, HpglOutput, Origin, Version, export, export_regions}; use std::sync::Arc; diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs new file mode 100644 index 0000000000..3e20c69a98 --- /dev/null +++ b/crates/cad/src/hpgl/write.rs @@ -0,0 +1,1134 @@ +//! HP-GL export: the visible art over a region as pen moves, for pen plotters and cutters. +//! +//! Every visible path is drawn once along its own line: its fills and strokes are not painted, so +//! a filled shape is its closed outline (what a cutter cuts) and a stroke is its center line. Each +//! color is a pen: by default pen 1 is the first color the plot draws in, pen 2 the next new one, +//! and so on; with a pen table each path takes the pen whose color is nearest its own. The plots +//! of one export ([`export_regions`]) share the numbering. A path's color is its topmost visible +//! stroke's, else its topmost visible fill's (one at 0% opacity does not count); a gradient is the +//! average of its stops and a pattern its first visible solid fill (gray without one). Curves are +//! flattened to straight moves within a tolerance. Type is drawn as its glyph outlines, a gradient +//! mesh as its outline in the average of its shown points' colors, clipping masks cut what they clip, +//! and the art is cut at the region's edges. Hidden and non-printing layers, hidden objects, +//! objects at 0% opacity, template layers, guides and placed images are left out. +//! +//! Coordinates are plotter units (1016 to the inch, y up) from the region's bottom-left corner or +//! its center, rounded to whole units. Art past ±8,388,607 units (2²³ − 1, the smallest range +//! the HP-GL/2 reference asks a device to take for encoded polylines) from the origin is cut off. +//! An HP-GL plot with a coordinate outside −32,768 to 32,767 (16-bit numbers, the range older +//! devices read) gets a warning; HP-GL/2's encoded polylines take the wider range. +//! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with +//! `sort`, each next path the one that starts nearest where the pen stopped. With `inside_first`, +//! a pen draws a path only after every path of its own that lies inside it (a letter's counter +//! before the letter, a hole before the part). +//! +//! HP-GL writes `IN;`, then for each pen `PU;SP;` and per path `PUx,y;PDx,y,…;`, then `PU;SP0;`. +//! HP-GL/2 also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths +//! as one encoded polyline (`PE`, base 64). + +mod clip; +mod nest; + +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashSet}; +use std::sync::Arc; + +use kurbo::PathEl; +use vectorcraft_color::Paint; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, StrokeLayer, SymbolBoxes}; +use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect}; + +use crate::MAX_NEST; +use clip::Region; + +/// Plotter units per point. +const PLU_PER_PT: f64 = 1016.0 / 72.0; +/// The largest coordinate written, plotter units. +pub const MAX_COORD: i64 = (1 << 23) - 1; +/// The coordinates an HP-GL plot keeps to without a warning: 16-bit numbers, plotter units. +const CLASSIC_RANGE: std::ops::RangeInclusive = -32_768..=32_767; +/// The curve tolerance a path is flattened to by default, points. +pub const DEFAULT_TOLERANCE: f64 = 0.1; +/// The smallest and largest curve tolerance, points. +pub const TOLERANCE_RANGE: std::ops::RangeInclusive = 0.01..=10.0; +/// The most pens: a pen table's length, and the colors that get a pen of their own. +pub const MAX_PENS: usize = 255; +/// The most points the plots of one export hold. +const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 10_000_000 }; +/// The most work sorting paths and finding which lie inside others may take, over the whole plot: +/// one for each point or box compared. +const MAX_SORT_WORK: u64 = if cfg!(test) { 100_000 } else { 500_000_000 }; +/// The most edge tests clipping masks may take, over the whole plot. +const MAX_CLIP_TESTS: u64 = if cfg!(test) { 1_000_000 } else { 200_000_000 }; +/// The most points the clipping masks around an object may hold, together. +const MAX_MASK_POINTS: usize = if cfg!(test) { 10_000 } else { 10_000_000 }; +/// The most objects live art (blends, envelopes, repeats) may make for a plot, together, as one +/// evaluation of live art may make ([`vectorcraft_doc::live::MAX_GENERATED`]). +const MAX_GENERATED: u64 = if cfg!(test) { 1_000 } else { vectorcraft_doc::live::MAX_GENERATED as u64 }; + +const TEXT_OUTLINES: &str = "type is plotted as its glyph outlines"; +const INLINE_GRAPHICS: &str = "inline graphics in type are left out"; +const IMAGES: &str = "placed images are left out: a pen draws only lines"; +const MASKS: &str = "opacity masks are left out"; +const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out"; +const STROKE_LOOK: &str = "stroke widths, dashes, brushes and arrowheads are left out: each path is drawn once along its own line"; +const TOO_MANY_POINTS: &str = "the export is too large: the art past its first 10 million points was left out"; +const CLIP_WORK: &str = "clipping masks are too complex to apply in full: the art past that point is drawn whole"; +const MASK_POINTS: &str = "clipping masks past 10 million points together are left out: the art they clip is drawn unclipped by them"; +const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from the origin is cut off"; +const PAST_CLASSIC_RANGE: &str = "the plot has coordinates past 32,767 plotter units (about 82 cm) from the origin, which older HP-GL devices may not read: HP-GL/2 takes a wider range"; +const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color"; +const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order"; +const UNSORTED_INSIDE: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order, each after the paths inside it"; +const UNNESTED: &str = "the plot has too many paths to find in full which lie inside others: from the pen where that stopped, a path may be drawn before the paths inside it"; +const TOO_DEEP: &str = "symbols, blends, envelopes and repeats nested more than 8 deep were left out"; +const LIVE_LEFT_OUT: &str = "blends, envelopes and repeats make too many objects: the art past 4 million objects was left out"; +const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects: the instances past that were left out"; + +/// The language written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Version { + /// `IN`, `SP`, `PU` and `PD` only. + #[default] + Hpgl, + /// HP-GL/2: pen colors (`NP`, `PC`) and encoded polylines (`PE`). + Hpgl2, +} + +impl Version { + pub const ALL: [Self; 2] = [Self::Hpgl, Self::Hpgl2]; + + /// The `hpglVersion` param value. + pub fn id(self) -> &'static str { + match self { + Self::Hpgl => "hpgl", + Self::Hpgl2 => "hpgl2", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Hpgl => "HP-GL", + Self::Hpgl2 => "HP-GL/2", + } + } + + /// A version by id (`hpgl`, `hpgl2`; `hp-gl` and `hp-gl/2` also read), any case. + pub fn from_id(s: &str) -> Option { + let s = s.trim(); + Self::ALL.into_iter().find(|v| s.eq_ignore_ascii_case(v.id()) || s.eq_ignore_ascii_case(v.label())) + } +} + +/// Where the plot's origin (0, 0) lies on the region. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Origin { + #[default] + BottomLeft, + Center, +} + +impl Origin { + pub const ALL: [Self; 2] = [Self::BottomLeft, Self::Center]; + + /// The `origin` param value. + pub fn id(self) -> &'static str { + match self { + Self::BottomLeft => "bottomLeft", + Self::Center => "center", + } + } + + /// An origin by id, any case. + pub fn from_id(s: &str) -> Option { + Self::ALL.into_iter().find(|o| s.trim().eq_ignore_ascii_case(o.id())) + } +} + +/// The HP-GL export settings. +#[derive(Clone, Debug)] +pub struct HpglOptions { + pub version: Version, + /// How far a flattened curve may lie from the curve, points ([`TOLERANCE_RANGE`]; a value + /// outside it is an error). + pub tolerance: f64, + /// The pen table: pen 1's color first, up to [`MAX_PENS`] (more is an error). Empty: a pen for + /// each color the art draws in. + pub pens: Vec<[u8; 3]>, + pub origin: Origin, + /// Draw each pen's paths nearest first, instead of in paint order. + pub sort: bool, + /// Draw a path only after the pen's paths that lie inside it. + pub inside_first: bool, + /// The area plotted (document space): its bottom-left corner or center is the origin, and the + /// art is cut at its edges. + pub region: Rect, +} + +impl Default for HpglOptions { + fn default() -> Self { + Self { + version: Version::default(), + tolerance: DEFAULT_TOLERANCE, + pens: vec![], + origin: Origin::default(), + sort: false, + inside_first: false, + region: Rect::ZERO, + } + } +} + +/// A written plot. +#[derive(Clone, Debug, Default)] +pub struct HpglOutput { + pub bytes: Vec, + /// The pens' colors, pen 1's first. + pub pens: Vec<[u8; 3]>, + /// What was approximated or left out. + pub warnings: Vec, +} + +/// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are +/// applied first. +pub fn export(doc: &Document, opts: &HpglOptions) -> Result { + Ok(export_regions(doc, opts, &[opts.region])?.pop().unwrap_or_default()) +} + +/// Write the visible art of `doc` over each of `regions` as an HP-GL plot, one per region, as +/// [`export`] does with `opts.region`. The plots share one pen numbering, so a color is the same +/// pen in each, and one point budget. +pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> Result, String> { + let regions: Vec = regions.iter().map(|r| r.abs()).collect(); + if regions.iter().any(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { + return Err("the export region is empty".into()); + } + if !TOLERANCE_RANGE.contains(&opts.tolerance) { + return Err("the curve tolerance must be from 0.01 to 10 points".into()); + } + if opts.pens.len() > MAX_PENS { + return Err("the pen table holds more than 255 pens".into()); + } + let tolerance = opts.tolerance; + // Layers the plot leaves out go first: baking can wrap one in art that would be drawn (its + // crop marks). + let shown = plotted(&doc.layers, false).map(|layers| { + let mut d = doc.clone(); + d.layers = layers; + d + }); + let doc = shown.as_ref().unwrap_or(doc); + let baked = vectorcraft_effects::bake_document(doc); + let doc = baked.as_ref().unwrap_or(doc); + // Each plot's lines in plotter units, with the points a move doesn't change dropped, and its + // warnings. + let mut plots: Vec<(Lines, Vec)> = Vec::with_capacity(regions.len()); + let mut points = 0; + for r in regions { + // Document space to plotter units, y up. + let o = match opts.origin { + Origin::BottomLeft => Point::new(r.x0, r.y1), + Origin::Center => r.center(), + }; + let span = MAX_COORD as f64 / PLU_PER_PT; + let range = Rect::new(o.x - span, o.y - span, o.x + span, o.y + span); + let mut walk = Walk::new(doc, r.intersect(range), tolerance); + walk.points = points; + // Only a region past the range can have art the range cuts off. + if r.intersect(range) != r { + walk.range = Some((range, r)); + walk.asked = Region::new(vec![corners(r)], FillRule::NonZero); + } + for l in &doc.layers { + walk.node(l, false); + } + points = walk.points; + let Walk { runs, warnings, .. } = walk; + let to_plu = |p: Point| { + let x = ((p.x - o.x) * PLU_PER_PT).round().clamp(-(MAX_COORD as f64), MAX_COORD as f64) as i64; + let y = ((o.y - p.y) * PLU_PER_PT).round().clamp(-(MAX_COORD as f64), MAX_COORD as f64) as i64; + (x, y) + }; + let lines = runs + .into_iter() + .filter_map(|run| { + let mut pts: Vec<(i64, i64)> = Vec::with_capacity(run.points.len()); + for p in run.points { + let q = to_plu(p); + if pts.last() != Some(&q) { + pts.push(q); + } + } + // A line shorter than a plotter unit draws nothing and takes no pen. + (pts.len() >= 2).then_some((run.rgb, pts)) + }) + .collect(); + plots.push((lines, warnings)); + } + let mut pen_warnings = vec![]; + let colors: Vec<[u8; 3]> = plots.iter().flat_map(|(lines, _)| lines.iter().map(|(c, _)| *c)).collect(); + let (pens, of_line) = assign_pens(&colors, &opts.pens, &mut pen_warnings); + let mut of_line = of_line.into_iter(); + let mut out = Vec::with_capacity(plots.len()); + for (lines, mut warnings) in plots { + let mut by_pen: Vec>> = vec![vec![]; pens.len()]; + for ((_, pts), pen) in lines.into_iter().zip(of_line.by_ref()) { + if let Some(paths) = by_pen.get_mut(pen) { + paths.push(pts); + } + } + if !by_pen.iter().all(Vec::is_empty) { + pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); + } + if opts.sort || opts.inside_first { + let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); + for paths in &mut by_pen { + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); + } + } + if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { + warn(&mut warnings, PAST_CLASSIC_RANGE); + } + let bytes = match opts.version { + Version::Hpgl => plain(&by_pen), + Version::Hpgl2 => encoded(&by_pen, &pens), + }; + out.push(HpglOutput { bytes, pens: pens.clone(), warnings }); + } + Ok(out) +} + +/// A plot's lines in plotter units, each with its color. +type Lines = Vec<([u8; 3], Vec<(i64, i64)>)>; + +fn warn(warnings: &mut Vec, w: &str) { + if !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } +} + +/// The corners of `r`, as a clipping ring. +fn corners(r: Rect) -> Vec { + vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] +} + +/// Does `r` hold `p`, its edges included? +fn holds(r: Rect, p: Point) -> bool { + p.x >= r.x0 && p.x <= r.x1 && p.y >= r.y0 && p.y <= r.y1 +} + +/// The pens and each line's pen (0-based), from the lines' `colors`: with a table, the table and +/// the nearest of its colors; else each new color a pen of its own, up to [`MAX_PENS`], and the +/// nearest after that. +fn assign_pens(colors: &[[u8; 3]], table: &[[u8; 3]], warnings: &mut Vec) -> (Vec<[u8; 3]>, Vec) { + let mut pens: Vec<[u8; 3]> = table.iter().take(MAX_PENS).copied().collect(); + let fixed = !pens.is_empty(); + let mut of_line = Vec::with_capacity(colors.len()); + for &rgb in colors { + let pen = match pens.iter().position(|c| *c == rgb) { + Some(i) => i, + None if !fixed && pens.len() < MAX_PENS => { + pens.push(rgb); + pens.len() - 1 + } + None => { + if !fixed { + warn(warnings, MANY_COLORS); + } + nearest(&pens, rgb) + } + }; + of_line.push(pen); + } + (pens, of_line) +} + +/// The index of the color in `pens` nearest `c` (the first of equally near ones). +fn nearest(pens: &[[u8; 3]], c: [u8; 3]) -> usize { + let d = |p: &[u8; 3]| (0..3).map(|i| (i32::from(p[i]) - i32::from(c[i])).pow(2)).sum::(); + pens.iter().enumerate().min_by_key(|(_, p)| d(p)).map_or(0, |(i, _)| i) +} + +fn dist2(a: (i64, i64), b: (i64, i64)) -> i128 { + let (dx, dy) = (i128::from(a.0) - i128::from(b.0), i128::from(a.1) - i128::from(b.1)); + dx * dx + dy * dy +} + +/// The squared distance from `p` to box `b` (`[x0, y0, x1, y1]`), 0 inside it. +fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { + let d = |v: i64, lo: i64, hi: i64| i128::from(lo.saturating_sub(v).max(v.saturating_sub(hi)).max(0)); + let (dx, dy) = (d(p.0, b[0], b[2]), d(p.1, b[1], b[3])); + dx * dx + dy * dy +} + +/// `paths` (one pen's) reordered from `at`. With `nearest`, each next path is the one nearest +/// where the pen stopped, by its start or, for an open path (then drawn backwards), its end, or +/// for a closed path any of its points (where it then starts); without, the first in paint order. +/// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. +/// Once `work` is spent, the paths left keep paint order, each after the paths inside it with +/// `inside_first`, with a warning. Returns where the pen stops. +fn reorder( + paths: &mut Vec>, + mut at: (i64, i64), + work: &mut u64, + nearest: bool, + inside_first: bool, + warnings: &mut Vec, +) -> (i64, i64) { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); + // The paths each path lies inside, and how many paths inside each are still to draw. + let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); + if inside_first && outer.is_none() { + warn(warnings, UNNESTED); + } + let mut waiting = vec![0usize; paths.len()]; + for o in outer.iter().flatten().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w += 1; + } + } + if !nearest { + if let Some(outer) = &outer { + let all = std::mem::take(paths).into_iter().enumerate().collect(); + inner_first(all, outer, &mut waiting, paths); + } + return at; + } + // Each path with its index and box. + let mut left: Vec<_> = std::mem::take(paths).into_iter().zip(boxes).enumerate().map(|(i, (p, b))| (i, p, b)).collect(); + while !left.is_empty() { + // (index, reversed, the closed path's point to start at, distance) + let mut best: (usize, bool, Option, i128) = (usize::MAX, false, None, i128::MAX); + for (i, (j, p, b)) in left.iter().enumerate() { + let (Some(&s), Some(&e)) = (p.first(), p.last()) else { continue }; + *work = work.saturating_sub(1); + if waiting.get(*j).is_some_and(|w| *w > 0) { + continue; + } + if s == e && p.len() > 2 { + if box_dist2(at, *b) >= best.3 { + continue; + } + *work = work.saturating_sub(p.len() as u64); + if let Some((k, d)) = p.iter().enumerate().map(|(k, q)| (k, dist2(at, *q))).min_by_key(|(_, d)| *d) + && d < best.3 + { + best = (i, false, Some(k), d); + } + continue; + } + let (ds, de) = (dist2(at, s), dist2(at, e)); + if ds < best.3 { + best = (i, false, None, ds); + } + if de < best.3 { + best = (i, true, None, de); + } + } + // Spent, or no path free to draw (which only a fault could cause): the rest in paint + // order, each after the paths inside it. + if *work == 0 || best.0 >= left.len() { + match &outer { + Some(outer) => { + warn(warnings, UNSORTED_INSIDE); + inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); + } + None => { + warn(warnings, UNSORTED); + paths.extend(left.into_iter().map(|(_, p, _)| p)); + } + } + return at; + } + let (j, mut p, _) = left.remove(best.0); + for o in outer.as_ref().and_then(|outer| outer.get(j)).into_iter().flatten() { + if let Some(w) = waiting.get_mut(*o) { + *w = w.saturating_sub(1); + } + } + if best.1 { + p.reverse(); + } else if let Some(k) = best.2 { + // A closed path starts at its point nearest the pen. + p.pop(); + let k = k.min(p.len().saturating_sub(1)); + p.rotate_left(k); + if let Some(&s) = p.first() { + p.push(s); + } + } + if let Some(&e) = p.last() { + at = e; + } + paths.push(p); + } + at +} + +/// Appends `left` (each path with its index among the pen's paths) to `paths` in paint order, +/// each after the paths lying inside it: `outer` lists the paths each lies inside, and `waiting` +/// counts, for each, the paths inside it still to draw. [`nest::outer`] puts a path only inside +/// larger ones, so every path comes free; a path left waiting by a fault is drawn last. +fn inner_first(left: Vec<(usize, Vec<(i64, i64)>)>, outer: &[Vec], waiting: &mut [usize], paths: &mut Vec>) { + let mut held: Vec>> = (0..outer.len()).map(|_| None).collect(); + let mut free = BinaryHeap::new(); + for (j, p) in left { + if let Some(h) = held.get_mut(j) { + if waiting.get(j).is_none_or(|w| *w == 0) { + free.push(Reverse(j)); + } + *h = Some(p); + } else { + paths.push(p); + } + } + while let Some(Reverse(j)) = free.pop() { + let Some(p) = held.get_mut(j).and_then(Option::take) else { continue }; + paths.push(p); + for &o in outer.get(j).into_iter().flatten() { + if let Some(w) = waiting.get_mut(o) { + *w = w.saturating_sub(1); + if *w == 0 { + free.push(Reverse(o)); + } + } + } + } + paths.extend(held.into_iter().flatten()); +} + +/// HP-GL: `IN;`, each pen's paths as `PU` and `PD`, `PU;SP0;`. +fn plain(by_pen: &[Vec>]) -> Vec { + let mut s = String::from("IN;"); + for (i, paths) in by_pen.iter().enumerate().filter(|(_, p)| !p.is_empty()) { + s.push_str(&format!("PU;SP{};", i + 1)); + for path in paths { + let Some(((x, y), rest)) = path.split_first() else { continue }; + s.push_str(&format!("PU{x},{y};PD")); + for (k, (x, y)) in rest.iter().enumerate() { + if k > 0 { + s.push(','); + } + s.push_str(&format!("{x},{y}")); + } + s.push(';'); + } + } + s.push_str("PU;SP0;"); + s.into_bytes() +} + +/// HP-GL/2: `IN;`, the palette, each pen's paths as one `PE`, `PU;SP0;`. +fn encoded(by_pen: &[Vec>], pens: &[[u8; 3]]) -> Vec { + let mut out = b"IN;".to_vec(); + if !pens.is_empty() { + // Pens 1 to n on a plotter, and on a raster device (whose pens are 0 to NP − 1) too. + let np = (pens.len() + 1).next_power_of_two().max(2); + out.extend(format!("NP{np};").bytes()); + for (i, [r, g, b]) in pens.iter().enumerate() { + out.extend(format!("PC{},{r},{g},{b};", i + 1).bytes()); + } + } + let mut at: Option<(i64, i64)> = None; + for (i, paths) in by_pen.iter().enumerate().filter(|(_, p)| !p.is_empty()) { + out.extend(format!("PU;SP{};PE", i + 1).bytes()); + for path in paths { + for (k, &p) in path.iter().enumerate() { + if k == 0 { + out.push(b'<'); + } + // The first move of the plot is absolute, and so is one longer than the range a + // device takes for each number; every other is relative. + match at.map(|a| (p.0 - a.0, p.1 - a.1)).filter(|d| d.0.abs() <= MAX_COORD && d.1.abs() <= MAX_COORD) { + None => { + out.push(b'='); + pe_number(&mut out, p.0); + pe_number(&mut out, p.1); + } + Some((dx, dy)) => { + pe_number(&mut out, dx); + pe_number(&mut out, dy); + } + } + at = Some(p); + } + } + out.push(b';'); + } + out.extend(b"PU;SP0;"); + out +} + +/// `v` as a base-64 PE number: the sign in the low bit, the low digits from 63 up and the last +/// digit from 191 up. +fn pe_number(out: &mut Vec, v: i64) { + let mut n = (v.unsigned_abs() << 1) | u64::from(v < 0); + while n >= 64 { + out.push(63 + (n % 64) as u8); + n /= 64; + } + out.push(191 + n as u8); +} + +/// One line the pen draws, in document space, and its color. +struct Run { + rgb: [u8; 3], + points: Vec, +} + +/// The document walk: each visible path becomes runs, clipped, in paint order. +struct Walk<'a> { + doc: &'a Document, + symbols: SymbolBoxes<'a>, + /// The region the art is cut at. + region: Rect, + edge: Option, + /// The plotter's range and the region asked for, when that reaches past the range. + range: Option<(Rect, Rect)>, + /// The region asked for, to cut a line at before testing it against the range. + asked: Option, + tolerance: f64, + /// The clipping masks around the current object, outermost first. + clips: Vec, + /// The points those masks hold ([`MAX_MASK_POINTS`]). + mask_points: usize, + runs: Vec, + points: usize, + clip_tests: u64, + warnings: Vec, + nest: u32, + /// Objects the symbol instances written held, together ([`vectorcraft_doc::MAX_SYMBOL_NODES`]). + symbol_nodes: u64, + /// Objects the live art written made, together ([`MAX_GENERATED`]). + generated: u64, + /// A copy of the document without its layers, to bake symbol art in ([`Walk::baked`]). + scratch: Option, + /// The symbols whose art holds geometry effects ([`Walk::baked`]), or instances of such + /// symbols. + effect_symbols: HashSet, +} + +impl<'a> Walk<'a> { + fn new(doc: &'a Document, region: Rect, tolerance: f64) -> Self { + Self { + doc, + symbols: doc.symbol_boxes().quick(), + region, + edge: Region::new(vec![corners(region)], FillRule::NonZero), + range: None, + asked: None, + tolerance, + clips: vec![], + mask_points: 0, + runs: vec![], + points: 0, + clip_tests: 0, + warnings: vec![], + nest: 0, + symbol_nodes: 0, + generated: 0, + scratch: None, + effect_symbols: effect_symbols(doc), + } + } + + fn warn(&mut self, w: &str) { + warn(&mut self.warnings, w); + } + + fn full(&self) -> bool { + self.points >= MAX_POINTS + } + + fn node(&mut self, n: &Node, force: bool) { + if n.is_ruler_guide() || n.is_template() { + return; + } + if self.full() { + self.warn(TOO_MANY_POINTS); + return; + } + if !force && !n.visible { + return; + } + // Art at no opacity shows nothing, and nor does anything inside it. + if n.opacity <= 0.0 { + return; + } + // Bounds don't measure the effects in symbol art, which apply only once an instance is + // placed: an instance of such art, and what holds one, is not skipped by its bounds. + let unmeasured = !self.effect_symbols.is_empty() && holds_instance_of(n, &self.effect_symbols); + // The box round all the object can paint, a blend's steps on its spine included. + if !n.is_layer() && !unmeasured { + match n.reach_bounds_with(&self.symbols) { + Some(b) if !overlaps(b, self.region) => { + // Art over the asked-for region but past the plotter's range is cut off. + if self.range.is_some_and(|(_, whole)| overlaps(b, whole)) { + self.warn(OUT_OF_RANGE); + } + return; + } + None if !n.is_container() => return, + _ => {} + } + } + if n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { + self.warn(MASKS); + } + let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); + if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) { + self.warn(RASTER_FX); + } + match &n.kind { + // Non-printing layers aren't plotted. + NodeKind::Layer { children, printable, clip, .. } => { + if *printable { + self.children(children, *clip); + } + } + NodeKind::Group { children, clip } => self.children(children, *clip), + NodeKind::Path { path, guide, .. } => { + if !*guide { + self.shape(n, &path.to_bezpath()); + } + } + NodeKind::Compound { children, .. } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + self.shape(n, &bp); + } + NodeKind::Text(t) => { + if t.runs.iter().any(|r| r.inline.is_some()) { + self.warn(INLINE_GRAPHICS); + } + if let Some(o) = vectorcraft_effects::outline_text(n) { + self.warn(TEXT_OUTLINES); + self.node(&o, true); + } + } + NodeKind::Image(_) => self.warn(IMAGES), + NodeKind::SymbolInstance { symbol, xf } => self.symbol(n, symbol, *xf), + // A gradient mesh is its outline, in the average of its shown points' colors: its + // patches are shading, not lines. A mesh whose every point is at 0% opacity shows + // nothing. + NodeKind::Mesh(m) => { + let shown: Vec<[u8; 3]> = m.points.iter().filter(|p| p.opacity > 0.0).map(|p| rgb8(&p.color)).collect(); + if !shown.is_empty() { + self.draw(average(shown.into_iter()), &m.outline().to_bezpath(), true); + } + } + // Live blends, envelopes and repeats are drawn as their evaluated art, a level at a + // time, so a gradient mesh a blend makes is its outline too. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { + if self.nest >= MAX_NEST { + self.warn(TOO_DEEP); + return; + } + // Once the budget is spent, nothing more is evaluated. + if self.generated >= MAX_GENERATED { + self.warn(LIVE_LEFT_OUT); + return; + } + let art = vectorcraft_effects::expand_live(Some(self.doc), n); + let count = art.iter().map(|c| c.count() as u64).sum::(); + if self.generated.saturating_add(count) > MAX_GENERATED { + self.generated = MAX_GENERATED; + self.warn(LIVE_LEFT_OUT); + return; + } + self.generated += count; + self.nest += 1; + art.iter().for_each(|c| self.node(c, false)); + self.nest -= 1; + } + NodeKind::RulerGuide { .. } => {} + } + } + + /// A symbol instance's art, placed (clip groups included), within the budget for the export. + fn symbol(&mut self, n: &Node, symbol: &str, xf: Affine) { + let doc = self.doc; + if self.symbol_nodes >= vectorcraft_doc::MAX_SYMBOL_NODES { + self.warn(SYMBOLS_LEFT_OUT); + return; + } + let Some(sym) = doc.symbols.iter().find(|s| s.name == symbol) else { return }; + if self.nest >= MAX_NEST { + self.warn(TOO_DEEP); + return; + } + // The instance's own changes and its stain, as the canvas draws it. + let art = vectorcraft_brush::instance_art(&sym.art, n); + let count = art.count_with_masks() as u64; + let most = doc.symbol_slice_map(symbol, xf).map_or(count, |m| m.most_placed(&art, doc)); + let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let placed = doc.place_symbol_art(symbol, &art, xf); + let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64); + (placed, n) + }); + match placed { + Some(Some(art)) => { + let art = self.baked(art); + self.nest += 1; + self.node(&art, true); + self.nest -= 1; + } + Some(None) => {} + None => { + self.symbol_nodes = vectorcraft_doc::MAX_SYMBOL_NODES; + self.warn(SYMBOLS_LEFT_OUT); + } + } + } + + /// Placed symbol art with its geometry effects applied: the document's bake reaches the + /// layers, not the symbols' art. + fn baked(&mut self, art: Node) -> Node { + if !vectorcraft_effects::needs_bake(&art) { + return art; + } + // Guides and layers left out go first, as for the document's layers. + let art = Arc::new(art); + let art = match plotted(std::slice::from_ref(&art), false) { + Some(mut kept) => match kept.pop() { + Some(a) => a, + None => return Node::group(vectorcraft_doc::NodeId(0), vec![]), + }, + None => art, + }; + let art = Arc::unwrap_or_clone(art); + let doc = self.doc; + let scratch = self.scratch.get_or_insert_with(|| { + let mut d = doc.clone(); + d.layers.clear(); + d + }); + scratch.layers = vec![Arc::new(art)]; + let baked = vectorcraft_effects::bake_document(scratch).and_then(|b| b.layers.into_iter().next()); + let art = std::mem::take(&mut scratch.layers).into_iter().next(); + baked.or(art).map(Arc::unwrap_or_clone).unwrap_or_else(|| Node::group(vectorcraft_doc::NodeId(0), vec![])) + } + + /// A container's children. A clipping container's first child cuts the rest; it is drawn + /// itself, once and whole, when it paints: where its stroke paints (over the clipped art) when + /// it has one, else where its fill paints (below it). Nothing to clip by hides the clipped art, + /// as on the canvas. + fn children(&mut self, children: &[Arc], clip: bool) { + let Some((first, rest)) = children.split_first().filter(|_| clip) else { + children.iter().for_each(|c| self.node(c, false)); + return; + }; + 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)); + if paint.stroke.is_none() + && let Some(fill) = &paint.fill + { + self.node(fill, false); + } + match vectorcraft_effects::clip_outline(first).map(|(bp, rule)| (self.rings(&bp), rule)) { + Some((Some(rings), rule)) => { + let held: usize = rings.iter().map(Vec::len).sum(); + if let Some(region) = Region::new(rings, rule) { + self.mask_points += held; + self.clips.push(region); + rest.iter().for_each(|c| self.node(c, false)); + self.clips.pop(); + self.mask_points -= held; + } + } + // A mask past the budget isn't built: the art it would clip is drawn unclipped by it. + Some((None, _)) => { + self.warn(MASK_POINTS); + rest.iter().for_each(|c| self.node(c, false)); + } + 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); + } + } + + /// `bp` flattened into closed rings for a clipping mask; `None` when they would take the + /// masks around it past [`MAX_MASK_POINTS`]. + fn rings(&self, bp: &BezPath) -> Option>> { + let left = MAX_MASK_POINTS.saturating_sub(self.mask_points); + let (mut rings, whole) = self.flatten_within(bp, true, left); + if !whole || rings.iter().map(Vec::len).sum::() > left { + return None; + } + for r in &mut rings { + r.pop(); + } + Some(rings) + } + + /// 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. + fn shape(&mut self, n: &Node, bp: &BezPath) { + if bp.elements().is_empty() { + return; + } + let fill = top_fill(n); + let paint = match (top_stroke(n), fill) { + (Some(s), _) => { + if s.dash.as_ref().is_some_and(|d| d.is_dashed()) + || s.brush.is_some() + || s.profile.is_some() + || s.start_arrow.is_some() + || s.end_arrow.is_some() + { + self.warn(STROKE_LOOK); + } + &s.paint + } + (None, Some(f)) => f, + (None, None) => return, + }; + let rgb = self.flat(paint); + self.draw(rgb, bp, fill.is_some()); + } + + /// `bp` drawn in `rgb`: flattened (with `close` every subpath closed), then cut and added. + fn draw(&mut self, rgb: [u8; 3], bp: &BezPath, close: bool) { + // Bounded by the plot's point budget; each curve by its own segment cap. + let budget = MAX_POINTS.saturating_sub(self.points).saturating_mul(4).max(4); + let (lines, whole) = self.flatten_within(bp, close, budget); + if !whole { + self.warn(TOO_MANY_POINTS); + } + for line in lines { + self.add(rgb, line); + } + } + + /// One color for a paint: a solid color, the average of a gradient's stops, or a pattern's + /// first solid fill (gray without one). + fn flat(&self, p: &Paint) -> [u8; 3] { + match p { + Paint::None => [0; 3], + Paint::Solid { color, .. } => rgb8(color), + Paint::Gradient(g) => average(g.gradient.stops.iter().map(|s| rgb8(&s.color))), + Paint::Pattern { pattern, .. } => { + self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a))).map_or([128; 3], |c| rgb8(&c)) + } + } + } + + /// `bp` as polylines (each subpath one), within the tolerance, stopping once about `budget` + /// moves are made; with `close` every subpath ends at its start, else those that close. Also + /// whether every element of `bp` was flattened. + fn flatten_within(&self, bp: &BezPath, close: bool, budget: usize) -> (Vec>, bool) { + let mut out: Vec> = vec![]; + let mut cur: Vec = vec![]; + let end = |cur: &mut Vec, out: &mut Vec>, closing: bool| { + if (closing || close) + && let Some(&s) = cur.first() + && cur.last() != Some(&s) + { + cur.push(s); + } + out.push(std::mem::take(cur)); + }; + let (made, taken) = (std::cell::Cell::new(0usize), std::cell::Cell::new(0usize)); + let elements = bp.iter().take_while(|_| made.get() < budget).inspect(|_| taken.set(taken.get() + 1)); + vectorcraft_geom::flatten(elements, self.tolerance, |el| { + made.set(made.get() + 1); + match el { + PathEl::MoveTo(p) => { + end(&mut cur, &mut out, false); + cur.push(p); + } + PathEl::LineTo(p) => { + if cur.last() != Some(&p) { + cur.push(p); + } + } + PathEl::ClosePath => { + let s = cur.first().copied(); + end(&mut cur, &mut out, true); + // A subpath that goes on after its close starts where it closed. + if let Some(s) = s { + cur.push(s); + } + } + _ => {} + } + }); + let whole = taken.get() == bp.elements().len(); + // A subpath the budget cut short stays open: a move back to its start isn't on its outline. + if whole { + end(&mut cur, &mut out, false); + } else { + out.push(cur); + } + out.retain(|l| l.len() >= 2); + (out, whole) + } + + /// A polyline drawn in `rgb`, cut by the region and the clipping masks around it. + fn add(&mut self, rgb: [u8; 3], line: Vec) { + // Only the part over the region asked for counts: the rest is cut off anyway. + if let (Some((range, _)), Some(asked)) = (self.range, &self.asked) + && !self.warnings.iter().any(|w| w == OUT_OF_RANGE) + { + let mut unlimited = u64::MAX; + if asked.cut(&line, &mut unlimited).unwrap_or_default().iter().flatten().any(|p| !holds(range, *p)) { + self.warn(OUT_OF_RANGE); + } + } + // The region has four edges, so its cut costs a few tests a point and needs no limit. + let mut unlimited = u64::MAX; + let mut pieces = self.edge.as_ref().and_then(|edge| edge.cut(&line, &mut unlimited)).unwrap_or_default(); + for clip in &self.clips { + let mut left = MAX_CLIP_TESTS.saturating_sub(self.clip_tests); + let cut: Option>>> = pieces.iter().map(|p| clip.cut(p, &mut left)).collect(); + self.clip_tests = MAX_CLIP_TESTS.saturating_sub(left); + match cut { + Some(cut) => pieces = cut.into_iter().flatten().collect(), + None => { + // Spent: this art keeps the parts this mask and the ones inside it would cut, + // and later art that reaches a mask isn't clipped either. + self.clip_tests = MAX_CLIP_TESTS; + warn(&mut self.warnings, CLIP_WORK); + break; + } + } + } + for mut points in pieces { + let left = MAX_POINTS.saturating_sub(self.points); + if points.len() > left { + self.warn(TOO_MANY_POINTS); + points.truncate(left); + } + if points.len() < 2 { + return; + } + self.points += points.len(); + self.runs.push(Run { rgb, points }); + } + } +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// `nodes` (the children of a clipping container when `clipping`) without the layers and +/// sublayers a plot leaves out (hidden, non-printing, template or at 0% opacity) and without +/// guides, in layers and groups at any depth, so that baking can't wrap one in art that is drawn +/// (its Crop Marks); `None` when nothing changes. A guide that is a clipping path keeps its place +/// without its effects. +fn plotted(nodes: &[Arc], clipping: bool) -> Option>> { + let mut changed = false; + let mut out = Vec::with_capacity(nodes.len()); + for (i, n) in nodes.iter().enumerate() { + match &n.kind { + NodeKind::Layer { printable, template, .. } if !(n.visible && n.opacity > 0.0 && *printable && !*template) => { + changed = true; + continue; + } + NodeKind::Path { guide: true, .. } => { + changed = true; + if clipping && i == 0 { + let mut g = (**n).clone(); + g.appearance.effects.clear(); + out.push(Arc::new(g)); + } + continue; + } + _ => {} + } + let inner = match &n.kind { + NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } => plotted(children, *clip), + _ => None, + }; + match inner { + Some(children) => { + changed = true; + let mut c = (**n).clone(); + if let Some(ch) = c.children_mut() { + *ch = children; + } + out.push(Arc::new(c)); + } + None => out.push(n.clone()), + } + } + changed.then_some(out) +} + +/// The symbols of `doc` whose art holds geometry effects, and those whose art holds instances of +/// them, as deep as symbols are drawn ([`MAX_NEST`]). +fn effect_symbols(doc: &Document) -> HashSet { + let mut set: HashSet = doc.symbols.iter().filter(|s| vectorcraft_effects::needs_bake(&s.art)).map(|s| s.name.clone()).collect(); + for _ in 0..MAX_NEST { + if set.is_empty() { + break; + } + let more: Vec = + doc.symbols.iter().filter(|s| !set.contains(&s.name) && holds_instance_of(&s.art, &set)).map(|s| s.name.clone()).collect(); + if more.is_empty() { + break; + } + set.extend(more); + } + set +} + +/// Is `n`, or anything in it, an instance of one of `symbols`? +fn holds_instance_of(n: &Node, symbols: &HashSet) -> bool { + match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbols.contains(symbol), + _ => n.children().into_iter().flatten().any(|c| holds_instance_of(c, symbols)), + } +} + +fn rgb8(c: &vectorcraft_color::Color) -> [u8; 3] { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] +} + +/// The average of `colors` (black without any). +fn average(colors: impl Iterator) -> [u8; 3] { + let (sum, n) = colors.fold(([0.0; 3], 0.0), |(a, n), c| ([0, 1, 2].map(|i| a[i] + f64::from(c[i])), n + 1.0)); + sum.map(|v| (v / f64::max(n, 1.0)).round().clamp(0.0, 255.0) as u8) +} + +/// The first visible solid fill color in `n` (a pattern tile's art), hidden objects and objects +/// or fills at 0% opacity left out. +fn first_solid(n: &Node) -> Option { + if !n.visible || n.opacity <= 0.0 { + return None; + } + let own = n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 => f.paint.color(), + _ => None, + }); + own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c))) +} + +/// The topmost stroke of `n` that paints: visible, with a paint, a weight and some opacity. +fn top_stroke(n: &Node) -> Option<&StrokeLayer> { + n.appearance.items.iter().rev().find_map(|i| match i { + AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0 && s.opacity > 0.0 => Some(s), + _ => None, + }) +} + +/// 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 { + AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() && f.opacity > 0.0 => Some(&f.paint), + _ => None, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/cad/src/hpgl/write/clip.rs b/crates/cad/src/hpgl/write/clip.rs new file mode 100644 index 0000000000..235032ee2b --- /dev/null +++ b/crates/cad/src/hpgl/write/clip.rs @@ -0,0 +1,265 @@ +//! Polylines cut by a region: each segment is split where it crosses the region's edges, and the +//! pieces whose middle lies inside are kept, joined where they meet. + +use vectorcraft_geom::{FillRule, Point, Rect}; + +/// A clipping region: closed rings (each closes implicitly) and the rule that decides inside. +pub(super) struct Region { + rings: Vec>, + rule: FillRule, + bounds: Rect, +} + +impl Region { + /// The region of `rings` under `rule`; `None` when it has no ring of three points or more. + pub fn new(rings: Vec>, rule: FillRule) -> Option { + let rings: Vec> = rings.into_iter().filter(|r| r.len() >= 3).collect(); + let bounds = rings.iter().flatten().fold(None, |b: Option, p| Some(b.map_or(Rect::from_points(*p, *p), |b| b.union_pt(*p))))?; + Some(Self { rings, rule, bounds }) + } + + /// The edges this region has, which [`Region::cut`] tests each segment against. + fn edges(&self) -> u64 { + self.rings.iter().map(|r| r.len() as u64).sum() + } + + /// Does `p` lie on one of the region's edges (within a millionth of a point)? + fn on_edge(&self, p: Point) -> bool { + self.rings.iter().any(|ring| { + ring.iter().enumerate().any(|(i, &a)| { + let b = ring[(i + 1) % ring.len()]; + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (a + ab * t).distance(p) <= 1e-6 + }) + }) + } + + /// Is `p` inside (non-zero winding or an odd number of crossings), or on an edge? + fn contains(&self, p: Point) -> bool { + if self.on_edge(p) { + return true; + } + let mut winding = 0i64; + for ring in &self.rings { + for (i, &a) in ring.iter().enumerate() { + let b = ring[(i + 1) % ring.len()]; + if a.y <= p.y { + if b.y > p.y && cross(a, b, p) > 0.0 { + winding += 1; + } + } else if b.y <= p.y && cross(a, b, p) < 0.0 { + winding -= 1; + } + } + } + match self.rule { + FillRule::NonZero => winding != 0, + FillRule::EvenOdd => winding % 2 != 0, + } + } + + /// Where segment `a`–`b` crosses this region's edges, as fractions of its length strictly + /// between 0 and 1, sorted. + fn crossings(&self, a: Point, b: Point) -> Vec { + let mut ts = vec![]; + for ring in &self.rings { + for (i, &c) in ring.iter().enumerate() { + let d = ring[(i + 1) % ring.len()]; + if let Some(t) = intersect(a, b, c, d) + && t > 1e-12 + && t < 1.0 - 1e-12 + { + ts.push(t); + } + } + } + ts.sort_by(f64::total_cmp); + ts.dedup_by(|x, y| (*x - *y).abs() <= 1e-12); + ts + } + + /// The parts of polyline `line` inside the region, each a polyline of two points or more. + /// Every test of a point or a segment against an edge takes one from `budget`; `None` when + /// the budget runs out first. + pub fn cut(&self, line: &[Point], budget: &mut u64) -> Option>> { + let edges = self.edges(); + let mut out: Vec> = vec![]; + let mut cur: Vec = vec![]; + for w in line.windows(2) { + let (a, b) = (w[0], w[1]); + // A segment wholly off the region's box lies outside it. + let near = overlaps(Rect::from_points(a, b), self.bounds); + let ts = if near { + spend(budget, edges)?; + self.crossings(a, b) + } else { + vec![] + }; + let mut from = 0.0; + for to in ts.into_iter().chain([1.0]) { + let (p, q) = (a.lerp(b, from), a.lerp(b, to)); + // Each piece between crossings is tested on its own: its edge and winding tests. + let inside = if near { + spend(budget, edges.saturating_mul(2))?; + self.contains(p.midpoint(q)) + } else { + false + }; + if inside { + if cur.last().is_none_or(|l| l.distance(p) > 1e-9) { + flush(&mut out, &mut cur); + cur.push(p); + } + cur.push(q); + } else { + flush(&mut out, &mut cur); + } + from = to; + } + } + flush(&mut out, &mut cur); + // A closed line that leaves the region and comes back: its last piece ends where its + // first starts, at the line's start, and the two are one piece. + if line.len() > 2 + && line.first() == line.last() + && out.len() >= 2 + && let (Some(end), Some(start)) = (out.last().and_then(|l| l.last()), out.first().and_then(|l| l.first())) + && end.distance(*start) <= 1e-9 + { + let first = out.remove(0); + if let Some(last) = out.last_mut() { + last.extend(first.into_iter().skip(1)); + } + } + Some(out) + } +} + +/// Take `n` from `budget`; `None` when it holds less. +fn spend(budget: &mut u64, n: u64) -> Option<()> { + *budget = budget.checked_sub(n)?; + Some(()) +} + +fn flush(out: &mut Vec>, cur: &mut Vec) { + let line = std::mem::take(cur); + if line.len() >= 2 { + out.push(line); + } +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Which side of line `a`–`b` point `p` lies on (positive: left). +fn cross(a: Point, b: Point, p: Point) -> f64 { + (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y) +} + +/// Where segment `a`–`b` crosses segment `c`–`d`, as a fraction of `a`–`b`; `None` when they +/// are parallel or don't meet. +fn intersect(a: Point, b: Point, c: Point, d: Point) -> Option { + let (r, s) = (b - a, d - c); + let den = r.x * s.y - r.y * s.x; + if den.abs() <= f64::EPSILON * (r.hypot() * s.hypot()).max(f64::MIN_POSITIVE) { + return None; + } + let ac = c - a; + let t = (ac.x * s.y - ac.y * s.x) / den; + let u = (ac.x * r.y - ac.y * r.x) / den; + ((0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u)).then_some(t) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec { + vec![Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)] + } + + /// `line` cut by `r` with no limit on the work. + fn cut(r: &Region, line: &[Point]) -> Vec> { + let mut unlimited = u64::MAX; + r.cut(line, &mut unlimited).unwrap() + } + + #[test] + fn a_line_across_a_square_keeps_its_middle() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + let pieces = cut(&r, &[Point::new(-5.0, 5.0), Point::new(15.0, 5.0)]); + assert_eq!(pieces, vec![vec![Point::new(0.0, 5.0), Point::new(10.0, 5.0)]]); + // Wholly outside, nothing; wholly inside, the line as it is. + assert!(cut(&r, &[Point::new(20.0, 0.0), Point::new(30.0, 0.0)]).is_empty()); + let inside = vec![Point::new(1.0, 1.0), Point::new(9.0, 1.0), Point::new(9.0, 9.0)]; + assert_eq!(cut(&r, &inside), vec![inside]); + } + + /// Each piece between crossings costs a pass over the edges: a line through the teeth of a + /// comb spends far more than its two points times the edges. + #[test] + fn every_piece_between_crossings_is_paid_for() { + let mut ring = vec![]; + for i in 0..100 { + let x = f64::from(i) * 4.0; + ring.extend([Point::new(x, 0.0), Point::new(x + 2.0, 0.0), Point::new(x + 2.0, 10.0), Point::new(x + 4.0, 10.0)]); + } + ring.extend([Point::new(400.0, 20.0), Point::new(0.0, 20.0)]); + let r = Region::new(vec![ring], FillRule::NonZero).unwrap(); + let line = [Point::new(-1.0, 5.0), Point::new(401.0, 5.0)]; + // 402 edges, 200 crossings, 201 pieces: 402 + 201 × 2 × 402 tests. + let need = 402 + 201 * 2 * 402; + let mut budget = need; + assert_eq!(r.cut(&line, &mut budget).map(|p| p.len()), Some(100)); + assert_eq!(budget, 0); + let mut short = need - 1; + assert!(r.cut(&line, &mut short).is_none()); + // A segment off the region's box costs nothing. + assert_eq!(r.cut(&[Point::new(0.0, 30.0), Point::new(9.0, 30.0)], &mut 0), Some(vec![])); + } + + /// A line along any of the region's edges is inside it. + #[test] + fn a_line_on_an_edge_is_kept() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + for line in [ + [Point::new(0.0, 0.0), Point::new(10.0, 0.0)], + [Point::new(10.0, 0.0), Point::new(10.0, 10.0)], + [Point::new(10.0, 10.0), Point::new(0.0, 10.0)], + [Point::new(0.0, 10.0), Point::new(0.0, 0.0)], + ] { + assert_eq!(cut(&r, &line), vec![line.to_vec()], "{line:?}"); + } + } + + #[test] + fn a_line_leaving_and_coming_back_is_two_pieces() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0)]; + assert_eq!(cut(&r, &line), vec![vec![Point::new(5.0, 5.0), Point::new(10.0, 5.0)], vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0)]]); + } + + /// A closed line that starts inside, leaves and comes back is one piece: the part after it + /// comes back runs on into the part before it left. + #[test] + fn a_closed_line_cut_once_stays_one_piece() { + let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap(); + let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0)]; + assert_eq!(cut(&r, &line), vec![vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0), Point::new(10.0, 5.0)]]); + } + + #[test] + fn the_fill_rule_decides_a_hole() { + let rings = vec![square(0.0, 0.0, 10.0, 10.0), square(3.0, 3.0, 7.0, 7.0)]; + let line = [Point::new(-1.0, 5.0), Point::new(11.0, 5.0)]; + // Both rings wind the same way: even-odd cuts a hole, non-zero doesn't. + let even_odd = cut(&Region::new(rings.clone(), FillRule::EvenOdd).unwrap(), &line); + assert_eq!(even_odd, vec![vec![Point::new(0.0, 5.0), Point::new(3.0, 5.0)], vec![Point::new(7.0, 5.0), Point::new(10.0, 5.0)]]); + let non_zero = cut(&Region::new(rings, FillRule::NonZero).unwrap(), &line); + assert_eq!(non_zero.len(), 1); + assert!(Region::new(vec![vec![Point::ZERO, Point::new(1.0, 1.0)]], FillRule::NonZero).is_none()); + } +} diff --git a/crates/cad/src/hpgl/write/nest.rs b/crates/cad/src/hpgl/write/nest.rs new file mode 100644 index 0000000000..eee4630ff3 --- /dev/null +++ b/crates/cad/src/hpgl/write/nest.rs @@ -0,0 +1,357 @@ +//! Which of a pen's paths lie inside which of its closed paths, in plotter units, for drawing the +//! inner paths first. + +/// The box of `p`: `[x0, y0, x1, y1]`. +pub(super) fn bounds(p: &[(i64, i64)]) -> [i64; 4] { + p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]) +} + +/// For each of `paths` (`boxes` are their [`bounds`]), the closed paths it lies inside: none of +/// its segments crosses the closed path's line, each part of it between the points where it meets +/// that line lies inside the closed path (by the even-odd rule) or on the line, and at least one +/// part lies inside. A path touching the line of the one around it is inside it, a path crossing +/// it is not, and copies of one outline are not inside each other. A path is tested only against +/// closed paths larger than it (by the area of their box, then the area they enclose, then paint +/// order), so no two paths are each inside the other, and only against the closed paths in the +/// [`Grid`] cell of its box's first corner. `None` once `work` is spent: a grid cell filled and a +/// box compared cost one each, and each segment tested costs the closed path's points for its +/// crossings and again for each part of it. +pub(super) fn outer(paths: &[Vec<(i64, i64)>], boxes: &[[i64; 4]], work: &mut u64) -> Option>> { + let size: Vec<(i128, i128)> = paths + .iter() + .zip(boxes) + .map(|(p, b)| { + let box_area = (i128::from(b[2]) - i128::from(b[0])) * (i128::from(b[3]) - i128::from(b[1])); + (box_area, if closed(p) { twice_area(p) } else { 0 }) + }) + .collect(); + let rings: Vec = (0..paths.len()).filter(|&i| paths.get(i).is_some_and(|p| closed(p))).collect(); + let mut out = vec![vec![]; paths.len()]; + if rings.is_empty() { + return Some(out); + } + let grid = Grid::new(&rings, boxes, work)?; + for (a, (p, ab)) in paths.iter().zip(boxes).enumerate() { + for &r in grid.at(ab[0], ab[1]) { + spend(work, 1)?; + let (Some(rb), Some(ring)) = (boxes.get(r), paths.get(r)) else { continue }; + let smaller = (size.get(a), a) < (size.get(r), r); + if !smaller || rb[0] > ab[0] || rb[1] > ab[1] || rb[2] < ab[2] || rb[3] < ab[3] { + continue; + } + if inside(p, ring, work)? + && let Some(o) = out.get_mut(a) + { + o.push(r); + } + } + } + Some(out) +} + +/// The most cells along each side of a [`Grid`]. +const MAX_GRID_SIDE: usize = 512; +/// The most cells of a [`Grid`] a closed path is listed in; one whose box covers more is listed +/// once, among the wide ones. A closed path has 4 points or more, so the grid lists no more +/// entries than the pen has points. +const MAX_CELLS_PER_PATH: usize = 4; + +/// A grid over the boxes of a pen's closed paths, about one cell for each, up to +/// [`MAX_GRID_SIDE`] a side: each cell lists, in paint order, the closed paths whose box covers +/// it, and the closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells are listed +/// apart. A closed path whose box holds a point is among the wide ones or in that point's cell. +struct Grid { + /// The first corner of the closed paths' boxes together. + x0: i128, + y0: i128, + /// Their width and height, plus one. + w: i128, + h: i128, + /// Cells along each side. + side: usize, + /// The cells row by row. + cells: Vec>, + /// The closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells. + wide: Vec, +} + +impl Grid { + /// The grid of the closed paths `rings` (indices into `boxes`). `None` once `work` is spent. + fn new(rings: &[usize], boxes: &[[i64; 4]], work: &mut u64) -> Option { + let all = rings + .iter() + .filter_map(|&r| boxes.get(r)) + .fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |e, b| [e[0].min(b[0]), e[1].min(b[1]), e[2].max(b[2]), e[3].max(b[3])]); + let side = ((rings.len() as f64).sqrt().ceil() as usize).clamp(1, MAX_GRID_SIDE); + let (x0, y0) = (i128::from(all[0]), i128::from(all[1])); + let mut grid = Self { + x0, + y0, + w: (i128::from(all[2]) - x0 + 1).max(1), + h: (i128::from(all[3]) - y0 + 1).max(1), + side, + cells: vec![vec![]; side * side], + wide: vec![], + }; + for &r in rings { + let Some(b) = boxes.get(r) else { continue }; + let (Some((cx0, cy0)), Some((cx1, cy1))) = (grid.cell(b[0], b[1]), grid.cell(b[2], b[3])) else { continue }; + let covered = (cx1 - cx0 + 1) * (cy1 - cy0 + 1); + if covered > MAX_CELLS_PER_PATH { + spend(work, 1)?; + grid.wide.push(r); + continue; + } + spend(work, covered as u64)?; + for cy in cy0..=cy1 { + for cell in grid.cells.get_mut(cy * side + cx0..=cy * side + cx1).into_iter().flatten() { + cell.push(r); + } + } + } + Some(grid) + } + + /// The column and row of the cell holding `(x, y)`, `None` outside the grid. + fn cell(&self, x: i64, y: i64) -> Option<(usize, usize)> { + let (dx, dy) = (i128::from(x) - self.x0, i128::from(y) - self.y0); + if dx < 0 || dy < 0 || dx >= self.w || dy >= self.h { + return None; + } + let side = self.side as i128; + Some(((dx * side / self.w) as usize, (dy * side / self.h) as usize)) + } + + /// The wide closed paths, then those listed in the cell holding `(x, y)`. + fn at(&self, x: i64, y: i64) -> impl Iterator { + let cell = self.cell(x, y).and_then(|(cx, cy)| self.cells.get(cy * self.side + cx)).map_or(&[][..], Vec::as_slice); + self.wide.iter().chain(cell) + } +} + +/// Takes `n` from `work`; `None`, with `work` at 0, when it has less. +fn spend(work: &mut u64, n: u64) -> Option<()> { + let left = work.checked_sub(n); + *work = left.unwrap_or(0); + left.map(|_| ()) +} + +/// Does `p` start and end at one point, with three other points or more? +fn closed(p: &[(i64, i64)]) -> bool { + p.len() > 3 && p.first() == p.last() +} + +/// Twice the area the closed path `p` encloses. +fn twice_area(p: &[(i64, i64)]) -> i128 { + let sum: i128 = p + .windows(2) + .map(|w| match w { + [a, b] => i128::from(a.0) * i128::from(b.1) - i128::from(b.0) * i128::from(a.1), + _ => 0, + }) + .sum(); + sum.abs() +} + +/// Does `p` lie inside the closed path `ring`, as [`outer`] decides? `None` once `work` is spent. +fn inside(p: &[(i64, i64)], ring: &[(i64, i64)], work: &mut u64) -> Option { + let mut any = false; + for w in p.windows(2) { + let [s, e] = w else { continue }; + spend(work, ring.len() as u64)?; + // Where the segment meets the line without crossing it: its ends and the ring's points on + // it. Between two of these the segment is all inside or all outside. + let mut stops = vec![*s, *e]; + for edge in ring.windows(2) { + let [a, b] = edge else { continue }; + if crosses((*s, *e), (*a, *b)) { + return Some(false); + } + if a != s && a != e && on((*s, *e), *a) { + stops.push(*a); + } + } + let along = |q: &(i64, i64)| { + (i128::from(q.0) - i128::from(s.0)) * (i128::from(e.0) - i128::from(s.0)) + + (i128::from(q.1) - i128::from(s.1)) * (i128::from(e.1) - i128::from(s.1)) + }; + stops.sort_by_key(along); + stops.dedup(); + for part in stops.windows(2) { + let [m, n] = part else { continue }; + spend(work, ring.len() as u64)?; + // The part's middle, doubled so it is whole. + match locate((i128::from(m.0) + i128::from(n.0), i128::from(m.1) + i128::from(n.1)), ring) { + Some(false) => return Some(false), + Some(true) => any = true, + None => {} + } + } + } + Some(any) +} + +/// Which side of the line through `a` and `b` `c` is on: 1 left, -1 right, 0 on it. +fn side(a: (i64, i64), b: (i64, i64), c: (i64, i64)) -> i128 { + let d = (i128::from(b.0) - i128::from(a.0)) * (i128::from(c.1) - i128::from(a.1)) + - (i128::from(b.1) - i128::from(a.1)) * (i128::from(c.0) - i128::from(a.0)); + d.signum() +} + +/// Do segments `u` and `v` cross at a point inside both, each passing from one side of the other +/// to the other side? +fn crosses(u: ((i64, i64), (i64, i64)), v: ((i64, i64), (i64, i64))) -> bool { + side(u.0, u.1, v.0) * side(u.0, u.1, v.1) < 0 && side(v.0, v.1, u.0) * side(v.0, v.1, u.1) < 0 +} + +/// Does `q` lie on segment `u`? +fn on(u: ((i64, i64), (i64, i64)), q: (i64, i64)) -> bool { + let ((x0, y0), (x1, y1)) = u; + side(u.0, u.1, q) == 0 && q.0 >= x0.min(x1) && q.0 <= x0.max(x1) && q.1 >= y0.min(y1) && q.1 <= y0.max(y1) +} + +/// Where the doubled point `q` lies against the closed path `ring`: `Some(true)` inside by the +/// even-odd rule, `Some(false)` outside, `None` on its line. +fn locate(q: (i128, i128), ring: &[(i64, i64)]) -> Option { + let mut inside = false; + for w in ring.windows(2) { + let [a, b] = w else { continue }; + let (a, b) = ((2 * i128::from(a.0), 2 * i128::from(a.1)), (2 * i128::from(b.0), 2 * i128::from(b.1))); + // Which side of the segment's line `q` is on, 0 on it. + let side = (b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0); + if side == 0 && q.0 >= a.0.min(b.0) && q.0 <= a.0.max(b.0) && q.1 >= a.1.min(b.1) && q.1 <= a.1.max(b.1) { + return None; + } + // A segment crossing the level of `q` to its right. + if (a.1 > q.1) != (b.1 > q.1) && (side > 0) == (b.1 > a.1) { + inside = !inside; + } + } + Some(inside) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x0: i64, y0: i64, x1: i64, y1: i64) -> Vec<(i64, i64)> { + vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + } + + fn nest(paths: &[Vec<(i64, i64)>]) -> Option>> { + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = u64::MAX; + outer(paths, &boxes, &mut work) + } + + #[test] + fn points_inside_outside_and_on_the_line() { + let ring = square(0, 0, 10, 10); + assert_eq!(locate((10, 10), &ring), Some(true)); + assert_eq!(locate((30, 10), &ring), Some(false)); + assert_eq!(locate((10, -2), &ring), Some(false)); + assert_eq!(locate((20, 10), &ring), None); + assert_eq!(locate((0, 0), &ring), None); + } + + #[test] + fn a_path_inside_another_lies_inside_it_and_not_the_other_way() { + let paths = [square(0, 0, 100, 100), square(10, 10, 20, 20), vec![(30, 30), (40, 40)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0]])); + } + + #[test] + fn touching_counts_as_inside_and_crossing_does_not() { + // A square with a corner on the outline's left edge, a triangle whose corners lie on the + // outline's edges, and a square that crosses the outline. + let paths = [square(0, 0, 100, 100), square(0, 40, 20, 60), vec![(0, 50), (50, 0), (100, 50), (0, 50)], square(90, 90, 120, 95)]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0], vec![]])); + } + + /// Every point of a copy lies on the other's line, and none inside it. + #[test] + fn copies_of_one_outline_do_not_lie_inside_each_other() { + let paths = [square(0, 0, 10, 10), square(0, 0, 10, 10), vec![(10, 10), (10, 0), (0, 0), (0, 10), (10, 10)]]; + assert_eq!(nest(&paths), Some(vec![vec![], vec![], vec![]])); + } + + /// A line whose ends and middle lie inside an outline but which crosses a slot cut into it. + #[test] + fn a_line_across_a_slot_is_not_inside() { + let slotted = vec![(0, 0), (100, 0), (100, 100), (14, 100), (14, 40), (12, 40), (12, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slotted, vec![(10, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + } + + /// A line that leaves an outline and comes back through two of its corners, crossing none of + /// its sides, and one that runs along the bottom of a slot. + #[test] + fn a_line_through_corners_is_inside_only_where_it_stays_inside() { + let v_slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (15, 40), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[v_slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![]])); + let slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (10, 50), (10, 100), (0, 100), (0, 0)]; + assert_eq!(nest(&[slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![0]])); + } + + /// An outline that crosses itself encloses no area by its signed sum, yet a line in one of its + /// lobes lies inside it. + #[test] + fn a_line_inside_a_lobe_of_a_crossed_outline_lies_inside_it() { + let bow = vec![(0, 0), (100, 100), (0, 100), (100, 0), (0, 0)]; + assert_eq!(twice_area(&bow), 0); + assert_eq!(nest(&[bow, vec![(40, 80), (60, 80)]]), Some(vec![vec![], vec![0]])); + } + + #[test] + fn a_ring_inside_a_ring_inside_a_ring_lies_inside_both() { + let paths = [square(20, 20, 80, 80), square(0, 0, 100, 100), square(40, 40, 60, 60)]; + assert_eq!(nest(&paths), Some(vec![vec![1], vec![], vec![0, 1]])); + } + + /// 10,000 parts in rows, each with a hole: each hole is tested against the few closed paths + /// near it, not against every part. + #[test] + fn parts_in_rows_are_tested_only_against_the_closed_paths_near_them() { + let mut paths = vec![]; + for row in 0..100 { + for col in 0..100 { + let (x, y) = (col * 30, row * 30); + paths.push(square(x, y, x + 20, y + 20)); + paths.push(square(x + 5, y + 5, x + 15, y + 15)); + } + } + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 5_000_000; + let found = outer(&paths, &boxes, &mut work); + let want: Vec> = (0..paths.len()).map(|i| if i % 2 == 1 { vec![i - 1] } else { vec![] }).collect(); + assert_eq!(found, Some(want)); + } + + /// 400 closed paths that each cover most of the grid are listed once each, not in every cell + /// they cover. + #[test] + fn closed_paths_covering_many_cells_are_listed_once() { + let mut paths: Vec> = (0..400).map(|i| square(i, i, 1000 + i, 1000 + i)).collect(); + paths.extend((0..400).map(|i| { + let (x, y) = ((i % 20) * 50, (i / 20) * 50); + square(x, y, x + 10, y + 10) + })); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let rings: Vec = (0..paths.len()).collect(); + let mut work = u64::MAX; + let Some(grid) = Grid::new(&rings, &boxes, &mut work) else { panic!("the budget ran out") }; + assert_eq!(grid.wide, (0..400).collect::>()); + assert!(grid.cells.iter().map(Vec::len).sum::() <= 400 * MAX_CELLS_PER_PATH); + } + + #[test] + fn finding_what_lies_inside_stops_at_its_budget() { + let paths: Vec> = (0..50).map(|i| square(-i, -i, 100 + i, 100 + i)).collect(); + let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect(); + let mut work = 1_000; + assert_eq!(outer(&paths, &boxes, &mut work), None); + assert_eq!(work, 0); + let mut work = u64::MAX; + let found = outer(&paths, &boxes, &mut work); + assert_eq!(found.map(|o| o.iter().map(Vec::len).sum::()), Some(50 * 49 / 2)); + } +} diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs new file mode 100644 index 0000000000..c29ef67532 --- /dev/null +++ b/crates/cad/src/hpgl/write/tests.rs @@ -0,0 +1,1109 @@ +//! HP-GL export: the instructions written, pens, origins, what is cut and left out, limits, and +//! plots read back by the HP-GL reader. + +use std::sync::Arc; + +use kurbo::{PathEl, Shape as _}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, LayerColor, Node, NodeKind, TextObject}; +use vectorcraft_geom::{PathData, Point, Rect, shapes}; + +use super::*; + +/// A 200 × 100 pt document whose first layer holds what `nodes` makes. +fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec) -> Document { + let mut d = Document::new(200.0, 100.0); + let nodes = nodes(&mut d); + let layer = d.layers[0].id; + for n in nodes { + d.insert(Some(layer), usize::MAX, n).unwrap(); + } + d +} + +fn stroked(d: &mut Document, p: PathData, c: Color) -> Node { + Node::path(d.alloc_id(), p, Appearance::basic(Paint::None, Paint::solid(c), 1.0)) +} + +fn filled(d: &mut Document, p: PathData, c: Color) -> Node { + Node::path(d.alloc_id(), p, Appearance::basic(Paint::solid(c), Paint::None, 1.0)) +} + +fn line(a: (f64, f64), b: (f64, f64)) -> PathData { + let mut bp = BezPath::new(); + bp.move_to(a); + bp.line_to(b); + PathData::from_bezpath(&bp) +} + +fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> PathData { + shapes::rectangle(Rect::new(x0, y0, x1, y1)) +} + +/// The options with the first artboard as the region. +fn opts(d: &Document) -> HpglOptions { + HpglOptions { region: d.artboards[0].rect, ..HpglOptions::default() } +} + +fn plot(d: &Document, o: &HpglOptions) -> HpglOutput { + match export(d, o) { + Ok(out) => out, + Err(e) => panic!("{e}"), + } +} + +fn text(out: &HpglOutput) -> String { + String::from_utf8(out.bytes.clone()).expect("ASCII") +} + +/// A black square outline, a red line and a blue ellipse filled without a stroke. +fn three() -> Document { + doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK), + stroked(d, line((70.0, 20.0), (150.0, 80.0)), Color::rgb8(255, 0, 0)), + filled(d, shapes::ellipse(Rect::new(70.0, 30.0, 130.0, 70.0)), Color::rgb8(0, 0, 255)), + ] + }) +} + +/// Every instruction in the HP-GL reference guide's form: a two-letter mnemonic, integer +/// parameters separated by commas, and a semicolon (https://support.hp.com/sg-en/document/c01121263). +#[test] +fn a_plot_is_pen_moves_in_plotter_units_from_the_bottom_left() { + let d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]); + let out = plot(&d, &opts(&d)); + // 1016 units to the inch, y up from the artboard's bottom edge; the square closes. + assert_eq!(text(&out), "IN;PU;SP1;PU141,1270;PD847,1270,847,847,141,847,141,1270;PU;SP0;"); + for i in text(&out).split_terminator(';') { + let (op, params) = i.split_at(2); + assert!(op.bytes().all(|c| c.is_ascii_uppercase()), "{i}"); + assert!(params.is_empty() || params.split(',').all(|v| v.parse::().is_ok()), "{i}"); + } + assert_eq!(out.pens, [[0, 0, 0]]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); +} + +#[test] +fn the_origin_can_be_the_center() { + let d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]); + let out = plot(&d, &HpglOptions { origin: Origin::Center, ..opts(&d) }); + // The artboard's center is (100, 50): x from -1270, y from 564 (40 pt up). + assert_eq!(text(&out), "IN;PU;SP1;PU-1270,564;PD-564,564,-564,141,-1270,141,-1270,564;PU;SP0;"); +} + +#[test] +fn each_new_color_is_the_next_pen_and_pens_draw_in_turn() { + let d = doc_with(|d| { + vec![ + stroked(d, line((10.0, 10.0), (20.0, 10.0)), Color::rgb8(255, 0, 0)), + stroked(d, line((10.0, 20.0), (20.0, 20.0)), Color::BLACK), + stroked(d, line((10.0, 30.0), (20.0, 30.0)), Color::rgb8(255, 0, 0)), + ] + }); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens, [[255, 0, 0], [0, 0, 0]]); + assert_eq!(text(&out), "IN;PU;SP1;PU141,1270;PD282,1270;PU141,988;PD282,988;PU;SP2;PU141,1129;PD282,1129;PU;SP0;"); + // A pen table: each color takes the nearest pen's (red is as near black as blue, and black + // comes first). + let table = plot(&d, &HpglOptions { pens: vec![[0, 0, 0], [0, 0, 255]], ..opts(&d) }); + assert_eq!(table.pens, [[0, 0, 0], [0, 0, 255]]); + assert!(text(&table).starts_with("IN;PU;SP1;PU141,1270;"), "{}", text(&table)); + assert!(!text(&table).contains("SP2"), "{}", text(&table)); +} + +#[test] +fn a_filled_path_is_its_closed_outline_and_a_stroke_its_line() { + let d = doc_with(|d| { + let mut open = BezPath::new(); + open.move_to((10.0, 10.0)); + open.line_to((20.0, 10.0)); + open.line_to((20.0, 20.0)); + let open = PathData::from_bezpath(&open); + let both = Node::path(d.alloc_id(), open.clone(), Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb8(255, 0, 0)), 1.0)); + vec![filled(d, open.clone(), Color::BLACK), stroked(d, open, Color::BLACK), both] + }); + let out = plot(&d, &opts(&d)); + let t = text(&out); + assert!(t.contains("PU141,1270;PD282,1270,282,1129,141,1270;"), "the fill closes: {t}"); + assert!(t.contains("PU141,1270;PD282,1270,282,1129;PU;"), "the stroke doesn't: {t}"); + // A path with a fill and a stroke is closed too, in its stroke's color. + assert_eq!(out.pens, [[0, 0, 0], [255, 0, 0]]); + assert!(t.ends_with("SP2;PU141,1270;PD282,1270,282,1129,141,1270;PU;SP0;"), "{t}"); +} + +/// A gradient mesh is its outline, once, in the average of its points' colors. +#[test] +fn a_gradient_mesh_is_its_outline() { + let d = doc_with(|d| { + let mesh = vectorcraft_doc::live::GradientMesh::from_surface(4, 4, &|u, v| Point::new(10.0 + u * 50.0, 10.0 + v * 50.0), &|u, _| { + if u < 0.5 { Color::rgb8(255, 0, 0) } else { Color::rgb8(0, 0, 255) } + }); + vec![Node::new(d.alloc_id(), NodeKind::Mesh(mesh))] + }); + let out = plot(&d, &opts(&d)); + let t = text(&out); + assert_eq!(t.matches("PD").count(), 1, "{t}"); + assert!(t.starts_with("IN;PU;SP1;PU"), "{t}"); + // The square's four corners are on the line it draws. + for corner in ["141,1270", "847,1270", "847,564", "141,564"] { + assert!(t.contains(corner), "{corner} in {t}"); + } + let [r, g, b] = out.pens[0]; + assert!(r > 0 && b > 0 && g == 0, "{:?}", out.pens); +} + +/// A square gradient mesh, 30 pt a side, at `x`. +fn mesh_at(x: f64) -> vectorcraft_doc::live::GradientMesh { + vectorcraft_doc::live::GradientMesh::from_surface(4, 4, &|u, v| Point::new(x + u * 30.0, 10.0 + v * 30.0), &|_, _| Color::BLACK) +} + +fn blend_of(d: &mut Document, keys: Vec, steps: u32) -> Node { + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Steps(steps), ..Default::default() }; + Node::new(d.alloc_id(), NodeKind::Blend { children: keys.into_iter().map(Arc::new).collect(), spec: Box::new(spec) }) +} + +/// A mesh inside a blend is its outline too: a blend of two meshes with one step between them +/// draws three outlines. A mesh whose every point is at 0% opacity draws nothing. +#[test] +fn meshes_in_blends_are_outlines_and_clear_meshes_draw_nothing() { + let d = doc_with(|d| { + let keys = vec![Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(10.0))), Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(110.0)))]; + let mut clear = mesh_at(60.0); + clear.points.iter_mut().for_each(|p| p.opacity = 0.0); + vec![blend_of(d, keys, 1), Node::new(d.alloc_id(), NodeKind::Mesh(clear))] + }); + let t = text(&plot(&d, &opts(&d))); + assert_eq!(t.matches("PD").count(), 3, "{t}"); +} + +/// Live art that makes more objects than the plot's limit (1,000 in tests) is left out with a +/// warning. +#[test] +fn live_art_past_its_limit_is_left_out() { + let d = doc_with(|d| { + let keys = vec![stroked(d, line((10.0, 10.0), (10.0, 20.0)), Color::BLACK), stroked(d, line((190.0, 10.0), (190.0, 20.0)), Color::BLACK)]; + vec![blend_of(d, keys, 1_200)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == LIVE_LEFT_OUT), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP0;"); +} + +/// A line shorter than a plotter unit draws nothing and takes no pen. +#[test] +fn a_line_that_draws_nothing_takes_no_pen() { + let d = doc_with(|d| { + vec![stroked(d, line((10.0, 10.0), (10.01, 10.0)), Color::rgb8(255, 0, 0)), stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens, [[0, 0, 0]]); + assert!(text(&out).starts_with("IN;PU;SP1;PU141,1270;"), "{}", text(&out)); + let two = plot(&d, &HpglOptions { version: Version::Hpgl2, ..opts(&d) }); + assert!(two.bytes.starts_with(b"IN;NP2;PC1,0,0,0;PU;SP1;PE"), "{:?}", String::from_utf8_lossy(&two.bytes)); +} + +/// The plots of one export share the pens: a color is the same pen in each, numbered in the +/// order the plots first draw in it. +#[test] +fn the_plots_of_one_export_share_their_pens() { + let d = doc_with(|d| { + vec![ + stroked(d, line((10.0, 10.0), (20.0, 10.0)), Color::BLACK), + stroked(d, line((110.0, 10.0), (120.0, 10.0)), Color::rgb8(255, 0, 0)), + stroked(d, line((110.0, 20.0), (120.0, 20.0)), Color::BLACK), + ] + }); + let regions = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 200.0, 100.0)]; + let plots = export_regions(&d, &HpglOptions::default(), ®ions).unwrap(); + assert_eq!(plots.len(), 2); + for p in &plots { + assert_eq!(p.pens, [[0, 0, 0], [255, 0, 0]]); + } + assert_eq!(text(&plots[0]), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;"); + assert_eq!(text(&plots[1]), "IN;PU;SP1;PU141,1129;PD282,1129;PU;SP2;PU141,1270;PD282,1270;PU;SP0;"); +} + +#[test] +fn hidden_and_non_printing_art_is_left_out() { + let mut d = doc_with(|d| { + let mut hidden = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK); + hidden.visible = false; + let none = Node::path(d.alloc_id(), rect(30.0, 10.0, 40.0, 20.0), Appearance::basic(Paint::None, Paint::None, 1.0)); + vec![hidden, none] + }); + let mut layer = Node::layer(d.alloc_id(), "Notes", LayerColor::Preset(1)); + if let NodeKind::Layer { printable, .. } = &mut layer.kind { + *printable = false; + } + let art = stroked(&mut d, rect(50.0, 10.0, 60.0, 20.0), Color::BLACK); + layer.children_mut().unwrap().push(Arc::new(art)); + d.layers.push(Arc::new(layer)); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); +} + +/// Hidden and template layers, guides of either kind and placed images draw nothing; an image +/// comes back as a warning. +#[test] +fn layers_guides_and_images_that_draw_nothing_are_left_out() { + let mut d = doc_with(|d| { + let mut guide = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK); + if let NodeKind::Path { guide, .. } = &mut guide.kind { + *guide = true; + } + let ruler = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 50.0, artboard: None }); + let image = vectorcraft_doc::ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + vec![ruler, guide, Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)))] + }); + let hidden = d.add_layer(Some("Notes")); + let template = d.add_layer(Some("Tracing")); + for l in [hidden, template] { + let n = stroked(&mut d, rect(30.0, 10.0, 40.0, 20.0), Color::BLACK); + d.insert(Some(l), 0, n).unwrap(); + } + if let Some(n) = d.node_mut(hidden) { + n.visible = false; + } + if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) { + *template = true; + } + let out = plot(&d, &opts(&d)); + assert_eq!(text(&out), "IN;PU;SP0;"); + assert_eq!(out.warnings, [IMAGES]); +} + +/// A non-printing layer's Crop Marks are left out with the layer, and so is a template layer's. +#[test] +fn the_crop_marks_of_a_layer_left_out_are_left_out() { + for template in [false, true] { + let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]); + let layer = d.layers[0].id; + if let Some(n) = d.node_mut(layer) { + n.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true }); + if let NodeKind::Layer { printable, template: t, .. } = &mut n.kind { + (*printable, *t) = (template, template); + } + } + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;", "template: {template}"); + } +} + +/// A guide's Crop Marks are left out with the guide, in the layers and in a symbol's art. +#[test] +fn the_crop_marks_of_a_guide_are_left_out() { + let guide = |d: &mut Document| { + let mut g = stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK); + g.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true }); + if let NodeKind::Path { guide, .. } = &mut g.kind { + *guide = true; + } + g + }; + let d = doc_with(|d| { + let g = guide(d); + vec![Node::group(d.alloc_id(), vec![Arc::new(g)])] + }); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); + let mut d = Document::new(200.0, 100.0); + let g = guide(&mut d); + let art = Node::group(d.alloc_id(), vec![Arc::new(g)]); + let d = symbol_doc(art, |_| {}); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); +} + +/// Inline graphics in type are left out with a warning. +#[test] +fn inline_graphics_in_type_are_left_out_with_a_warning() { + let d = doc_with(|d| { + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let mut t = TextObject::point(Point::new(10.0, 60.0), "O", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("mark"), style)); + vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == INLINE_GRAPHICS), "{:?}", out.warnings); +} + +/// A gradient's pen is the average of its stops' colors. +#[test] +fn a_gradient_takes_the_average_of_its_stops() { + let mut g = vectorcraft_color::GradientPaint::new(Default::default()); + g.gradient.stops[0].color = Color::rgb8(255, 0, 0); + g.gradient.stops[1].color = Color::rgb8(0, 0, 255); + let d = + doc_with(|d| vec![Node::path(d.alloc_id(), rect(10.0, 10.0, 60.0, 40.0), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 1.0))]); + assert_eq!(plot(&d, &opts(&d)).pens, [[128, 0, 128]]); +} + +/// An object or group at 0% opacity shows nothing, and a fill or stroke at 0% opacity gives a +/// path no color: here only the blue fill's outline is drawn. +#[test] +fn art_at_no_opacity_is_left_out() { + let (red, green, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 255, 0), Color::rgb8(0, 0, 255)); + let d = doc_with(|d| { + let mut faded = stroked(d, rect(10.0, 10.0, 20.0, 20.0), red); + faded.opacity = 0.0; + let inner = stroked(d, line((30.0, 10.0), (40.0, 10.0)), green); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + group.opacity = 0.0; + let mut clear_fill = filled(d, rect(10.0, 30.0, 20.0, 40.0), green); + let mut fill_and_clear_stroke = + Node::path(d.alloc_id(), rect(50.0, 10.0, 60.0, 20.0), Appearance::basic(Paint::solid(blue), Paint::solid(red), 1.0)); + for item in clear_fill.appearance.items.iter_mut().chain(fill_and_clear_stroke.appearance.items.iter_mut()) { + match item { + AppearanceItem::Fill(f) if f.paint == Paint::solid(green) => f.opacity = 0.0, + AppearanceItem::Stroke(s) => s.opacity = 0.0, + _ => {} + } + } + vec![faded, group, clear_fill, fill_and_clear_stroke] + }); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens, [[0, 0, 255]]); + assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out)); +} + +/// A pattern's pen is the color of its first visible solid fill: a hidden tile object, one at 0% +/// opacity and a fill at 0% opacity are skipped. +#[test] +fn a_pattern_takes_the_color_of_its_first_visible_fill() { + let mut d = doc_with(|_| vec![]); + let mut hidden = filled(&mut d, rect(0.0, 0.0, 5.0, 5.0), Color::rgb8(255, 0, 0)); + hidden.visible = false; + let mut faded = filled(&mut d, rect(0.0, 5.0, 5.0, 10.0), Color::rgb8(0, 255, 0)); + faded.opacity = 0.0; + let mut clear_fill = filled(&mut d, rect(5.0, 5.0, 10.0, 10.0), Color::rgb8(255, 255, 0)); + for item in &mut clear_fill.appearance.items { + if let AppearanceItem::Fill(f) = item { + f.opacity = 0.0; + } + } + let shown = filled(&mut d, rect(5.0, 0.0, 10.0, 5.0), Color::rgb8(0, 0, 255)); + let art = [hidden, faded, clear_fill, shown].map(Arc::new).to_vec(); + d.patterns.push(vectorcraft_doc::PatternDef::new("tiles", art)); + let path = Node::path( + d.alloc_id(), + rect(10.0, 10.0, 50.0, 50.0), + Appearance::basic(Paint::Pattern { pattern: "tiles".into(), xf: Default::default() }, Paint::None, 1.0), + ); + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, path).unwrap(); + assert_eq!(plot(&d, &opts(&d)).pens, [[0, 0, 255]]); +} + +#[test] +fn art_is_cut_at_the_artboard_and_by_clipping_masks() { + let d = doc_with(|d| { + let across = stroked(d, line((-50.0, 50.0), (250.0, 50.0)), Color::BLACK); + let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::default()); + let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::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![across, group] + }); + let t = text(&plot(&d, &opts(&d))); + assert_eq!(t, "IN;PU;SP1;PU0,706;PD2822,706;PU282,988;PD564,988;PU;SP0;"); +} + +#[test] +fn type_is_drawn_as_its_outlines() { + let d = doc_with(|d| { + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Ol", style))))] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TEXT_OUTLINES), "{:?}", out.warnings); + // A pen-up move to each contour (the O's two and the l's one), and the two around the pen. + let t = text(&out); + assert!(t.matches("PU").count() >= 5, "{t}"); +} + +#[test] +fn an_empty_or_zero_length_plot_has_no_moves() { + let empty = Document::new(200.0, 100.0); + assert_eq!(text(&plot(&empty, &opts(&empty))), "IN;PU;SP0;"); + let d = + doc_with(|d| vec![stroked(d, line((10.0, 10.0), (10.0, 10.0)), Color::BLACK), stroked(d, line((10.0, 10.0), (10.01, 10.0)), Color::BLACK)]); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;"); + let bad = HpglOptions { region: Rect::new(0.0, 0.0, 0.0, 10.0), ..HpglOptions::default() }; + assert_eq!(export(&d, &bad).err().as_deref(), Some("the export region is empty")); + let nan = HpglOptions { region: Rect::new(0.0, 0.0, f64::NAN, 10.0), ..HpglOptions::default() }; + assert!(export(&d, &nan).is_err()); +} + +/// A tolerance outside its range and a pen table of more than 255 pens are errors, not changed. +#[test] +fn bad_options_are_errors() { + let d = three(); + for tolerance in [0.0, 0.009, 10.01, f64::NAN, f64::INFINITY] { + let o = HpglOptions { tolerance, ..opts(&d) }; + assert_eq!(export(&d, &o).err().as_deref(), Some("the curve tolerance must be from 0.01 to 10 points"), "{tolerance}"); + } + for tolerance in [0.01, 10.0] { + assert!(export(&d, &HpglOptions { tolerance, ..opts(&d) }).is_ok(), "{tolerance}"); + } + let o = HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS + 1], ..opts(&d) }; + assert_eq!(export(&d, &o).err().as_deref(), Some("the pen table holds more than 255 pens")); + assert!(export(&d, &HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS], ..opts(&d) }).is_ok()); +} + +#[test] +fn art_past_the_plotter_range_is_cut_off() { + let d = doc_with(|d| vec![stroked(d, line((0.0, 50.0), (1e9, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() }); + assert!(out.warnings.iter().any(|w| w == OUT_OF_RANGE), "{:?}", out.warnings); + assert_eq!(text(&out), format!("IN;PU;SP1;PU0,706;PD{MAX_COORD},706;PU;SP0;")); + // Art past the range but off the region asked for would be cut off anyway: no warning. + let d = doc_with(|d| vec![stroked(d, line((-1e9, 50.0), (10.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU0,706;PD141,706;PU;SP0;"); +} + +/// HP-GL past 16-bit coordinates is written as it is, with a warning; HP-GL/2 gets none. +#[test] +fn hpgl_past_16_bit_coordinates_warns() { + let region = Rect::new(-2400.0, 0.0, 2400.0, 100.0); + // Past 32,767 from the bottom-left corner, within the range from the center. + let d = doc_with(|d| vec![stroked(d, line((-2000.0, 50.0), (2000.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region, ..HpglOptions::default() }); + assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]); + assert_eq!(text(&out), "IN;PU;SP1;PU5644,706;PD62089,706;PU;SP0;"); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU-28222,0;PD28222,0;PU;SP0;"); + // Past −32,768 and 32,767 from the center. + let d = doc_with(|d| vec![stroked(d, line((-2400.0, 50.0), (2400.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() }); + assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]); + assert_eq!(text(&out), "IN;PU;SP1;PU-33867,0;PD33867,0;PU;SP0;"); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, version: Version::Hpgl2, ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); +} + +#[test] +fn a_plot_stops_at_its_point_budget() { + let d = doc_with(|d| (0..MAX_POINTS / 4).map(|i| stroked(d, rect(1.0, 1.0, 2.0 + (i % 50) as f64, 3.0), Color::BLACK)).collect()); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); + assert!(text(&out).matches("PU").count() <= MAX_POINTS / 5 + 3); +} + +/// The points a plain plot moves through. +fn moves(t: &str) -> usize { + t.split_terminator(';') + .filter(|i| i.starts_with("PU") || i.starts_with("PD")) + .map(|i| i[2..].split(',').filter(|v| !v.is_empty()).count() / 2) + .sum() +} + +#[test] +fn one_path_longer_than_the_budget_is_cut_at_it() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((1.0, 1.0)); + for i in 0..MAX_POINTS + 10 { + bp.line_to((if i % 2 == 0 { 2.0 } else { 1.0 }, 1.0 + (i % 50) as f64)); + } + vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); + let n = moves(&text(&out)); + assert!(n > 2 && n <= MAX_POINTS, "{n}"); +} + +/// A path whose flattening stops at the budget while its moves so far lie off the artboard: the +/// rest, over the artboard, is left out with a warning. +#[test] +fn a_path_cut_short_by_the_budget_warns() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((-50.0, 10.0)); + for i in 0..MAX_POINTS * 4 + 10 { + bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0)); + } + bp.line_to((50.0, 50.0)); + bp.line_to((60.0, 50.0)); + vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); +} + +/// A filled path cut short by the budget is not closed: the move from where it stopped back to its +/// start is not on its outline. Only its first move, along y = 50 off the artboard's left edge, +/// is drawn. +#[test] +fn a_filled_path_cut_short_stays_open() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((10.0, 50.0)); + bp.line_to((-40.0, 50.0)); + for i in 0..MAX_POINTS * 4 + 10 { + bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0)); + } + vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD0,706;PU;SP0;"); +} + +/// A blend's steps that its bent spine turns onto the region plotted are drawn, although its keys +/// and spine lie off it. +#[test] +fn blend_steps_a_spine_turns_onto_the_region_are_drawn() { + let d = doc_with(|d| { + let keys = + vec![stroked(d, line((-400.0, -200.0), (200.0, -200.0)), Color::BLACK), stroked(d, line((-560.0, -60.0), (40.0, -60.0)), Color::BLACK)]; + let mut spine = BezPath::new(); + spine.move_to((-100.0, -200.0)); + spine.quad_to((-100.0, -60.0), (-260.0, -60.0)); + let spec = vectorcraft_doc::BlendSpec { + spacing: vectorcraft_doc::BlendSpacing::Steps(20), + spine: Some(PathData::from_bezpath(&spine)), + orientation: vectorcraft_doc::BlendOrientation::AlignToPath, + ..Default::default() + }; + vec![Node::new(d.alloc_id(), NodeKind::Blend { children: keys.into_iter().map(Arc::new).collect(), spec: Box::new(spec) })] + }); + // The keys and spine lie above y = -55; the steps turned along the spine cross this area. + let region = Rect::new(-300.0, 0.0, -200.0, 50.0); + let t = text(&plot(&d, &HpglOptions { region, ..HpglOptions::default() })); + assert!(t.contains("PD"), "{t}"); +} + +/// A closed path that starts on the artboard, leaves it and comes back is one line, with no +/// pen lift where it started. +#[test] +fn a_closed_path_cut_at_the_artboard_is_one_line() { + let d = doc_with(|d| { + let mut bp = BezPath::new(); + bp.move_to((150.0, 10.0)); + bp.line_to((250.0, 10.0)); + bp.line_to((250.0, 50.0)); + bp.line_to((150.0, 50.0)); + bp.close_path(); + vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + let t = text(&plot(&d, &opts(&d))); + assert_eq!(t, "IN;PU;SP1;PU2822,706;PD2117,706,2117,1270,2822,1270;PU;SP0;"); +} + +/// A clipping path that paints is drawn once: where its fill paints (before the art it clips), +/// or where its stroke paints (after it). +#[test] +fn a_clipping_path_is_drawn_where_it_paints() { + let clip_doc = |fill: Paint, stroke: Paint| { + doc_with(|d| { + let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::basic(fill, stroke, 1.0)); + let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::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 red = Paint::solid(Color::rgb8(255, 0, 0)); + let filled = clip_doc(red.clone(), Paint::None); + assert_eq!(plot(&filled, &opts(&filled)).pens, [[255, 0, 0], [0, 0, 0]]); + let stroked = clip_doc(Paint::None, red.clone()); + assert_eq!(plot(&stroked, &opts(&stroked)).pens, [[0, 0, 0], [255, 0, 0]]); + let both = clip_doc(Paint::solid(Color::rgb8(0, 0, 255)), red); + let out = plot(&both, &opts(&both)); + assert_eq!(out.pens, [[0, 0, 0], [255, 0, 0]], "drawn once, in its stroke's color"); + // Two pen changes, a move to each of the two paths, and the last pen up. + assert_eq!(text(&out).matches("PU").count(), 5, "{}", text(&out)); + // A stroke at 0% opacity isn't drawn: the clipping path is drawn in its fill, below. + let faded = doc_with(|d| { + let mut clip = Node::path( + d.alloc_id(), + rect(20.0, 20.0, 40.0, 40.0), + Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0), + ); + for item in &mut clip.appearance.items { + if let AppearanceItem::Stroke(s) = item { + s.opacity = 0.0; + } + } + 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(&faded, &opts(&faded)).pens, [[0, 0, 255], [0, 0, 0]]); + // An open clipping path with a fill and a stroke is drawn closed, as other filled paths are. + 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 open = PathData::from_bezpath(&open); + let open_doc = doc_with(|d| { + let clip = Node::path(d.alloc_id(), open, Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0)); + 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] + }); + 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 symbol `art` and an instance of it at its place. +fn symbol_doc(art: Node, instance: impl FnOnce(&mut Node)) -> Document { + let mut d = doc_with(|d| { + let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); + instance(&mut n); + vec![n] + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art) }); + d +} + +/// Symbols nested more than 8 deep are left out with a warning. +#[test] +fn symbols_nested_too_deep_are_left_out_with_a_warning() { + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s9".into(), xf: Affine::IDENTITY })]); + let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s0".into(), art: Arc::new(art) }); + for i in 1..10 { + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("s{}", i - 1), xf: Affine::IDENTITY }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("s{i}"), art: Arc::new(inner) }); + } + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == TOO_DEEP), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP0;"); +} + +/// A symbol instance stained by its fill draws in the stained color. +#[test] +fn a_stained_symbol_instance_takes_its_stain() { + let mut d = Document::new(200.0, 100.0); + let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK); + let d = symbol_doc(art, |n| n.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 1.0).into()); + assert_eq!(plot(&d, &opts(&d)).pens, [[255, 0, 0]]); +} + +/// The geometry effects in a symbol's art apply: a line moved 30 pt right by a Transform effect. +#[test] +fn a_symbol_draws_its_art_with_its_effects() { + let mut d = Document::new(200.0, 100.0); + let mut art = stroked(&mut d, line((10.0, 10.0), (20.0, 10.0)), Color::BLACK); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 30.0}), + visible: true, + }); + let d = symbol_doc(art, |_| {}); + assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP1;PU564,1270;PD706,1270;PU;SP0;"); +} + +/// Art an effect moves onto the artboard from off it is drawn, for an instance on its own and +/// one in a group: the bounds that skip art off the artboard don't measure symbol effects. +#[test] +fn symbol_art_an_effect_moves_onto_the_artboard_is_drawn() { + let mut d = Document::new(200.0, 100.0); + let mut art = stroked(&mut d, line((-30.0, 10.0), (-20.0, 10.0)), Color::BLACK); + art.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 40.0}), + visible: true, + }); + let alone = symbol_doc(art.clone(), |_| {}); + assert_eq!(text(&plot(&alone, &opts(&alone))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;"); + let mut grouped = doc_with(|d| { + let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); + vec![Node::group(d.alloc_id(), vec![Arc::new(n)])] + }); + grouped.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art.clone()) }); + assert_eq!(text(&plot(&grouped, &opts(&grouped))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;"); + // Other art off the artboard is still skipped: a blend there doesn't spend the live art's + // limit (1,000 objects in tests) before the blend on the artboard. + let mut d = doc_with(|d| { + let off = + vec![stroked(d, line((-500.0, 10.0), (-500.0, 20.0)), Color::BLACK), stroked(d, line((-400.0, 10.0), (-400.0, 20.0)), Color::BLACK)]; + let on = vec![stroked(d, line((10.0, 10.0), (10.0, 20.0)), Color::BLACK), stroked(d, line((30.0, 10.0), (30.0, 20.0)), Color::BLACK)]; + vec![blend_of(d, off, 1_200), blend_of(d, on, 1)] + }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "unused".into(), art: Arc::new(art) }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out).matches("PD").count(), 3, "{}", text(&out)); +} + +/// A rectangle with `per_side` straight moves along each side. +fn fine_rect(x0: f64, y0: f64, x1: f64, y1: f64, per_side: u32) -> PathData { + let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]; + let mut bp = BezPath::new(); + bp.move_to(corners[0]); + for (k, a) in corners.iter().enumerate() { + let b = corners[(k + 1) % 4]; + for i in 1..=per_side { + let t = f64::from(i) / f64::from(per_side); + bp.line_to((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)); + } + } + bp.close_path(); + PathData::from_bezpath(&bp) +} + +/// Nested clipping masks of 6,000 points each pass the 10,000 points masks may hold together +/// in tests: the inner one is left out with a warning, and the line is cut by the outer one only. +#[test] +fn masks_past_their_point_budget_are_left_out() { + let d = doc_with(|d| { + let clip_group = |d: &mut Document, mask: PathData, art: Node| { + let mask = Node::path(d.alloc_id(), mask, Appearance::basic(Paint::None, Paint::None, 1.0)); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + group + }; + let across = Node::path(d.alloc_id(), line((0.0, 50.0), (200.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + let inner = clip_group(d, fine_rect(50.0, 30.0, 150.0, 70.0, 1500), across); + vec![clip_group(d, fine_rect(10.0, 10.0, 190.0, 90.0, 1500), inner)] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == MASK_POINTS), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD2681,706;PU;SP0;"); +} + +/// A line through the 500 teeth of a comb-shaped mask crosses its edges about 1,000 times, and +/// each piece between crossings is tested against all 2,002 edges: that spends the clipping +/// budget, so the line is drawn whole with a warning. +#[test] +fn a_mask_crossed_many_times_spends_the_clipping_budget() { + let d = doc_with(|d| { + let mut pts = vec![]; + for i in 0..500 { + let x = f64::from(i) * 0.4; + pts.extend([(x, 20.0), (x + 0.2, 20.0), (x + 0.2, 50.0), (x + 0.4, 50.0)]); + } + pts.extend([(200.0, 80.0), (0.0, 80.0)]); + let mut bp = BezPath::new(); + bp.move_to(pts[0]); + pts[1..].iter().for_each(|&p| bp.line_to(p)); + bp.close_path(); + let comb = Node::path(d.alloc_id(), PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::None, 1.0)); + let clipped = stroked(d, line((0.5, 35.0), (199.5, 35.0)), Color::BLACK); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(comb), Arc::new(clipped)]); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + vec![group] + }); + let out = plot(&d, &opts(&d)); + assert!(out.warnings.iter().any(|w| w == CLIP_WORK), "{:?}", out.warnings); + assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out)); +} + +#[test] +fn more_colors_than_pens_share_the_nearest_pen() { + let d = doc_with(|d| (0..300).map(|i| stroked(d, rect(1.0, 1.0, 5.0, 5.0), Color::rgb8((i % 256) as u8, (i / 256) as u8, 7))).collect()); + let out = plot(&d, &opts(&d)); + assert_eq!(out.pens.len(), MAX_PENS); + assert!(out.warnings.iter().any(|w| w == MANY_COLORS), "{:?}", out.warnings); +} + +#[test] +fn sorting_draws_the_nearest_path_next() { + let d = doc_with(|d| { + vec![ + stroked(d, line((150.0, 90.0), (150.0, 80.0)), Color::BLACK), + stroked(d, line((10.0, 80.0), (10.0, 90.0)), Color::BLACK), + stroked(d, line((80.0, 90.0), (80.0, 80.0)), Color::BLACK), + ] + }); + let unsorted = text(&plot(&d, &opts(&d))); + assert_eq!(unsorted, "IN;PU;SP1;PU2117,141;PD2117,282;PU141,282;PD141,141;PU1129,141;PD1129,282;PU;SP0;"); + // From the origin: the left line drawn backwards from its lower end, then the middle and the + // right one from the end the pen is nearest. + let sorted = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })); + assert_eq!(sorted, "IN;PU;SP1;PU141,141;PD141,282;PU1129,282;PD1129,141;PU2117,141;PD2117,282;PU;SP0;"); +} + +/// A closed path is as near as its nearest point, and starts there. +#[test] +fn sorting_measures_a_closed_path_by_its_nearest_point() { + let d = doc_with(|d| vec![stroked(d, rect(40.0, 60.0, 50.0, 70.0), Color::BLACK), stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK)]); + let t = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })); + // The large square's bottom-left corner is nearer the origin than any point of the small one. + assert_eq!(t, "IN;PU;SP1;PU141,141;PD141,1270,2681,1270,2681,141,141,141;PU564,423;PD564,564,706,564,706,423,564,423;PU;SP0;"); +} + +#[test] +fn sorting_stops_at_its_budget_and_keeps_every_path() { + let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect()); + let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) }); + assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings); + assert_eq!(text(&out).matches("PD").count(), 600); +} + +/// `x` points in plotter units. +fn plu(x: f64) -> i64 { + (x * 1016.0 / 72.0).round() as i64 +} + +/// The left edge of each path a plain HP-GL plot draws, in drawing order (plotter units). +fn lefts(out: &HpglOutput) -> Vec { + let mut lefts: Vec = vec![]; + for i in text(out).split_terminator(';') { + let xs = i.get(2..).unwrap_or_default().split(',').step_by(2).filter_map(|x| x.parse::().ok()); + match i.get(..2) { + Some("PU") => lefts.extend(xs.min()), + Some("PD") => { + if let (Some(l), Some(x)) = (lefts.last_mut(), xs.min()) { + *l = (*l).min(x); + } + } + _ => {} + } + } + lefts +} + +/// A letter O made as one path: its outline, then its counter. +#[test] +fn inside_first_cuts_a_letters_counter_before_its_outline() { + let d = doc_with(|d| { + let mut bp = rect(10.0, 10.0, 60.0, 90.0).to_bezpath(); + bp.extend(shapes::ellipse(Rect::new(25.0, 30.0, 45.0, 70.0)).to_bezpath()); + vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)] + }); + assert_eq!(lefts(&plot(&d, &opts(&d))), [plu(10.0), plu(25.0)]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [plu(25.0), plu(10.0)]); +} + +/// A part drawn before its two holes: the holes are cut first, nearest first with `sort`. +#[test] +fn a_part_with_two_holes_is_cut_holes_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(160.0, 40.0, 180.0, 60.0)), Color::BLACK), + stroked(d, shapes::ellipse(Rect::new(20.0, 40.0, 40.0, 60.0)), Color::BLACK), + ] + }); + let (part, right, left) = (plu(10.0), plu(160.0), plu(20.0)); + assert_eq!(lefts(&plot(&d, &opts(&d))), [part, right, left]); + assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [right, left, part]); + // Sorting alone starts at the part's corner, nearest the origin; inside first, the nearest + // hole comes first, then the other, then the part. + assert_eq!(lefts(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })), [part, left, right]); + let both = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&both), [left, right, part]); + assert!(both.warnings.is_empty(), "{:?}", both.warnings); +} + +/// Rings inside rings are cut innermost first, and a ring with nothing inside it keeps its place +/// in paint order. +#[test] +fn nested_rings_are_cut_innermost_first() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(120.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(10.0, 10.0, 100.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 90.0, 80.0), Color::BLACK), + stroked(d, rect(30.0, 30.0, 80.0, 70.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(120.0), plu(30.0), plu(20.0), plu(10.0)]); +} + +/// Each pen's paths are ordered among themselves: a red square inside a black part is drawn with +/// the red pen, after the black part. +#[test] +fn inside_first_orders_each_pens_paths() { + let d = doc_with(|d| { + vec![ + stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK), + stroked(d, rect(20.0, 20.0, 40.0, 40.0), Color::rgb8(255, 0, 0)), + stroked(d, rect(50.0, 20.0, 70.0, 40.0), Color::BLACK), + ] + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(lefts(&out), [plu(50.0), plu(10.0), plu(20.0)]); +} + +/// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in +/// paint order, with a warning. +#[test] +fn inside_first_past_its_budget_draws_in_paint_order() { + let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED]); + let l = lefts(&out); + assert_eq!(l.len(), 100); + assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); + // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(lefts(&out), l); +} + +/// Sorting 600 lines inside two rings runs out of work: every path is drawn, the inner ring and +/// then the outer one last. +#[test] +fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 150.0, 30.0), Color::BLACK), stroked(d, rect(0.7, 7.0, 140.0, 25.0), Color::BLACK)]; + v.extend((0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); + assert_eq!(out.warnings, [UNSORTED_INSIDE]); + let l = lefts(&out); + assert_eq!(l.len(), 602); + assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); +} + +/// Without `sort`, 1,000 lines inside a ring are drawn in paint order and the ring after them, +/// within the budget. +#[test] +fn inside_first_alone_keeps_paint_order_for_many_paths() { + let d = doc_with(|d| { + let mut v = vec![stroked(d, rect(0.5, 5.0, 199.0, 30.0), Color::BLACK)]; + v.extend((0..1000).map(|i| stroked(d, line((1.0 + 0.19 * f64::from(i), 10.0), (1.0 + 0.19 * f64::from(i), 20.0)), Color::BLACK))); + v + }); + let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let l = lefts(&out); + assert_eq!(l.len(), 1001); + assert_eq!(l.last(), Some(&plu(0.5))); + assert!(l.get(..1000).is_some_and(|l| l.windows(2).all(|w| w[0] < w[1])), "{l:?}"); +} + +#[test] +fn art_wholly_past_the_plotter_range_is_cut_off_with_a_warning() { + let d = doc_with(|d| vec![stroked(d, line((7e8, 50.0), (7e8 + 10.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() }); + assert!(out.warnings.iter().any(|w| w == OUT_OF_RANGE), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP0;"); +} + +/// The reference guide's example of an encoded number: 10,525 is written as the bytes 121, 71 +/// and 196 (https://support.hp.com/sg-en/document/c01121263). +#[test] +fn encoded_numbers_follow_the_reference_guide() { + let mut out = vec![]; + pe_number(&mut out, 10_525); + assert_eq!(out, [121, 71, 196]); + out.clear(); + pe_number(&mut out, -1); + pe_number(&mut out, 0); + // The sign in the low bit: -1 is 3. + assert_eq!(out, [194, 191]); +} + +#[test] +fn hpgl2_writes_the_palette_and_encoded_polylines() { + let out = plot(&three(), &HpglOptions { version: Version::Hpgl2, ..opts(&three()) }); + let b = &out.bytes; + assert!(b.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;PC3,0,0,255;PU;SP1;PE<="), "{:?}", String::from_utf8_lossy(b)); + assert!(b.ends_with(b";PU;SP0;")); + assert_eq!(b.windows(4).filter(|w| w == b";PE<").count(), 3, "one encoded polyline per pen"); + // Encoded moves take fewer bytes than the same moves written as text. + let plain = plot(&three(), &opts(&three())); + assert!(b.len() < plain.bytes.len(), "{} against {}", b.len(), plain.bytes.len()); +} + +/// A move longer than 8,388,607 units is written absolute (`=`), so every number stays in the +/// range: here from about −5,000,000 to +5,000,000 units around a centered origin. +#[test] +fn hpgl2_writes_a_move_past_the_range_absolute() { + let d = doc_with(|d| { + vec![ + stroked(d, line((-354_000.0, 50.0), (-353_990.0, 50.0)), Color::BLACK), + stroked(d, line((353_990.0, 50.0), (354_000.0, 50.0)), Color::BLACK), + ] + }); + let o = HpglOptions { + version: Version::Hpgl2, + origin: Origin::Center, + region: Rect::new(-400_000.0, 0.0, 400_000.0, 100.0), + ..HpglOptions::default() + }; + let out = plot(&d, &o); + assert_eq!(out.bytes.iter().filter(|b| **b == b'=').count(), 2, "{:?}", String::from_utf8_lossy(&out.bytes)); + let back = super::super::import(&out.bytes).unwrap(); + let mut xs = vec![]; + back.document.walk(|n| { + if let Some(p) = n.path_data() { + xs.extend(p.to_bezpath().elements().iter().filter_map(|e| if let PathEl::MoveTo(p) = e { Some(p.x) } else { None })); + } + }); + assert_eq!(xs.len(), 2); + assert!((xs[1] - xs[0] - 707_990.0).abs() < 0.2, "{xs:?}"); +} + +/// The points of every path `doc` holds, in paint order. +fn polylines(doc: &Document) -> Vec<(Vec, Option)> { + let mut out = vec![]; + doc.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + let bp = path.to_bezpath(); + let pts = bp.elements().iter().filter_map(|e| match e { + PathEl::MoveTo(p) | PathEl::LineTo(p) => Some(*p), + _ => None, + }); + let stroke = n.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.paint.clone()) } else { None }); + out.push((pts.collect(), stroke)); + } + }); + out +} + +/// Each version's plot read back: the same lines, within the tolerance and half a plotter unit, +/// and with HP-GL/2 the same colors. +#[test] +fn plots_read_back_as_the_same_lines() { + let d = three(); + for version in Version::ALL { + let out = plot(&d, &HpglOptions { version, ..opts(&d) }); + let back = super::super::import(&out.bytes).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + let lines = polylines(&back.document); + assert_eq!(lines.len(), 3, "{version:?}"); + // The reader's artboard is round what the art paints: line the two up by the square. + let shift = lines[0].0[0] - Point::new(10.0, 10.0); + // Two roundings to whole plotter units (1/1016 in) apart at most. + let near = |p: Point, q: Point| (p - shift).distance(q) <= 0.11; + let square = [(10.0, 10.0), (60.0, 10.0), (60.0, 40.0), (10.0, 40.0), (10.0, 10.0)].map(Point::from); + assert!(lines[0].0.iter().zip(square).all(|(p, q)| near(*p, q)) && lines[0].0.len() == 5, "{:?}", lines[0].0); + assert!(near(lines[1].0[0], Point::new(70.0, 20.0)) && near(lines[1].0[1], Point::new(150.0, 80.0)), "{:?}", lines[1].0); + // The ellipse's every point lies on it, within the tolerance and the rounding. + let ellipse = kurbo::Ellipse::new((100.0, 50.0), (30.0, 20.0), 0.0); + for p in &lines[2].0 { + let q = p.to_vec2() - shift - kurbo::Vec2::new(100.0, 50.0); + let r = (q.x / 30.0).hypot(q.y / 20.0); + assert!((r - 1.0).abs() * 20.0 <= DEFAULT_TOLERANCE + 0.1, "{p:?} off the ellipse"); + } + assert!(lines[2].0.len() > 16 && ellipse.area() > 0.0); + if version == Version::Hpgl2 { + let colors: Vec> = lines.iter().map(|l| l.1.clone()).collect(); + assert_eq!( + colors, + [Paint::solid(Color::rgb8(0, 0, 0)), Paint::solid(Color::rgb8(255, 0, 0)), Paint::solid(Color::rgb8(0, 0, 255))].map(Some) + ); + } + } +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 8125d02c93..0ef28a4aaa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,5 +1,5 @@ //! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an -//! HP-GL/2 plot file reader. +//! HP-GL/2 plot file reader and writer. //! //! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF //! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths @@ -12,13 +12,13 @@ //! clipping) is approximated or left out, and each such loss comes back as a warning. //! //! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. -//! [`hpgl::import`] reads an HP-GL plotter file: see [`hpgl`]. +//! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`]. //! //! - `aci`: the 256-colour index palette and the nearest index of a colour. //! - `writer`: group codes, handles and the file's sections, tables and objects. //! - `scene`: the document walk that turns objects into entities. //! - `import`: the reader, entities to art, blocks to symbols. -//! - `hpgl`: the HP-GL reader, the pen's moves to paths. +//! - `hpgl`: the HP-GL reader, the pen's moves to paths, and the writer, paths to pen moves. mod aci; pub mod hpgl; diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index 5b5fd3653a..5fc952a061 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -73,6 +73,7 @@ pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[ lib!("sky-gradients", "Sky Gradients", sky_gradients), lib!("neutral-gradients", "Neutral Gradients", neutral_gradients), lib!("organic-fades", "Organic Fades", organic_fades), + lib!("holographic", "Holographic", holographic), lib!("duotone-gradients", "Duotone Gradients", duotone_gradients), lib!("radial-glows", "Radial Glows", radial_glows), ]; @@ -531,6 +532,62 @@ fn organic_fades() -> (Vec, Vec) { (s, vec![]) } +/// The stops of a [`holographic`] look: offset, opacity and midpoint toward the next stop. +type Bands = [(f32, f32, f32)]; + +/// Foil: six opaque linear bands, a narrow bright one among them, with off-center midpoints. +const FOIL: &Bands = &[(0.0, 1.0, 0.37), (0.13, 1.0, 0.64), (0.34, 1.0, 0.28), (0.48, 1.0, 0.68), (0.71, 1.0, 0.42), (1.0, 1.0, 0.5)]; + +/// Glow: five radial rings from a bright center out to a clear edge. +const GLOW: &Bands = &[(0.0, 0.95, 0.4), (0.12, 1.0, 0.58), (0.31, 0.72, 0.34), (0.58, 0.3, 0.62), (1.0, 0.0, 0.5)]; + +/// A [`holographic`] look: name, kind, stops and the OKLCH color of each stop in order. +type Look = (&'static str, GradientKind, &'static Bands, Anchors); + +/// Iridescent looks mixed perceptually: linear foils of narrow pastel and pearl bands, and radial +/// glows that fade out to a clear edge. Each color is one stop of [`FOIL`] or [`GLOW`] in order. +fn holographic() -> (Vec, Vec) { + use GradientKind::{Linear, Radial}; + // Pale pearl, pastel and deep-violet OKLCH `(l, c, h°)` anchors. + const PEARL: (f64, f64, f64) = (0.97, 0.01, 90.0); + const SILVER: (f64, f64, f64) = (0.86, 0.015, 255.0); + const BLUSH: (f64, f64, f64) = (0.82, 0.055, 340.0); + const VIOLET: (f64, f64, f64) = (0.72, 0.1, 310.0); + const BLUE: (f64, f64, f64) = (0.73, 0.09, 255.0); + const CYAN: (f64, f64, f64) = (0.79, 0.09, 210.0); + const MINT: (f64, f64, f64) = (0.82, 0.08, 165.0); + const LIME: (f64, f64, f64) = (0.86, 0.085, 125.0); + const GOLD: (f64, f64, f64) = (0.89, 0.08, 85.0); + const AMBER: (f64, f64, f64) = (0.82, 0.1, 55.0); + const CORAL: (f64, f64, f64) = (0.78, 0.09, 25.0); + const DEEP_VIOLET: (f64, f64, f64) = (0.42, 0.04, 275.0); + let looks: [Look; 14] = [ + ("Prism Veil", Linear, FOIL, &[SILVER, BLUSH, PEARL, CYAN, VIOLET, SILVER]), + ("Opal Ribbon", Linear, FOIL, &[PEARL, MINT, BLUSH, PEARL, BLUE, SILVER]), + ("Pearl Flash", Linear, FOIL, &[SILVER, VIOLET, PEARL, GOLD, CYAN, PEARL]), + ("Aurora Foil", Linear, FOIL, &[BLUE, MINT, PEARL, BLUSH, CYAN, SILVER]), + ("Iris Sheen", Linear, FOIL, &[BLUSH, VIOLET, PEARL, CYAN, MINT, SILVER]), + ("Frost Spectrum", Linear, FOIL, &[CYAN, BLUE, PEARL, MINT, BLUSH, SILVER]), + ("Silver Tide", Linear, FOIL, &[SILVER, CYAN, PEARL, VIOLET, BLUE, SILVER]), + ("Rose Quartz Gleam", Radial, GLOW, &[PEARL, BLUSH, CORAL, VIOLET, BLUSH]), + ("Moonstone Shift", Radial, GLOW, &[PEARL, CYAN, MINT, BLUE, SILVER]), + ("Violet Edge", Radial, GLOW, &[PEARL, VIOLET, BLUSH, BLUE, DEEP_VIOLET]), + ("Mint Halo", Radial, GLOW, &[PEARL, MINT, CYAN, LIME, MINT]), + ("Amber Corona", Radial, GLOW, &[PEARL, GOLD, AMBER, CORAL, AMBER]), + ("Polar Glint", Radial, GLOW, &[PEARL, CYAN, BLUE, VIOLET, BLUE]), + ("Pastel Orbit", Radial, GLOW, &[PEARL, BLUSH, MINT, CYAN, VIOLET]), + ]; + let s = looks + .iter() + .map(|&(name, kind, bands, colors)| { + let stops: Vec<(f32, Color, f32, f32)> = + bands.iter().zip(colors).map(|(&(at, opacity, mid), &(l, c, h))| (at, oklch(l, c, h), opacity, mid)).collect(); + gradient_with(name, kind, GradientMethod::Perceptual, &stops) + }) + .collect(); + (s, vec![]) +} + /// The sequential scales of [`perceptual`]: name and anchors of rising lightness. const SEQUENTIAL: [(&str, Anchors); 5] = [ ("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]), @@ -708,9 +765,63 @@ mod tests { } } + #[test] + fn holographic_holds_seven_opaque_foils_and_seven_glows_to_a_clear_edge() { + let lib = builtin_library("holographic").unwrap(); + let order: Vec<&str> = GRADIENT_LIBRARIES.iter().map(|b| b.name).collect(); + assert_eq!(order.iter().position(|n| *n == "Holographic"), Some(4), "after Organic Fades: {order:?}"); + assert!(lib.groups.is_empty()); + assert_eq!(lib.len(), 14); + // No name another built-in gradient has, in any case. + let mut others = std::collections::HashSet::new(); + for other in builtins().filter(|b| b.id != "holographic").map(|b| builtin_library(b.id).unwrap()) { + others.extend(other.iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| s.name.to_lowercase())); + } + assert!(lib.iter().all(|s| !others.contains(&s.name.to_lowercase())), "a name another library has"); + let (mut foils, mut glows) = (0, 0); + for s in lib.iter() { + let Paint::Gradient(g) = &s.paint else { panic!("{} is a gradient", s.name) }; + let (g, n) = (&g.gradient, &s.name); + assert_eq!(g.method, GradientMethod::Perceptual, "{n}"); + let bands = match &g.kind { + GradientKind::Linear => { + foils += 1; + FOIL + } + GradientKind::Radial => { + glows += 1; + GLOW + } + kind => panic!("{n}: {kind:?}"), + }; + // Every stop takes its band's offset, opacity and midpoint, and none is dropped. + let shape: Vec<(f32, f32, f32)> = g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect(); + assert_eq!(shape, bands, "{n}"); + assert!(g.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{n}"); + } + assert_eq!((foils, glows), (7, 7)); + // Foils are opaque; a glow is clear at the edge. + assert!(FOIL.iter().all(|&(_, opacity, _)| opacity == 1.0)); + assert_eq!(GLOW.last().map(|b| b.1), Some(0.0)); + // Every segment's midpoint is off center and within the Gradient panel's 13 to 87%. + for bands in [FOIL, GLOW] { + assert!(bands[..bands.len() - 1].iter().all(|&(_, _, m)| (m - 0.5).abs() > 0.02 && (0.13..=0.87).contains(&m))); + assert!(bands.windows(2).all(|w| w[0].0 < w[1].0) && bands[0].0 == 0.0 && bands[bands.len() - 1].0 == 1.0); + } + // Sampled colors: Prism Veil's bright band is the pearl anchor, and Violet Edge ends in deep violet. + let stop = |name: &str, i: usize| match &lib.swatch(name).unwrap().paint { + Paint::Gradient(g) => g.gradient.stops[i].color, + _ => panic!("{name} is a gradient"), + }; + assert_eq!(stop("Prism Veil", 2), oklch(0.97, 0.01, 90.0)); + assert_eq!(stop("Violet Edge", 4), oklch(0.42, 0.04, 275.0)); + let [l, ..] = to_oklab(&stop("Violet Edge", 4)); + assert!(l < 0.5 && to_oklab(&stop("Prism Veil", 2))[0] > 0.95, "{l}"); + } + #[test] fn the_other_libraries_keep_classic_mixing_and_centered_midpoints() { - for b in builtins().filter(|b| b.id != "organic-fades") { + for b in builtins().filter(|b| !["organic-fades", "holographic"].contains(&b.id)) { for s in builtin_library(b.id).unwrap().iter() { if let Paint::Gradient(g) = &s.paint { assert_eq!(g.gradient.method, GradientMethod::Classic, "{}", s.name); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 05968ced7e..4e51777060 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1051,7 +1051,8 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { /// - a pinned spine (`key_anchors`): the stored spine's first subpath with each key's anchor (and /// its handles) moved onto the key's centre, so moving a key moves its end of the spine; /// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread -/// evenly along it by arc length). +/// evenly along it by arc length) while the keys sit there; once one is moved off, pinned as +/// above at the anchors where they were spread. /// /// `None` for a blend made without a spine ([`BlendSpec::no_spine`]). pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { @@ -1077,7 +1078,24 @@ fn layout_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Optio return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect()))); }; let mut first = stored.subpaths.first()?.clone(); - let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { return Some((PathData::single(first), None)) }; + let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { + let mut path = PathData::single(first); + // A key moved off where Replace Spine spread it takes its point of the spine along, as + // on a pinned spine; otherwise the steps would put it back on the spine (#6809). + if Spine::new(&path).is_none_or(|sp| keys_spread_along(keys, &sp)) { + return Some((path, None)); + } + let Some(mut ka) = spread_anchors(&mut path, keys.len()) else { return Some((path, None)) }; + let mut first = path.subpaths.into_iter().next()?; + open_at_start(&mut first, &mut ka); + for (k, &ai) in keys.iter().zip(&ka) { + if let Some(a) = first.anchors.get_mut(ai) { + let d = center_of(k) - a.p; + a.translate(d); + } + } + return Some((PathData::single(first), Some(ka))); + }; for (k, &ai) in keys.iter().zip(&ka) { let c = center_of(k); if let Some(a) = first.anchors.get_mut(ai) { @@ -1091,21 +1109,62 @@ fn layout_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Optio /// [`blend_spine`] with every key on an anchor: a spine from Replace Spine gets anchors where its /// keys sit (by arc length), so editing it keeps them in place. pub fn pin_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Vec)> { - let (mut path, anchors) = blend_spine(keys, spec)?; - if let Some(a) = anchors { - return Some((path, a)); + // A spine from Replace Spine is pinned as stored, even with a key moved off it (where + // [`blend_spine`] moves that key's point along): the layout is the same. + if keys.len() >= 2 + && let Some(first) = stored_spine(spec).and_then(|s| s.subpaths.first()) + && pinned(spec, keys.len(), first.anchors.len()).is_none() + { + let mut path = PathData::single(first.clone()); + let mut out = spread_anchors(&mut path, keys.len())?; + // A closed spine's end keys get points of their own, so that once pinned each still + // moves only its own end. + let mut sp = path.subpaths.into_iter().next()?; + open_at_start(&mut sp, &mut out); + return Some((PathData::single(sp), out)); } + let (path, anchors) = blend_spine(keys, spec)?; + Some((path, anchors?)) +} + +/// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length +/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)? +fn keys_spread_along(keys: &[Arc], spine: &Spine) -> bool { let k = keys.len(); - let total = Spine::new(&path)?.length(); + keys.iter().enumerate().all(|(i, key)| { + let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }; + center_of(key).distance(spine.at(f).0) < 0.01 + }) +} + +/// Open closed spine `sp` at its first anchor when the last key sits there with the first (as +/// [`spread_anchors`] puts them): a copy of that anchor at the end takes the closing segment and +/// the last key, so the two end keys move their ends of the spine apart. +fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) { + let Some(&start) = sp.anchors.first() else { return }; + if !sp.closed || ka.len() < 2 { + return; + } + if let Some(last) = ka.last_mut().filter(|a| **a == 0) { + *last = sp.anchors.len(); + sp.anchors.push(start); + sp.closed = false; + } +} + +/// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit +/// on, splitting segments where none is. +fn spread_anchors(path: &mut PathData, k: usize) -> Option> { + let total = Spine::new(path)?.length(); let closed = path.subpaths.first().is_some_and(|s| s.closed); let mut out = vec![0usize]; for i in 1..k.saturating_sub(1) { let s = total * i as f64 / (k - 1) as f64; - out.push(anchor_at_length(&mut path, s)?); + out.push(anchor_at_length(path, s)?); } let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); out.push(last); - Some((path, out)) + Some(out) } /// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 2c0a6984d0..3eac4202b6 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -450,7 +450,7 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() { envelope, NodeKind::Envelope { content, - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }), fidelity: 100.0, editing: false, options: Default::default(), @@ -1192,7 +1192,7 @@ fn objects_at_edit_contents_uses_general_order() { d.alloc_id(), NodeKind::Envelope { content: vec![inner(a), inner(b)], - kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }), + kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }), fidelity: 100.0, editing: true, options: Default::default(), diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 41a684abde..2c5759f202 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -267,7 +267,7 @@ pub struct Document { /// they were copied from instead of the current layer. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub paste_remembers_layers: bool, - /// File → File Info (the title is [`Document::title`]). + /// File → File Info (its title is [`Document::info_title`]). #[serde(default, skip_serializing_if = "skip::is_default")] pub metadata: DocMetadata, /// Effect → Document Raster Effects Settings besides the resolution diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 7233f88a50..d1eb20ed21 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -60,12 +60,12 @@ pub use envelope::{ pub(crate) use expand::max_outset; pub use expand::{ Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with, - expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live, + expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, mesh_warp_cost, mesh_warp_image, subtree_has_live, }; #[cfg(test)] use mesh::grid_eval; pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds}; -pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point}; +pub use warp::{WARP_NET, WarpNet, WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, uses_net, warp_point, warp_version}; #[cfg(test)] mod tests { @@ -132,6 +132,200 @@ mod tests { let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI)); assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12); } + + /// Each warp style's Bézier net, at points worked out by a separate implementation of the same + /// nets: (style, bend %, horizontal and vertical distortion %, vertical, box w × h, (u, v)) → + /// the point in the box (origin at its top left, y down). + #[test] + fn warp_nets_bend_each_style_as_its_own_patch() { + #[rustfmt::skip] + let samples = [ + ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -31.1605665, 30.1243045), + ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 170.241998, 58.8743443), + ("arc", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 50.6087118, 47.6275948), + ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 18.8784581, 35.5028749), + ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.486168, 119.348666), + ("arcLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 38.1073767, 36.0911629), + ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 17.3830689, 17.1599587), + ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.498463, 86.7390371), + ("arcUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 46.1081993, 36.0252855), + ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 11.6570838), + ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 57.3903712), + ("arch", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 49.2508226, 36.2064484), + ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 22.6628335), + ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 116.087703), + ("bulge", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.965576, 36.2064484), + ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 10.6489723, 25.9914004), + ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 208.565464, 99.4817585), + ("shellLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 41.043987, 36.1302767), + ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -21.8346746, 41.6042708), + ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.791499, 90.3680631), + ("shellUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.9282896, 36.1475509), + ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 55.92), + ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 55.44), + ("flag", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 58.098, 35.91), + ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 40.8864), + ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 83.7792), + ("wave", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.33, 35.91), + ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 19.632), + ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 62.352), + ("fish", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 44.994, 35.91), + ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 146.64), + ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 102.48), + ("rise", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 118.686, 35.91), + ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 9.1136, 27.2784), + ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.2208, 94.1472), + ("fisheye", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 47.514, 35.7048), + ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -0.16, 27.84), + ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.48, 97.68), + ("inflate", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.54), + ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 40.16, 27.84), + ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 153.52, 97.68), + ("squeeze", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.0), + ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 32.2472, 27.9588), + ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 149.1136, 89.2224), + ("twist", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.56325, 33.8769), + ]; + for (style, bend, dh, dv, vertical, w, h, u, v, x, y) in samples { + let Some(st) = WarpStyle::from_id(style) else { panic!("{style}") }; + let p = WarpNet::new(st, bend / 100.0, dh / 100.0, dv / 100.0, w, h, vertical).at(u, v); + assert!(p.distance(Point::new(x, y)) < 1e-6, "{style} {bend} {dh} {dv} {vertical} ({u}, {v}): {p:?} vs ({x}, {y})"); + } + } + + /// Arc's edges are concentric circles: at 100 % on a 200 × 100 box the bottom edge is the half + /// circle through the bottom corners about the bottom's middle, and the top edge the one 100 + /// further out. One cubic for a half circle strays up to 1.84 % of the radius from the circle. + #[test] + fn warp_net_arc_edges_are_concentric_circles() { + let net = WarpNet::new(WarpStyle::Arc, 1.0, 0.0, 0.0, 200.0, 100.0, false); + let center = Point::new(100.0, 100.0); + for i in 0..=20 { + let u = f64::from(i) / 20.0; + for (v, r) in [(0.0, 200.0), (1.0, 100.0)] { + let d = net.at(u, v).distance(center); + assert!((d - r).abs() < 0.0184 * r, "u {u}, v {v}: {d} from the center, not {r}"); + // Its ends and middle lie on the circle. + if i % 10 == 0 { + assert!((d - r).abs() < 1e-9, "u {u}, v {v}: {d} from the center, not {r}"); + } + } + } + // Bend 0 leaves the box as it is. + for st in [WarpStyle::Arc, WarpStyle::ShellLower, WarpStyle::Bulge, WarpStyle::Twist] { + let flat = WarpNet::new(st, 0.0, 0.0, 0.0, 200.0, 100.0, false); + assert!(flat.at(0.3, 0.7).distance(Point::new(60.0, 70.0)) < 1e-9, "{st:?}"); + } + } + + /// The horizontal distortion scales each column of the net about its middle (narrowing the left + /// side for a positive value), then the vertical one each row (narrowing the top). + #[test] + fn warp_net_distortion_scales_the_columns_then_the_rows() { + let corners = |dh: f64, dv: f64| { + let net = WarpNet::new(WarpStyle::Arc, 0.0, dh, dv, 200.0, 100.0, false); + [net.at(0.0, 0.0), net.at(1.0, 0.0), net.at(0.0, 1.0), net.at(1.0, 1.0)] + }; + let near = |got: [Point; 4], want: [(f64, f64); 4]| got.iter().zip(want).all(|(p, (x, y))| p.distance(Point::new(x, y)) < 1e-9); + assert!(near(corners(0.5, 0.0), [(0.0, 25.0), (200.0, -25.0), (0.0, 75.0), (200.0, 125.0)]), "{:?}", corners(0.5, 0.0)); + assert!(near(corners(0.0, 0.5), [(50.0, 0.0), (150.0, 0.0), (-50.0, 100.0), (250.0, 100.0)]), "{:?}", corners(0.0, 0.5)); + // Both coordinates scale: after the columns the top row runs from (0, 15) to (200, -15), and + // it then scales by 1.4 about its middle, (100, 0). + assert!(near(corners(0.3, -0.4), [(-40.0, 21.0), (240.0, -21.0), (40.0, 91.0), (160.0, 109.0)]), "{:?}", corners(0.3, -0.4)); + } + + /// Numbers no warp can take still give a finite net: a bend or distortion that isn't a number + /// counts as 0, and out of range as its limit; a size that isn't a number as 0. + #[test] + fn warp_net_takes_any_numbers() { + for st in [WarpStyle::Arc, WarpStyle::ShellUpper, WarpStyle::Wave, WarpStyle::Inflate, WarpStyle::Twist] { + for (b, dh, w, h) in + [(f64::NAN, 0.2, 10.0, 10.0), (5.0, f64::INFINITY, 10.0, 0.0), (-1e300, -3.0, f64::NAN, 1e-300), (1e-300, 0.0, 0.0, 0.0)] + { + for vertical in [false, true] { + let p = WarpNet::new(st, b, dh, dh, w, h, vertical).at(0.3, 0.6); + assert!(p.x.is_finite() && p.y.is_finite(), "{st:?} {b} {dh} {w} {h}: {p:?}"); + } + } + } + let clamped = WarpNet::new(WarpStyle::Flag, 7.0, 0.0, 0.0, 200.0, 100.0, false); + assert_eq!(clamped, WarpNet::new(WarpStyle::Flag, 1.0, 0.0, 0.0, 200.0, 100.0, false)); + } + + /// A box near the largest finite size still warps: Flag on a 1e308 × 100 box moves points only + /// up and down, as on a 1 × 1e-306 one scaled up, and on a 1e308 × 1e-300 box keeps the box's + /// height. Fisheye at 100 % on a 1.7e308 square, whose net goes past the largest finite number, + /// gives finite points inside the box and past it, and the unit square's points scaled up where + /// those are finite. Fisheye moves y by the height alone, so on boxes of the largest finite + /// width and a height of 1e-300 or 100 every y is the unit square's times the height, also + /// where x goes past the largest finite number. + #[test] + fn warp_net_bends_a_huge_box_without_overflowing() { + let w = 1e308; + let flag = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 100.0, false); + let small = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, 1.0, 100.0 / w, false); + for (u, v) in [(0.0, 0.0), (0.3, 0.6), (0.9, 1.0), (1.0, 0.5)] { + let (p, q) = (flag.at(u, v), small.at(u, v)); + assert!(p.is_finite(), "({u}, {v}): {p:?}"); + assert!((p.x - u * w).abs() <= 1e-12 * w, "({u}, {v}): {p:?}"); + assert!((p.y - q.y * w).abs() <= 1e-9 * p.y.abs().max(1.0), "({u}, {v}): {p:?} against {q:?}"); + } + let thin = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 1e-300, false); + assert_eq!(thin.at(0.0, 1.0), Point::new(0.0, 1e-300)); + let fisheye = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.7e308, 1.7e308, false); + for (u, v) in [(0.1, 0.1), (0.8, 0.3), (1.5, 0.5), (3.0, 3.0)] { + let p = fisheye.at(u, v); + assert!(p.is_finite(), "({u}, {v}): {p:?}"); + } + let unit = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.0, 1.0, false); + let near = |a: f64, b: f64| (a - b).abs() <= 1e-12 * b.abs().max(f64::MIN_POSITIVE); + let (p, q) = (fisheye.at(0.1, 0.1), unit.at(0.1, 0.1)); + assert!(near(p.x, q.x * 1.7e308) && near(p.y, q.y * 1.7e308), "{p:?} against {q:?}"); + // The bottom of a box with a subnormal height stays exactly at that height: the net is + // built on the box halved, and the height's lowest bit but one is set. + let low = f64::MIN_POSITIVE / 4.0 + f64::from_bits(2); + let sub = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, low, false).at(0.0, 1.0); + assert_eq!(sub.y, low, "{sub:?}"); + for h in [1e-300, 100.0] { + let tall = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, h, false); + for (u, v) in [(0.1, 0.1), (0.9, 0.4), (0.5, 0.8)] { + let (p, q) = (tall.at(u, v), unit.at(u, v)); + assert!(p.is_finite() && near(p.y, q.y * h), "h {h}, ({u}, {v}): {p:?} against {q:?}"); + } + } + } + + /// Content with no width or no height takes the middle of a warp envelope's patch along that + /// side: under Arc a vertical line stays vertical, under Bulge a horizontal line stays straight. + #[test] + fn warp_envelopes_bend_flat_content_as_the_middle_of_the_patch() { + let warp = |style: &str| EnvelopeKind::Warp { style: style.into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; + let arc = warp("arc"); + let map = envelope_mapper(&arc, Rect::new(100.0, 0.0, 100.0, 100.0)); + for y in [0.0, 30.0, 100.0] { + let p = map(Point::new(100.0, y)); + assert!((p.x - 100.0).abs() <= 1e-9, "{y}: {p:?}"); + } + let bulge = warp("bulge"); + let map = envelope_mapper(&bulge, Rect::new(0.0, 50.0, 200.0, 50.0)); + for x in [0.0, 70.0, 200.0] { + let p = map(Point::new(x, 50.0)); + assert!((p.y - 50.0).abs() <= 1e-9, "{x}: {p:?}"); + } + } + + /// A warp envelope's map version: none in a file reads as 0 (the earlier map), 0 is written + /// as none, so files keep their bytes, and the nets' version is written. + #[test] + fn warp_envelopes_write_their_map_version_only_when_it_has_one() { + let old: EnvelopeKind = serde_json::from_str(r#"{"type":"warp","style":"arc","bend":20}"#).unwrap(); + assert!(matches!(old, EnvelopeKind::Warp { version: 0, .. }), "{old:?}"); + assert!(!serde_json::to_string(&old).unwrap().contains("version")); + let new = EnvelopeKind::Warp { style: "arc".into(), bend: 20.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; + let text = serde_json::to_string(&new).unwrap(); + assert!(text.contains(r#""version":1"#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), new); + } use vectorcraft_geom::shapes; fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc { @@ -537,7 +731,7 @@ mod tests { #[test] fn an_envelope_maps_from_its_box_with_its_margins() { let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art()); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = [Arc::new(rect)]; let src = |content: &[Arc], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src(); // Top, bottom, left and right. diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 8abe961841..f197837020 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -3,6 +3,10 @@ use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of}; use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes}; use vectorcraft_geom::Homography; +fn is_zero(n: &u32) -> bool { + *n == 0 +} + /// How an envelope distorts its content. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -19,6 +23,10 @@ pub enum EnvelopeKind { v: f64, #[serde(default = "yes")] horizontal: bool, + /// The map the warp draws with: [`WARP_NET`](super::WARP_NET) for its style's Bézier net, + /// 0 (as in files from before the version existed) for [`warp_point`](super::warp_point). + #[serde(default, skip_serializing_if = "is_zero")] + version: u32, }, /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. Mesh { diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 8675dcc011..7c80866ee7 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -178,29 +178,46 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { a.is_finite().then_some(a) } -/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into -/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map -/// that takes the triangle where the warp takes its corners (with Preserve Shape Using -/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing). -/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no -/// seams show. +/// Image `n` (`im`) pushed through the envelope's warp as a raster mesh warp ([`mesh_warp_image`] +/// with `w.cells` cells, Preserve Shape Using Transparency and Anti-Alias as the envelope's +/// options say). fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { let k = w.cells.max(1); - // Two triangles a cell, three objects each; an image an inner envelope already cut into - // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image - // is left out. - if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) { + // An image an inner envelope already cut into pieces is cut again here, so the pieces count + // against [`MAX_GENERATED`]. Past it, the image is left out. + if !Generating::charge(mesh_warp_cost(k)) { let mut g = n.clone(); g.kind = NodeKind::Group { children: vec![], clip: false }; return g; } + mesh_warp_image(n, im, w.f, k, !w.options.anti_alias, w.options.preserve_shape == PreserveShape::Transparency) +} + +/// How many objects [`mesh_warp_image`] makes of one image with `cells` rows and columns: two +/// triangles a cell, three objects each. +pub fn mesh_warp_cost(cells: usize) -> usize { + let k = cells.max(1); + k.saturating_mul(k).saturating_mul(6) +} + +/// Image `n` (`im`) pushed through point map `f` as a raster mesh warp: its frame cut into +/// `cells`² cells of two triangles, each a clip group showing the image under the affine map +/// that takes the triangle where `f` takes its corners (with `masked`, the image masked by the +/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past +/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the +/// color `n` colorizes a grayscale image with. The result is a group with `n`'s id, name, +/// transparency, opacity mask and appearance. +pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node { + let k = cells.max(1); + // A grayscale image's fill color colorizes each piece; the group of pieces takes none. + let colorize = n.extras().colorize.clone(); let (pw, ph) = (im.width as f64, im.height as f64); let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); let mut pieces = Vec::with_capacity(2 * k * k); for j in 0..k { for i in 0..k { let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; - let d = s.map(|p| (w.f)(im.xf * p)); + let d = s.map(|p| f(im.xf * p)); for t in [[0, 1, 2], [0, 2, 3]] { let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v])); let Some(xf) = affine_between(st, dt) else { continue }; @@ -216,8 +233,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { .collect(); let shape = PathData::single(SubPath::polyline(&tri, true)); let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); - image.extras_mut().pixelated = !w.options.anti_alias; - if w.options.preserve_shape == PreserveShape::Transparency { + image.extras_mut().pixelated = pixelated; + image.extras_mut().colorize = colorize.clone(); + if masked { // Preserve Shape Using Transparency: the triangle is the piece's alpha (a // white opacity mask) instead of its clipping path. let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); @@ -234,6 +252,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { } let mut g = n.clone(); g.kind = NodeKind::Group { children: pieces, clip: false }; + if let Some(e) = g.extras_if_any() { + e.colorize = None; + } g } diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index c59374dee9..be865e2769 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -1,5 +1,5 @@ use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at}; -use super::warp::{WarpStyle, flatten_subpath, resample, warp_point}; +use super::warp::{WarpNet, WarpStyle, flatten_subpath, resample, uses_net, warp_point}; use super::{ Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color, one, @@ -1731,10 +1731,19 @@ pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box Point + 'a> { let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()); match kind { - EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { + EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal, version } => { let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); let b = bend.clamp(-100.0, 100.0) / 100.0; let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); + if uses_net(*version) { + let net = WarpNet::new(st, b, dh, dv, src.width(), src.height(), !*horizontal); + // Content with no width or no height takes the middle of the patch along that side. + let (flat_u, flat_v) = (src.width() <= 0.0, src.height() <= 0.0); + return Box::new(move |u: f64, v: f64| { + let (u, v) = (if flat_u { 0.5 } else { u }, if flat_v { 0.5 } else { v }); + frame * (src.origin() + net.at(u, v).to_vec2()) + }); + } let c = src.center(); let (hw, hh) = (src.width() / 2.0, src.height() / 2.0); let horizontal = *horizontal; diff --git a/crates/doc/src/live/warp.rs b/crates/doc/src/live/warp.rs index 6d7392ac5f..08bef91140 100644 --- a/crates/doc/src/live/warp.rs +++ b/crates/doc/src/live/warp.rs @@ -109,6 +109,337 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f6 (x2, y2) } +// ===================================================================================== +// Warp nets: each style as one Bézier patch over its box +// ===================================================================================== + +/// The `version` a Warp effect's params or a warp envelope carries for the [`WarpNet`] map. With +/// none (or 0) a warp draws with [`warp_point`], as every warp did before, so saved documents keep +/// their look; new and imported warps are given this one. +pub const WARP_NET: u32 = 1; + +/// Does a warp of map `version` draw with [`WarpNet`]? A version newer than this build knows draws +/// with the newest map it has. +pub fn uses_net(version: u32) -> bool { + version >= WARP_NET +} + +/// The map version `v` names when it is one this build draws: a whole number from 0 to +/// [`WARP_NET`], written as an integer or as a float (`1` or `1.0`). +pub fn warp_version(v: &serde_json::Value) -> Option { + let n = v.as_f64()?; + (n.fract() == 0.0 && (0.0..=f64::from(WARP_NET)).contains(&n)).then_some(n as u32) +} + +/// A style's own control net: `rows` × `cols` points (2 to 4 each) of a Bézier patch, row by row +/// from the top, in the box's coordinates (origin at its top left, y down). +#[derive(Clone, Copy, Debug)] +struct Net { + rows: usize, + cols: usize, + p: [[Point; 4]; 4], +} + +impl Net { + /// The undistorted box `w` × `h` as a `rows` × `cols` net. + fn flat(w: f64, h: f64, rows: usize, cols: usize) -> Self { + let mut p = [[Point::ZERO; 4]; 4]; + for (r, row) in p.iter_mut().enumerate().take(rows) { + for (c, q) in row.iter_mut().enumerate().take(cols) { + *q = Point::new(w * (c as f64 / (cols - 1) as f64), h * (r as f64 / (rows - 1) as f64)); + } + } + Self { rows, cols, p } + } + + /// Upside down within a box `h` high: the rows in reverse order, each point mirrored. + fn mirrored(self, h: f64) -> Self { + let mut out = self; + for (to, from) in out.p[..self.rows].iter_mut().zip(self.p[..self.rows].iter().rev()) { + for (q, f) in to.iter_mut().zip(from).take(self.cols) { + *q = Point::new(f.x, h - f.y); + } + } + out + } + + /// Rows and columns exchanged, and x with y: a net made on the box turned on its side. + fn transposed(self) -> Self { + let mut out = Self { rows: self.cols, cols: self.rows, p: [[Point::ZERO; 4]; 4] }; + for (r, row) in self.p[..self.rows].iter().enumerate() { + for (c, q) in row[..self.cols].iter().enumerate() { + out.p[c][r] = Point::new(q.y, q.x); + } + } + out + } + + /// Each column scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from + /// 0 at the left column to 1 at the right: a horizontal distortion `a` (-1..1). + fn distort_columns(&mut self, a: f64) { + let (rows, cols) = (self.rows, self.cols); + for c in 0..cols { + let s = 1.0 + a * (2.0 * c as f64 / (cols - 1) as f64 - 1.0); + let mid = half_way(self.p[0][c], self.p[rows - 1][c]); + for row in &mut self.p[..rows] { + row[c] = mid + (row[c] - mid) * s; + } + } + } + + /// Each row scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from 0 + /// at the top row to 1 at the bottom: a vertical distortion `a` (-1..1). + fn distort_rows(&mut self, a: f64) { + let (rows, cols) = (self.rows, self.cols); + for (r, row) in self.p[..rows].iter_mut().enumerate() { + let s = 1.0 + a * (2.0 * r as f64 / (rows - 1) as f64 - 1.0); + let mid = half_way(row[0], row[cols - 1]); + for q in &mut row[..cols] { + *q = mid + (*q - mid) * s; + } + } + } + + /// The same patch as a bicubic net (degree elevation, which leaves the surface as it is). + fn bicubic(self) -> [[Point; 4]; 4] { + let rows: Vec<[Point; 4]> = self.p[..self.rows].iter().map(|row| cubic_line(&row[..self.cols])).collect(); + let mut out = [[Point::ZERO; 4]; 4]; + for c in 0..4 { + let col: Vec = rows.iter().map(|row| row[c]).collect(); + for (to, q) in out.iter_mut().zip(cubic_line(&col)) { + to[c] = q; + } + } + out + } +} + +/// The point half way from `a` to `b`, as halves added: two finite points always give a finite one. +fn half_way(a: Point, b: Point) -> Point { + Point::new(0.5 * a.x + 0.5 * b.x, 0.5 * a.y + 0.5 * b.y) +} + +/// A Bézier curve of 2 to 4 control points as the 4 of the same curve's cubic form. +fn cubic_line(p: &[Point]) -> [Point; 4] { + match *p { + [a, b] => [a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b], + [a, b, c] => [a, a.lerp(b, 2.0 / 3.0), c.lerp(b, 2.0 / 3.0), c], + [a, b, c, d] => [a, b, c, d], + _ => [Point::ZERO; 4], + } +} + +/// The handle offsets (along the chord, across it) of a cubic circular arc over a chord `chord` +/// long that turns through θ = |`b`| × 180°: each handle is 4/3 tan(θ/4) of the radius long, along +/// the arc's end tangent. With the radius `chord / (2 sin(θ/2))` that length is +/// `chord / (3 cos²(θ/4))`, which a bend near 0 leaves at a third of the chord. +fn arc_handles(chord: f64, b: f64) -> (f64, f64) { + let th = b.abs().min(1.0) * PI; + let len = chord / (3.0 * (th / 4.0).cos().powi(2)); + (len * (th / 2.0).cos(), len * (th / 2.0).sin()) +} + +/// A row from (0, `y`) to (`w`, `y`) bent into a circular arc of |`b`| × 180°: up for a positive +/// `b` when `up`, down when not, the other way for a negative one. +fn arc_row(w: f64, y: f64, b: f64, up: bool) -> [Point; 4] { + let (ax, ay) = arc_handles(w, b); + let dy = if (b > 0.0) == up { -ay } else { ay }; + [Point::new(0.0, y), Point::new(ax, y + dy), Point::new(w - ax, y + dy), Point::new(w, y)] +} + +/// Arc at bend |`b`| as four rows: the bottom edge bends up through the bottom corners, the top +/// edge is the arc around the same center `h` further out, and the rows between are spaced evenly +/// along the radii. The top corners lie `h` out from the bottom ones along the radii, which lean +/// out by θ/2. +fn arc_rows(w: f64, h: f64, b: f64) -> [[Point; 4]; 4] { + let th = b.abs().min(1.0) * PI; + let (s, c) = (th / 2.0).sin_cos(); + let (tl, tr) = (Point::new(-h * s, h * (1.0 - c)), Point::new(w + h * s, h * (1.0 - c))); + let (ax, ay) = arc_handles(tr.x - tl.x, b); + let top = [tl, Point::new(tl.x + ax, tl.y - ay), Point::new(tr.x - ax, tr.y - ay), tr]; + let bottom = arc_row(w, h, b.abs(), true); + let mut out = [[Point::ZERO; 4]; 4]; + for (r, row) in out.iter_mut().enumerate() { + for (q, (t, u)) in row.iter_mut().zip(top.iter().zip(bottom)) { + *q = t.lerp(u, r as f64 / 3.0); + } + } + out +} + +/// Warp style `style`'s own net at bend `b` (-1..1) over the box `w` × `h`, before distortion. +/// `vertical`: the net of the box turned on its side, where Rise moves the other half. +fn style_net(style: WarpStyle, b: f64, w: f64, h: f64, vertical: bool) -> Net { + use WarpStyle::*; + let two = |top: [Point; 4], bottom: [Point; 4]| { + let mut p = [[Point::ZERO; 4]; 4]; + (p[0], p[1]) = (top, bottom); + Net { rows: 2, cols: 4, p } + }; + let flat_row = |y: f64| [0.0, 1.0, 2.0, 3.0].map(|c| Point::new(w * c / 3.0, y)); + match style { + Arc => { + let rows = arc_rows(w, h, b); + let net = two(rows[0], rows[3]); + if b < 0.0 { net.mirrored(h) } else { net } + } + ArcUpper => two(arc_row(w, 0.0, b, true), flat_row(h)), + ArcLower => two(flat_row(0.0), arc_row(w, h, b, false)), + Arch => { + let top = arc_row(w, 0.0, b, true); + two(top, top.map(|q| Point::new(q.x, q.y + h))) + } + Bulge => two(arc_row(w, 0.0, b, true), arc_row(w, h, b, false)), + ShellLower | ShellUpper => { + let a = arc_rows(w, h, b); + let mut net = Net::flat(w, h, 4, 4); + if b >= 0.0 { + // The outer arc and the ends of the row inside it, upside down for Shell Lower. + net.p[0] = a[0]; + (net.p[1][0], net.p[1][3]) = (a[1][0], a[1][3]); + if style == ShellLower { net.mirrored(h) } else { net } + } else { + // The inner arc and the ends of the row outside it, upside down for Shell Upper. + net.p[3] = a[3]; + (net.p[2][0], net.p[2][3]) = (a[2][0], a[2][3]); + if style == ShellUpper { net.mirrored(h) } else { net } + } + } + Flag | Fish | Rise => { + let mut net = Net::flat(w, h, 2, 4); + let d = 2.0 * b * h; + match style { + Flag => { + for row in &mut net.p[..2] { + (row[1].y, row[2].y) = (row[1].y + d, row[2].y - d); + } + } + Fish => { + (net.p[0][1].y, net.p[0][2].y) = (net.p[0][1].y - d, net.p[0][2].y + d); + (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d); + } + _ => { + for row in &mut net.p[..2] { + if vertical { + (row[2].y, row[3].y) = (row[2].y - d, row[3].y - d); + } else { + (row[0].y, row[1].y) = (row[0].y + d, row[1].y + d); + } + } + } + } + net + } + Wave => { + let mut net = Net::flat(w, h, 3, 4); + let d = 2.0 * b * h; + (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d); + net + } + Fisheye => { + let mut net = Net::flat(w, h, 4, 4); + let c = Point::new(w / 2.0, h / 2.0); + for row in &mut net.p[1..3] { + for q in &mut row[1..3] { + *q = c + (*q - c) * (1.0 + 4.0 * b); + } + } + net + } + Inflate | Squeeze => { + let mut net = Net::flat(w, h, 3, 3); + let side = if style == Inflate { b * w / 2.0 } else { -b * w / 2.0 }; + net.p[0][1].y -= b * h / 2.0; + net.p[2][1].y += b * h / 2.0; + net.p[1][0].x -= side; + net.p[1][2].x += side; + net + } + Twist => { + // The four inner points move round: clockwise for a positive bend. + let mut net = Net::flat(w, h, 4, 4); + let (dx, dy) = (b.abs() * w, b.abs() * h); + if b >= 0.0 { + net.p[1][1].x += dx; + net.p[1][2].y += dy; + net.p[2][2].x -= dx; + net.p[2][1].y -= dy; + } else { + net.p[1][1].y += dy; + net.p[1][2].x -= dx; + net.p[2][2].y -= dy; + net.p[2][1].x += dx; + } + net + } + } +} + +/// A warp as one bicubic Bézier patch over its box: the style's own net at the bend, its columns +/// scaled by the horizontal distortion and then its rows by the vertical one. Points are in the +/// box's coordinates (origin at its top left, y down). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WarpNet { + /// The control points over the box divided by `scale`. + net: [[Point; 4]; 4], + /// 1, or a power of two when the net over the box itself does not fit in finite numbers. + scale: f64, + w: f64, + h: f64, +} + +impl WarpNet { + /// The net of `style` over a box `w` × `h` at bend `b` and distortions `dh` and `dv` (each + /// -1..1, clamped); `vertical` bends along the box's height. + pub fn new(style: WarpStyle, b: f64, dh: f64, dv: f64, w: f64, h: f64, vertical: bool) -> Self { + let unit = |x: f64| if x.is_finite() { x.clamp(-1.0, 1.0) } else { 0.0 }; + let size = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 }; + let (b, dh, dv, w, h) = (unit(b), unit(dh), unit(dv), size(w), size(h)); + let build = |w: f64, h: f64| { + let mut net = if vertical { style_net(style, b, h, w, true).transposed() } else { style_net(style, b, w, h, false) }; + net.distort_columns(dh); + net.distort_rows(dv); + net.bicubic() + }; + // A box near the largest finite size can overflow the net. The net is then built on the box + // divided by the smallest power of two that fits (2 to 4096), an exact division for every + // side above the subnormal range, and points are multiplied back as they are read. + let mut out = Self { net: build(w, h), scale: 1.0, w, h }; + while out.scale < 4096.0 && !out.net.iter().flatten().all(|q| q.is_finite()) { + let scale = out.scale * 2.0; + out = Self { net: build(w / scale, h / scale), scale, w, h }; + } + out + } + + /// The point at `(u, v)` of the box (0..1 across and down; outside, the patch goes on). A + /// coordinate the patch would send past the largest finite number is the box's own, held to + /// the finite range. + pub fn at(&self, u: f64, v: f64) -> Point { + let p = self.at_scaled(u, v); + let held = |x: f64| if x.is_nan() { 0.0 } else { x.clamp(-f64::MAX, f64::MAX) }; + let coord = |x: f64, flat: f64| if (x * self.scale).is_finite() { x * self.scale } else { held(flat) }; + Point::new(coord(p.x, u * self.w), coord(p.y, v * self.h)) + } + + /// [`Self::at`] divided by the net's scale. + fn at_scaled(&self, u: f64, v: f64) -> Point { + let bern = |t: f64| { + let s = 1.0 - t; + [s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t] + }; + let (bu, bv) = (bern(u), bern(v)); + let (mut x, mut y) = (0.0, 0.0); + for (row, wv) in self.net.iter().zip(bv) { + for (q, wu) in row.iter().zip(bu) { + x += wv * wu * q.x; + y += wv * wu * q.y; + } + } + Point::new(x, y) + } +} + /// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them. fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { let c = sp.segment(i); diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 151365c5d2..908b649e4d 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -1,11 +1,15 @@ //! File → File Info: the document's descriptive metadata (author, description, keywords, rating, //! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The -//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved -//! before File Info existed load unchanged. +//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it +//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and +//! [`Document::info_title`] is the title File Info shows and exports write. Every field is +//! defaulted, so documents saved before File Info existed load unchanged. use chrono::{Datelike, Timelike}; use serde::{Deserialize, Serialize}; +use crate::Document; + /// File Info → Copyright Status. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -44,6 +48,13 @@ pub const MAX_RATING: u8 = 5; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct DocMetadata { + /// The title the file this document was opened from gives itself (an SVG's root ``, a + /// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of + /// [`Document::title`], the document's display name. `None`: the file gave none, so the + /// display name is the title. Left out of a saved document when unset, so other documents + /// save as they did. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, pub author: String, /// The author's position or job title. pub author_title: String, @@ -83,6 +94,16 @@ impl DocMetadata { } } +impl Document { + /// The title File Info shows and the files an export writes carry (PDF `Title` and XMP + /// `dc:title`, SVG `<title>`, PNG `Title`): the title the file this document was opened from + /// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the + /// default file name) is always [`Document::title`]. + pub fn info_title(&self) -> &str { + self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title) + } +} + /// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time` /// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock, /// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same @@ -191,6 +212,24 @@ mod tests { assert!(DocMetadata::default().png_text("").is_empty()); } + #[test] + fn the_info_title_is_the_files_own_title_else_the_display_name() { + let mut d = Document::new(10.0, 10.0); + d.title = "box.svg".into(); + assert_eq!(d.info_title(), "box.svg"); + d.metadata.title = Some("Access paths".into()); + assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths")); + d.metadata.title = Some(" ".into()); + assert_eq!(d.info_title(), "box.svg"); + // Saved only when set: other documents' metadata is as before, and the field comes back. + d.metadata.title = None; + assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title")); + d.metadata.title = Some("Access paths".into()); + let json = serde_json::to_string(&d.metadata).unwrap(); + assert_eq!(serde_json::from_str::<DocMetadata>(&json).unwrap().title.as_deref(), Some("Access paths")); + assert_eq!(serde_json::from_str::<DocMetadata>("{}").unwrap().title, None); + } + /// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative /// decimal integer; anything else leaves the clock to decide, so an export is never refused. #[test] diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 4ecb8948bb..1952060663 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -194,8 +194,14 @@ fn round(h: &str, width: f64) -> StrokeLayer { StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) } } -/// Live effect `id` with `params` (the others keep the effect's defaults). -fn fx(id: &str, params: Value) -> Effect { +/// Live effect `id` with `params` (the others keep the effect's defaults). A Warp draws with its +/// style's Bézier net, as a new Warp does. +fn fx(id: &str, mut params: Value) -> Effect { + if id.starts_with("warp.") + && let Value::Object(m) = &mut params + { + m.entry("version").or_insert(json!(crate::live::WARP_NET)); + } Effect { id: id.into(), params, visible: true } } @@ -657,6 +663,24 @@ mod tests { assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn"); } + /// The built-in styles' Warps draw with the map a new Warp takes. + #[test] + fn builtin_warp_styles_take_the_nets_map() { + let mut warps = 0; + for b in STYLE_LIBRARIES { + let lib = builtin_style_library(b.id).unwrap(); + for g in &lib.styles { + let mut effects: Vec<&Effect> = g.appearance.effects.iter().collect(); + effects.extend(g.appearance.items.iter().flat_map(|i| i.effects())); + for e in effects.into_iter().filter(|e| e.id.starts_with("warp.")) { + assert_eq!(e.params["version"], json!(crate::live::WARP_NET), "{} › {}: {}", b.id, g.name, e.id); + warps += 1; + } + } + } + assert!(warps >= 3, "{warps}"); + } + #[test] fn painted_and_sketched_follows_textures_with_one_effect_per_style() { let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 05f9c29041..da845ba739 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -52,7 +52,7 @@ pub struct TextObject { /// the object, so the runs (and their styles) stay the text itself. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ruby: Vec<RubySpan>, - #[serde(default)] + #[serde(default = "ParaStyle::omitted")] pub para: ParaStyle, /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index fd828c79af..37c76c5e3a 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -28,6 +28,12 @@ pub struct CharStyle { /// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub kerning: Option<f64>, + /// Space before the character in 1/1000 em, as [`Self::kerning`], when it starts a line by + /// itself: the first character of the text, of a paragraph, or after a forced line break. The + /// Type tool's caret before such a character kerns with it (#5111), since there is no character + /// before it whose [`Self::kerning`] could space it. Elsewhere it has no effect. + #[serde(default, rename = "kerningBefore", skip_serializing_if = "crate::skip::is_default")] + pub kerning_before: f64, /// Use glyph shapes for automatic pair spacing; false uses font metrics. #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] pub optical_kerning: bool, @@ -411,6 +417,7 @@ impl Default for CharStyle { leading: None, tracking: 0.0, kerning: None, + kerning_before: 0.0, optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index f23de1d41f..bb3725072e 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -244,9 +244,21 @@ pub struct ParaStyle { /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with /// kashidas before they widen their word spaces. pub kashida: Kashida, + /// Korean Line Break Anywhere (Paragraph panel menu): a line may break between any two Hangul + /// syllables. Off, a Korean word (the characters between spaces, when any of them is Hangul) + /// breaks only at its spaces and punctuation, or where it is wider than the line. New type and + /// imported text have it off; paragraphs saved before it existed open with it on, the line + /// breaks they were laid out with. + pub korean_line_break_anywhere: bool, } impl ParaStyle { + /// The attributes a text object saved without `para` reads with: the defaults, with Korean + /// Line Break Anywhere on, as a saved paragraph without that field reads. + pub(crate) fn omitted() -> Self { + Self { korean_line_break_anywhere: true, ..Self::default() } + } + /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is @@ -330,6 +342,10 @@ struct ParaStyleFile { roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, + /// Written only when off: a paragraph saved without it was laid out breaking between Hangul + /// syllables. + #[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")] + korean_line_break_anywhere: bool, } impl From<ParaStyle> for ParaStyleFile { @@ -361,6 +377,7 @@ impl From<ParaStyle> for ParaStyleFile { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, .. } = p; Self { @@ -386,6 +403,7 @@ impl From<ParaStyle> for ParaStyleFile { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, } } } @@ -415,6 +433,7 @@ impl From<ParaStyleFile> for ParaStyle { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, .. } = f; Self { @@ -439,6 +458,7 @@ impl From<ParaStyleFile> for ParaStyle { kinsoku_type, roman_hanging, kashida, + korean_line_break_anywhere, } } } diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index 5ed95b78e1..e213c252bb 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -365,6 +365,7 @@ impl ParaList { position: CharPosition::Normal, tracking: 0.0, kerning: None, + kerning_before: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, underline: false, diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index aa403b4218..3b3781f87c 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -184,6 +184,29 @@ fn justification_options_read_from_a_file_stay_in_range() { assert_eq!(same, d); } +/// Korean Line Break Anywhere is off for a new paragraph and saved only when off; a paragraph +/// saved before it existed, or a text object saved without paragraph attributes, reads with it on, +/// and both values read back as saved. +#[test] +fn korean_line_break_anywhere_is_saved_only_when_off() { + let off = ParaStyle::default(); + assert!(!off.korean_line_break_anywhere); + let saved = serde_json::to_value(&off).unwrap(); + assert_eq!(saved["korean_line_break_anywhere"], false); + assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), off); + let on = ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() }; + let saved = serde_json::to_value(&on).unwrap(); + assert!(saved.get("korean_line_break_anywhere").is_none()); + assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), on); + let old: ParaStyle = serde_json::from_str(r#"{"justify":"Left"}"#).unwrap(); + assert!(old.korean_line_break_anywhere); + let mut saved = serde_json::to_value(TextObject::point(Point::ZERO, "가나", CharStyle::default())).unwrap(); + assert_eq!(saved["para"]["korean_line_break_anywhere"], false); + saved.as_object_mut().unwrap().remove("para"); + let old: TextObject = serde_json::from_value(saved).unwrap(); + assert_eq!(old.para, ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() }); +} + /// Numbered lists: each number style writes its numbers and rolls over (letters after Z, /// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds /// a period, and the count runs through list paragraphs, skips empty and bulleted ones and diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 5c18f2d5f0..84991392b2 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -1,4 +1,7 @@ -use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap}; +use super::{ + CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CLASSIC_ROTATE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, + json, trap, +}; /// Catalogue entry for one effect. #[derive(Clone, Debug, PartialEq)] @@ -81,6 +84,7 @@ fn lengths_of(id: &str) -> Lengths { "threeD.revolve" => always(&["offset"]), "threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]), "threeD.classicExtrude" => always(&["depth", "bevelHeight"]), + "threeD.classicRevolve" => always(&["offset"]), "threeD.inflate" => always(&["depth"]), "path.outlineStroke" => always(&["width"]), "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), @@ -112,7 +116,7 @@ fn lengths_of(id: &str) -> Lengths { } } -const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}"; +const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds, and images bend as 10 × 10 cells of two triangles unless the pieces would make the art pass 2,000,000 objects or the image's frame keeps its shape past the anchor budget), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}"; const DT: &[&str] = &["Effect", "Distort & Transform"]; @@ -207,6 +211,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "{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}), ), + 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"}), + ), + 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"}), + ), g( "convertToShape.rectangle", "Rectangle…", diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 67b4b464fd..8591bbab2c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -83,18 +83,20 @@ pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; -pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in}; +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, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, - classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in, - three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, 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, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, + classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in, + three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, 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, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; pub use trap::TRAP; pub use vectorcraft_three_d::Mesh; -pub use warp::{WarpStyle, warp_point}; +pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version}; mod catalog; mod geometry; diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index 7785aff335..1e3c8c1d31 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -31,7 +31,8 @@ pub fn merged_params(id: &str, params: &Value) -> Value { /// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its /// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of /// then) become the current ones that show exactly the same, unless those are given too, which win. -/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°). +/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°), and a Warp's +/// map `version` given as a whole float is stored as an integer. pub fn upgrade_params(id: &str, params: &Value) -> Value { match params { Value::Object(m) => { @@ -39,6 +40,7 @@ pub fn upgrade_params(id: &str, params: &Value) -> Value { upgrade_perspective(id, &mut m); upgrade_light(id, &mut m); upgrade_scribble(id, &mut m); + upgrade_warp_version(id, &mut m); Value::Object(m) } other => other.clone(), @@ -71,6 +73,19 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) { } } +/// A Warp's map `version` given as a whole float (`1.0`) is stored as the integer. +fn upgrade_warp_version(id: &str, m: &mut Map<String, Value>) { + if !id.starts_with("warp.") { + return; + } + if let Some(v) = m.get_mut("version") + && !v.is_u64() + && let Some(n) = crate::warp::warp_version(v) + { + *v = json!(n); + } +} + fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) { if id != "stylize.scribble" { return; @@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option<Effect> { if !catalog_index().contains_key(id) { vectorcraft_plugins::effect::installed(id)?; } - Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) + let mut params = merged_params(id, params); + // A new Warp takes its style's Bézier net unless a map is given; the catalog's defaults carry + // no version, so warps saved without one keep the earlier map. + if id.starts_with("warp.") + && let Value::Object(m) = &mut params + { + m.entry("version").or_insert(json!(crate::warp::WARP_NET)); + } + Some(Effect { id: id.to_string(), params, visible: true }) } /// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..9d93f82a79 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -4,19 +4,22 @@ //! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths //! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect //! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a -//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects -//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without +//! warp bends a line of type as a whole. An image is clipped by its reshaped frame, and a Warp +//! effect bends its pixels with the frame ([`cut_warped_images`]). Raster effects (shadows, glows, blur, +//! feather) stay on the result for the renderer and exporters. Type without //! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those //! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives //! them ([`image_rows`]). use std::collections::HashSet; +use std::rc::Rc; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use vectorcraft_doc::live::Hooks; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind}; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes}; use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params}; @@ -548,7 +551,14 @@ fn as_art_opts( let mut art = match &n.kind { NodeKind::Image(im) => { let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); - let image = Node::new(n.id, n.kind.clone()); + let mut image = Node::new(n.id, n.kind.clone()); + // Its pixels draw as the image's do: colorized by a grayscale image's fill color, and + // smoothed or not. + { + let mut e = image.extras_mut(); + e.colorize = n.extras().colorize.clone(); + e.pixelated = n.extras().pixelated; + } let clipped = || { let mut clip = Node::path(n.id, frame.clone(), Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { @@ -632,9 +642,9 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as /// its reference box and the art's seed (a random Transform moves the pieces together). Images and -/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry -/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The -/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path +/// symbol instances follow a Transform effect the same way ([`transform_placed`]), and images +/// follow a Warp effect as a raster mesh warp ([`cut_warped_images`]); other geometry effects leave them +/// as they are. The paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path /// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns /// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one /// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]). @@ -705,8 +715,9 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget let max = budget.at_least(used); // Read before the effects run: a path's own copies don't make it art of several objects. let copy_whole = copies_whole(art); + let mut warped = WarpedImages::new(); for e in fx { - let Some(bounds) = art.geometric_bounds() else { break }; + let Some(bounds) = warped.bounds(art) else { break }; let params = merged_params(&e.id, &e.params); // Where a live-effect plug-in sits, which it is listed by when it is deferred. let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e))); @@ -739,10 +750,21 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget } let mut groups = vec![]; if follows { - group_marks(art, &mut vec![], &HashSet::new(), &mut groups); + group_marks(art, &mut vec![], &HashSet::new(), None, &mut groups); + // The images earlier Warps bend are measured by their pieces only when a group's + // gradient needs the bounds. + if !groups.is_empty() + && let Some(images) = warped.when_cut(art) + { + groups.clear(); + group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups); + } } - // The ids of the paths that kept their shape (past the budget). + // The ids of the paths that kept their shape (past the budget), and their places in the + // order [`for_each_leaf`] visits the paths. let mut kept_ids = HashSet::new(); + let mut kept_leaves = HashSet::new(); + let mut leaf_at = 0usize; let transform = (e.id == "distort.transform").then(|| { let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); @@ -757,6 +779,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget continue; } for_each_leaf(art, &mut |leaf| { + let at = leaf_at; + leaf_at += 1; let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. let before = Budget::of(&path); @@ -789,6 +813,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None }); let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else { kept_ids.insert(leaf.id); + kept_leaves.insert(at); return; }; used = used.minus(before).plus(Budget::of(&out)); @@ -804,6 +829,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget if boxes { rebase_leaves(art, &mut leaves.into_iter()); } + // Warp bends images too: each one's pixels follow the warp's map over the same box. The + // images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image + // whose clipping frame kept its shape (past the budget) keeps its pixels as they are. + if let Some(style) = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id) + && crate::warp::bends(&e.id, ¶ms) + { + let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, ¶ms)); + warped.reserve(art); + mark_warped_images(art, &f, &kept_leaves, false, &mut 0, &mut warped); + } if let Some((m, copies, options, _)) = transform && options.objects { @@ -816,14 +851,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget // follow their bounds under Free Distort and Warp (they stay when no path changed them). // Each stays when a path under it that its bounds measure kept its shape (past the budget). let mut after = vec![]; - let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1; + let images = if groups.is_empty() { None } else { warped.when_cut(art) }; + let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1; if !groups.is_empty() { carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed)); } if follows && !held { - whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed)); + whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), warped.bounds(art), seed)); } } + cut_warped_images(art, &warped); whole } @@ -1445,17 +1482,25 @@ type GroupMark = (Vec<usize>, Option<Rect>, bool); /// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or /// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that -/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. -fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) { +/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. An image that `images` +/// lists is measured by the pieces it will be cut into ([`WarpedImages::image_bounds`]). +fn group_marks( + n: &Node, + at: &mut Vec<usize>, + kept: &HashSet<NodeId>, + images: Option<&WarpedImages>, + out: &mut Vec<GroupMark>, +) -> (Option<Rect>, bool) { match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)), + NodeKind::Image(_) => (images.map_or_else(|| n.geometric_bounds(), |w| w.image_bounds(n)), false), NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => { let layer = matches!(n.kind, NodeKind::Layer { .. }); let skip = children.len().saturating_sub(n.editing_children().len()); let (mut bounds, mut held) = (None, false); for (i, c) in children.iter().enumerate() { at.push(i); - let (b, h) = group_marks(c, at, kept, out); + let (b, h) = group_marks(c, at, kept, images, out); if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) { out.push((at.clone(), b, h)); } @@ -1590,6 +1635,225 @@ pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, ma copy_placed(n, m, copies, &mut room); } +/// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number). +pub(crate) const WARP_IMAGE_CELLS: usize = 10; + +/// A Warp effect's point map, shared by the images it bends. +pub(crate) type WarpMap = Rc<dyn Fn(Point) -> Point>; + +/// One image a Warp effect bent: the image's own key, and each warp it took with the placement +/// the image had when it took it, in order. +type WarpedImage = (String, Vec<(WarpMap, Affine)>); + +/// The images Warp effects bend while [`reshape_leaves_apart`] runs the effects. Each such image's +/// key is replaced by a key of this table ([`WarpedImages::add`]), which a copy a later Transform +/// effect makes keeps. The images are cut into pieces once, after the last effect +/// ([`cut_warped_images`]): the effects after a Warp reshape the image's frame, not each piece, +/// and a later Warp or Transform moves the one set of pieces. +pub(crate) struct WarpedImages { + /// What this table's keys start with: no image key in the art starts with it + /// ([`WarpedImages::reserve`]). + prefix: String, + /// The prefix was checked against the art's image keys. + reserved: bool, + images: Vec<WarpedImage>, +} + +impl WarpedImages { + pub(crate) fn new() -> Self { + static TABLES: AtomicUsize = AtomicUsize::new(0); + Self { prefix: format!("\0warp{}:", TABLES.fetch_add(1, Ordering::Relaxed)), reserved: false, images: vec![] } + } + + /// Before the first image of `art` is marked: lengthen the prefix until no image key in `art` + /// starts with it, so no key from the document is read as one of this table's. + pub(crate) fn reserve(&mut self, art: &Node) { + if self.reserved { + return; + } + self.reserved = true; + let mut keys = vec![]; + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + keys.push(im.key.clone()); + } + }); + while keys.iter().any(|k| k.starts_with(self.prefix.as_str())) { + self.prefix.push(':'); + } + } + + /// What this table's keys start with. + #[cfg(test)] + pub(crate) fn prefix(&self) -> &str { + &self.prefix + } + + /// The image that `key`, a key of this table, stands for. + fn get(&self, key: &str) -> Option<&WarpedImage> { + self.images.get(key.strip_prefix(self.prefix.as_str())?.parse::<usize>().ok()?) + } + + /// Record that the image with key `key`, placed by `xf`, takes warp `f`; returns the image's + /// new key. + fn add(&mut self, key: &str, xf: Affine, f: &WarpMap) -> String { + let (own, mut warps) = self.get(key).cloned().unwrap_or_else(|| (key.to_string(), vec![])); + warps.push((f.clone(), xf)); + self.images.push((own, warps)); + format!("{}{}", self.prefix, self.images.len() - 1) + } + + /// Does the art `n` have room, within `max` nodes, for the pieces of every image under it that + /// this table lists? + fn fits(&self, n: &Node, max: usize) -> bool { + let mut count = 0usize; + n.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind + && self.get(&im.key).is_some() + { + count += 1; + } + }); + vectorcraft_doc::live::mesh_warp_cost(WARP_IMAGE_CELLS).saturating_mul(count) <= max.saturating_sub(n.count()) + } + + /// The geometric bounds of `art` as it draws after the effects so far: each image this table + /// lists measured by the pieces the warps and moves it took would cut it into, when the art + /// has room for them ([`cut_warped_images`]). + pub(crate) fn bounds(&self, art: &Node) -> Option<Rect> { + if self.images.is_empty() || !self.fits(art, crate::distort::MAX_COPY_OBJECTS) { + return art.geometric_bounds(); + } + // The pieces the images would be cut into now: each group of pieces is shown or hidden as + // its image is, and hidden objects are left out of a layer's bounds. + let mut probe = art.clone(); + restore_warped_images(&mut probe, self, true); + probe.geometric_bounds() + } + + /// The geometric bounds of image `n`: when this table lists it, measured by the pieces the + /// warps and moves it took would cut it into. + fn image_bounds(&self, n: &Node) -> Option<Rect> { + let NodeKind::Image(im) = &n.kind else { return n.geometric_bounds() }; + if self.get(&im.key).is_none() { + return n.geometric_bounds(); + } + let mut probe = Node::group(n.id, vec![Arc::new(n.clone())]); + restore_warped_images(&mut probe, self, true); + probe.geometric_bounds() + } + + /// This table when the art `art` has room for the pieces of the images it lists, so that a + /// measure of the art takes them bent ([`WarpedImages::bounds`]); `None` otherwise. + fn when_cut(&self, art: &Node) -> Option<&Self> { + (!self.images.is_empty() && self.fits(art, crate::distort::MAX_COPY_OBJECTS)).then_some(self) + } +} + +/// Record in `warped` that each image under `n` takes warp `f`, walking the groups and layers +/// [`for_each_leaf`] walks and counting the paths it passes in `at`. `kept` holds the places of +/// the paths that kept their shape (past the budget), in the order [`for_each_leaf`] visits the +/// paths of the art: an image in a clip group whose clipping art holds one (`held`) does not take +/// the warp. +pub(crate) fn mark_warped_images(n: &mut Node, f: &WarpMap, kept: &HashSet<usize>, held: bool, at: &mut usize, warped: &mut WarpedImages) { + let (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }) = &mut n.kind else { return }; + let mut held = held; + // A clip group's first member is its clipping art: when a path in it kept its shape, it and + // the members it clips keep theirs. + if *clip && let Some(first) = children.first() { + let start = *at; + held |= (start..start + leaf_count(first)).any(|p| kept.contains(&p)); + } + for c in children.iter_mut() { + match &c.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => *at += 1, + NodeKind::Group { .. } | NodeKind::Layer { .. } => mark_warped_images(Arc::make_mut(c), f, kept, held, at, warped), + NodeKind::Image(_) if !held => { + if let NodeKind::Image(im) = &mut Arc::make_mut(c).kind { + im.key = warped.add(&im.key, im.xf, f); + } + } + _ => {} + } + } +} + +/// Give each image under `n` that `warped` lists its own key back, and cut it into the pieces of +/// a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`], each piece drawn as the image +/// is, smoothed or not) through the warps it took and the moves between and after them +/// ([`warped_map`]). The art keeps to [`crate::distort::MAX_COPY_OBJECTS`] nodes: when the pieces +/// would pass it, no image is cut. An image whose placement has no inverse is not cut either. +fn cut_warped_images(n: &mut Node, warped: &WarpedImages) { + cut_warped_images_within(n, warped, crate::distort::MAX_COPY_OBJECTS); +} + +/// [`cut_warped_images`] with the art keeping to `max` nodes. +pub(crate) fn cut_warped_images_within(n: &mut Node, warped: &WarpedImages, max: usize) { + if warped.images.is_empty() { + return; + } + let cut = warped.fits(n, max); + restore_warped_images(n, warped, cut); +} + +/// The walk of [`cut_warped_images`]: each listed image gets its key back, and with `cut` is cut +/// into pieces. +fn restore_warped_images(n: &mut Node, warped: &WarpedImages, cut: bool) { + let Some(children) = n.children_mut() else { return }; + for c in children.iter_mut() { + let NodeKind::Image(im) = &c.kind else { + if c.children().is_some() { + restore_warped_images(Arc::make_mut(c), warped, cut); + } + continue; + }; + let Some((key, warps)) = warped.get(&im.key) else { continue }; + let mut image = (**c).clone(); + let NodeKind::Image(own) = &mut image.kind else { continue }; + own.key = key.clone(); + // The pieces start from the image as it was placed when it took its first warp. + let first = warps.first().map(|(_, at)| ImageObject { xf: *at, ..(**own).clone() }); + let map = warped_map(warps, own.xf); + *c = Arc::new(match (first, map) { + (Some(first), Some(map)) if cut => { + let pixelated = image.extras().pixelated; + vectorcraft_doc::live::mesh_warp_image(&image, &first, &*map, WARP_IMAGE_CELLS, pixelated, false) + } + _ => image, + }); + } +} + +/// The map that takes a point of an image placed by the first of `warps`' placements to where the +/// warps and the moves between them take it, for an image now placed by `placed`: each warp in +/// turn, then the move from the placement the image had when it took that warp to the one it had +/// at the next, or to `placed` after the last. `None` when a placement has no inverse. +fn warped_map(warps: &[(WarpMap, Affine)], placed: Affine) -> Option<Box<dyn Fn(Point) -> Point>> { + let mut steps: Vec<(WarpMap, Affine)> = Vec::with_capacity(warps.len()); + for (i, (f, at)) in warps.iter().enumerate() { + let det = at.determinant(); + if !det.is_finite() || det == 0.0 { + return None; + } + let next = warps.get(i + 1).map_or(placed, |(_, at)| *at); + let m = next * at.inverse(); + if !m.is_finite() { + return None; + } + steps.push((f.clone(), m)); + } + Some(Box::new(move |p| steps.iter().fold(p, |q, (f, m)| *m * f(q)))) +} + +/// How many paths [`for_each_leaf`] visits in `n`. +fn leaf_count(n: &Node) -> usize { + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => 1, + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| leaf_count(c)).sum(), + _ => 0, + } +} + /// An object that a Transform effect places through its transform rather than reshapes: an image /// (pixel space to document) or a symbol instance (the symbol's art to document). fn is_placed(n: &Node) -> bool { diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4795e8848a..c73d76e2c8 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -1,4 +1,5 @@ -//! Revolve as live, shaded vector art, shared by the canvas and every exporter. +//! Revolve and Revolve (Classic) as live, shaded vector art, shared by the canvas and every +//! exporter. use std::sync::Arc; use serde_json::Value; @@ -13,6 +14,14 @@ use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; pub const REVOLVE: &str = "threeD.revolve"; +mod classic; +pub use classic::CLASSIC_REVOLVE; + +/// Is `id` Revolve or Revolve (Classic), the effects this module draws? +pub fn is_revolve(id: &str) -> bool { + id == REVOLVE || id == CLASSIC_REVOLVE +} + /// Most bytes the copies of the object's paint in the faces may take together. Each face and the /// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of /// thousands of faces took gigabytes; past this the source path is drawn. @@ -55,7 +64,7 @@ pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) - } fn visible(fx: &[Effect]) -> bool { - fx.iter().any(|e| e.visible && e.id == REVOLVE) + fx.iter().any(|e| e.visible && is_revolve(&e.id)) } pub fn has_revolve(n: &Node) -> bool { @@ -88,6 +97,17 @@ pub fn revolve_options(p: &Value) -> Revolve { } } +/// The options Revolve (`id` [`REVOLVE`]) or Revolve (Classic) ([`CLASSIC_REVOLVE`]) with +/// parameters `p` sweeps its surface with. +fn options_of(id: &str, p: &Value) -> Revolve { + if id == CLASSIC_REVOLVE { classic::options(p) } else { revolve_options(p) } +} + +/// The options effect `fx`, a Revolve or a Revolve (Classic), sweeps its surface with. +fn fx_options(fx: &Effect) -> Revolve { + options_of(&fx.id, &merged_params(&fx.id, &fx.params)) +} + /// Validate before a command mutates the document. Loading external files is still bounded /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { @@ -97,11 +117,16 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { /// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group /// revolve the art they show ([`resolve_for`]), cut for Revolve whatever 3D effect `n` carries now. pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> { - validate_revolve_params(p)?; + validate_in(doc, n, REVOLVE, p) +} + +/// [`validate_revolve_in`] for Revolve or Revolve (Classic), `id`. +pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { + validate_params_of(id, p)?; let n = &*resolve_for(doc, n, "Revolve", false)?; let effects = &n.appearance.effects; // The profile is measured before the object's one Revolve, hidden or shown. - let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects); + let before = effects.iter().position(|e| is_revolve(&e.id)).and_then(|i| effects.get(..i)).unwrap_or(effects); if is_container(n) { const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors"; let mut shapes = members(n, "Revolve")?; @@ -110,7 +135,7 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul } reshape_members(n, before, &mut shapes); let b = union_bounds(&shapes).ok_or(EMPTY)?; - let options = shared_options(&shapes, revolve_options(p))?; + 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))?; let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0)); @@ -123,8 +148,8 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul 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, revolve_options(p), surface_item(&paints), &mut 0)?; - check_shadow(p, [&path], b, revolve_options(p)) + check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?; + check_shadow(p, [&path], b, options_of(id, p)) } /// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast @@ -138,14 +163,21 @@ fn check_shadow<'a>(p: &Value, paths: impl IntoIterator<Item = &'a PathData>, b: } /// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of -/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve, -/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level -/// Revolve draws in place of those surfaces. +/// appearance item `target` of `n` ([`validate_item_of`]). +#[cfg(test)] pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> { + validate_item_of(n, REVOLVE, p, target, index) +} + +/// [`validate_revolve`] for a Revolve or Revolve (Classic), `id`, with parameters `p` at index +/// `index` of the effects of appearance item `target` of `n`. The surfaces of every fill and stroke +/// that carries one, `target`'s included, share one budget of paint copies, as in [`art`]. A visible +/// object-level Revolve draws in place of those surfaces. +pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> { if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() { - return validate_revolve(n, p); + return validate_in(None, n, id, p); } - validate_revolve_params(p)?; + validate_params_of(id, p)?; const EMPTY: &str = "Revolve requires a shape with at least two anchors"; let (path, _) = object_profile(n, "Revolve")?; if !has_two_anchors(&path) { @@ -156,13 +188,13 @@ pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: u let mut copies = 0; for (i, item) in n.appearance.items.iter().enumerate() { let effects = item.effects(); - let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) }; - let params = match at.and_then(|at| effects.get(at)) { - _ if i == target => p.clone(), - Some(fx) if crate::paints(item) => merged_params(REVOLVE, &fx.params), + let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && is_revolve(&e.id)) }; + let (kind, params) = match at.and_then(|at| effects.get(at)) { + _ if i == target => (id, p.clone()), + Some(fx) if crate::paints(item) => (fx.id.as_str(), merged_params(&fx.id, &fx.params)), _ => continue, }; - let options = revolve_options(¶ms); + 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)?; @@ -217,6 +249,16 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { Ok(()) } +/// [`validate_revolve_params`] for Revolve or Revolve (Classic), `id`; Revolve (Classic) also takes +/// only its own parameters, each in its range. +pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> { + validate_revolve_params(p)?; + if id == CLASSIC_REVOLVE { + classic::validate(p)?; + } + Ok(()) +} + fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx) } @@ -226,7 +268,7 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// 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. fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result<Option<Node>, String> { - let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(None) }; + 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 { return Ok(None); @@ -242,7 +284,7 @@ fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: /// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the /// limits. fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result<Vec<Arc<Node>>, String> { - let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(vec![]) }; + let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(vec![]) }; let mut caster = vectorcraft_three_d::ShadowCaster::new(); let mut cast = vec![]; for s in shapes { @@ -259,7 +301,8 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R } /// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts -/// the bytes of the paint copies of every surface of the object. +/// the bytes of the paint copies of every surface of the object. Revolve (Classic) draws every face +/// with its classic surface, as Extrude & Bevel (Classic) does. #[allow(clippy::too_many_arguments)] fn surface( n: &Node, @@ -275,6 +318,16 @@ fn surface( ) -> Option<(Vec<f64>, Node)> { // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. + if fx.id == CLASSIC_REVOLVE { + let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?; + *copies = copies.saturating_add(paint_copies(facets.len(), &paint)); + if *copies > MAX_PAINT_COPIES { + return None; + } + let depths = facets.iter().map(|f| f.depth).collect(); + let p = merged_params(&fx.id, &fx.params); + return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; *copies = copies.saturating_add(paint_copies(faces.len(), &paint)); @@ -371,8 +424,7 @@ fn surface( fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option<Node> { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); - let options = revolve_options(&merged_params(REVOLVE, &fx.params)); - surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) + surface(n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) } fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint { @@ -429,10 +481,10 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result<crate::SolidMesh, String> { let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let (paint, opacity, _) = paint_of(item); - let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, fx_options(fx))?; Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) }) }; - if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let before = n.appearance.effects.get(..index).unwrap_or_default(); let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let path = geometry(&path, before, &ctx); @@ -445,7 +497,7 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul let path = geometry(&path, &n.appearance.effects, &ctx); let mut out = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { - if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) && let Some(fx) = item.effects().get(index) { let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); @@ -461,13 +513,13 @@ fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate:: .appearance .effects .iter() - .position(|e| e.visible && e.id == REVOLVE) + .position(|e| e.visible && is_revolve(&e.id)) .ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?; let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; let mut shapes = members(n, "Revolve")?; reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; - let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + let options = shared_options(&shapes, fx_options(fx))?; let mut out = vec![]; for s in &shapes { let Some(item) = surface_item(&s.items) else { continue }; @@ -491,7 +543,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { let mut children = vec![]; let mut copies = 0; let mut remaining = n.appearance.effects.clone(); - if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = n.appearance.effects.get(index)?; let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); // An object's surface uses its top visible fill, falling back to its stroke. @@ -500,7 +552,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let surface = object_surface(n, &path, fx, item, expand, &mut copies)?; if let Some(b) = path.bounds() - && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()? { children.push(Arc::new(shadow)); } @@ -511,7 +563,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { .effects .iter() .enumerate() - .filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index)) + .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index)) .map(|(_, e)| e.clone()) .collect(); } else { @@ -520,14 +572,14 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> { // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { - if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) { + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?; - art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect(); + art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() - && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()? + && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()? { shadow.appearance.effects = art.appearance.effects.clone(); shadows.push(Arc::new(shadow)); @@ -666,12 +718,12 @@ fn overlaps(a: Rect, b: Rect) -> bool { /// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when /// the effect is only on the group's own fills or strokes or when its members can't be revolved. fn group_art(n: &Node, expand: bool) -> Option<Node> { - let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?; + let index = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id))?; let fx = n.appearance.effects.get(index)?; let mut shapes = members(n, "").ok()?; reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let b = union_bounds(&shapes)?; - let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?; + let options = shared_options(&shapes, fx_options(fx)).ok()?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut copies = 0; let mut surfaces = vec![]; @@ -690,6 +742,10 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { return None; } let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); + // A classic surface whose gradient has transparent stops carries an opacity mask: its faces go + // in runs, as a transparent member's do, and each run carries the mask. + let masks: Vec<Option<Box<vectorcraft_doc::OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect(); + let opaque = opaque && masks.iter().all(Option::is_none); let children = if !plain { scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0) } else { @@ -731,7 +787,8 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend }); continue; }; - if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() { + let masked = masks.get(member).is_some_and(Option::is_some); + if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() && !masked { runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend }); continue; } @@ -767,6 +824,9 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> { let mut group = Node::group(n.id, r.faces); group.opacity = r.opacity; group.blend = r.blend; + if let Some(mask) = r.member.and_then(|m| masks.get(m)).cloned().flatten() { + group.extras_mut().mask = Some(mask); + } Some((r.scopes, Arc::new(group))) }); scoped(n.id, runs.collect(), 0) diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs new file mode 100644 index 0000000000..1881381567 --- /dev/null +++ b/crates/effects/src/revolve/classic.rs @@ -0,0 +1,220 @@ +//! Revolve (Classic): the surface Revolve sweeps from a path, drawn with the classic surfaces of +//! Extrude & Bevel (Classic) (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend +//! steps and shading color. +use serde_json::Value; +use vectorcraft_three_d::Revolve; + +pub const CLASSIC_REVOLVE: &str = "threeD.classicRevolve"; + +/// The numeric parameters: (key, lowest, highest, whole numbers only). +const RANGES: [(&str, f64, f64, bool); 13] = [ + ("angle", 0.0, 360.0, false), + ("offset", 0.0, 1000.0, false), + ("rotationX", -180.0, 180.0, false), + ("rotationY", -180.0, 180.0, false), + ("rotationZ", -180.0, 180.0, false), + ("perspectiveAngle", 0.0, 160.0, false), + ("lightRotation", -180.0, 180.0, false), + ("lightHeight", 0.0, 90.0, false), + ("lightIntensity", 0.0, 100.0, false), + ("ambient", 0.0, 100.0, false), + ("highlightIntensity", 0.0, 100.0, false), + ("highlightSize", 0.0, 100.0, false), + ("blendSteps", 1.0, 256.0, true), +]; + +const FLAGS: [&str; 2] = ["cap", "lightBehind"]; + +/// 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. +pub(crate) fn options(p: &Value) -> Revolve { + Revolve { shade: false, smooth_shading: false, lights: None, light_color: [1.0; 3], ..super::revolve_options(p) } +} + +/// Check Revolve (Classic)'s parameters `p` (its defaults filled in) beyond the checks it shares +/// with Revolve: every key is one of its parameters, numbers are in range, flags are true or false, +/// and the surface and shading color are named. +pub(crate) fn validate(p: &Value) -> Result<(), String> { + crate::solids::classic::validate_classic(CLASSIC_REVOLVE, p, &RANGES, &FLAGS) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_doc::color::{Color, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId}; + use vectorcraft_geom::{BezPath, PathData}; + + use super::*; + + const BLUE: Color = Color::Rgb { r: 0.2, g: 0.4, b: 0.8 }; + + /// Half a vase's profile, open, filled blue. + fn profile() -> Node { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)) + } + + fn revolved(params: Value) -> Node { + let mut n = profile(); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, ¶ms).unwrap()); + crate::revolve_art(&n).unwrap() + } + + /// The faces of the surface of `art`, back to front. + fn faces(art: &Node) -> Vec<&Node> { + art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect() + } + + fn fill(face: &Node) -> Option<Color> { + face.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => f.paint.color(), + AppearanceItem::Stroke(_) => None, + }) + } + + fn facets(params: &Value) -> usize { + let n = profile(); + let path = n.path_data().unwrap(); + let p = crate::merged_params(CLASSIC_REVOLVE, params); + vectorcraft_three_d::revolve_facets(path, path.bounds().unwrap(), options(&p)).unwrap().len() + } + + #[test] + fn plastic_shades_every_face_of_the_swept_surface_in_the_fill() { + let art = revolved(json!({})); + let drawn = faces(&art); + assert_eq!(drawn.len(), facets(&json!({})), "one face for each facet of the surface"); + let colors: Vec<[f32; 3]> = drawn.iter().map(|f| fill(f).unwrap().to_rgb()).collect(); + // Lit from one side, the faces take more than one tone, and none is another hue: a face is + // the blue darkened toward black, lightened toward white, or both. + let distinct = colors.iter().filter(|c| colors.iter().any(|d| (c[0] - d[0]).abs() > 0.02)).count(); + assert!(distinct > 0, "{colors:?}"); + // Diffuse has no highlight, so every face is the blue darkened toward black. + let art = revolved(json!({"surface": "diffuse"})); + for face in faces(&art) { + let [r, g, b] = fill(face).unwrap().to_rgb(); + assert!(b <= 0.8 + 1e-6 && (g * 2.0 - b).abs() < 1e-4 && (r * 4.0 - b).abs() < 1e-4, "{r} {g} {b}"); + } + } + + #[test] + fn wireframe_strokes_the_edges_of_every_face_and_no_shading_keeps_the_paint() { + let art = revolved(json!({"surface": "wireframe"})); + let faces_drawn = faces(&art); + assert_eq!(faces_drawn.len(), facets(&json!({"surface": "wireframe"}))); + for face in faces_drawn { + match face.appearance.items.as_slice() { + [AppearanceItem::Stroke(s)] => assert_eq!((s.paint.color(), s.width), (Some(BLUE), 0.5)), + items => panic!("a wireframe face is one stroke: {items:?}"), + } + } + for face in faces(&revolved(json!({"surface": "none"}))) { + assert_eq!(fill(face), Some(BLUE)); + } + } + + #[test] + fn angle_offset_edge_and_cap_shape_the_surface() { + let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap(); + // Seen from the front, the full turn is twice the profile's width across, about its left edge. + assert!((full.width() - 60.0).abs() < 2.0, "{full:?}"); + let ring = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "offset": 20})).visual_bounds().unwrap(); + assert!((ring.width() - 100.0).abs() < 2.0, "{ring:?}"); + // About its right edge the axis is at x 130, so the surface reaches x 160. + let right = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "edge": "right"})).visual_bounds().unwrap(); + assert!((right.x1 - 160.0).abs() < 2.0, "{right:?}"); + assert!(facets(&json!({"angle": 90})) < facets(&json!({}))); + // The profile's ends lie on the axis, so they close nothing until an offset moves them off it. + assert!(facets(&json!({"cap": false, "offset": 10})) < facets(&json!({"offset": 10})), "hollow leaves out the end caps"); + } + + #[test] + fn a_group_revolves_its_members_about_one_shared_axis() { + let mut b = BezPath::new(); + b.move_to((150.0, 60.0)); + b.line_to((170.0, 60.0)); + b.line_to((170.0, 140.0)); + let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(profile()), std::sync::Arc::new(outer)]); + g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).unwrap()); + let art = crate::revolve_art(&g).unwrap(); + // The axis is at the left edge of both members together, x 100, so the outer one reaches 70 + // out on either side of it. + let bounds = art.visual_bounds().unwrap(); + assert!((bounds.x0 - 30.0).abs() < 2.0 && (bounds.x1 - 170.0).abs() < 2.0, "{bounds:?}"); + let mut colors = vec![]; + art.walk(&mut |n| colors.extend(fill(n).map(|c| c.to_rgb()))); + assert!(colors.iter().any(|c| c[2] > c[0]) && colors.iter().any(|c| c[0] == c[2]), "both members' paints are drawn"); + } + + /// A group member in a gradient with transparent stops keeps the shared back-to-front order of + /// every member's faces: its faces go in runs that carry its opacity mask, and the other + /// member's faces are drawn between them where they are in front of some and behind others. + #[test] + fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let art = |end_opacity: f32| { + let mut end = GradientStop::new(1.0, Color::BLACK); + end.opacity = end_opacity; + let gradient = + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end])))); + let mut b = BezPath::new(); + b.move_to((150.0, 60.0)); + b.line_to((170.0, 60.0)); + b.line_to((170.0, 140.0)); + let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(outer), std::sync::Arc::new(profile())]); + g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({})).unwrap()); + crate::revolve_art(&g).unwrap() + }; + // The drawing order: `M` for a group with an opacity mask, `F` for a face outside one. + fn visit(n: &Node, order: &mut String) { + if n.extras().mask.is_some() { + order.push('M'); + return; + } + if n.path_data().is_some() { + order.push('F'); + } + for c in n.children().into_iter().flatten() { + visit(c, order); + } + } + let order = |art: &Node| { + let mut order = String::new(); + visit(art, &mut order); + order + }; + assert!(!order(&art(1.0)).contains('M'), "an opaque gradient needs no mask"); + let masked = order(&art(0.5)); + let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap()); + assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "faces between the member's runs: {masked}"); + } + + #[test] + fn bad_parameters_are_refused() { + let check = |p: Value| crate::validate_three_d(&profile(), CLASSIC_REVOLVE, &crate::merged_params(CLASSIC_REVOLVE, &p)); + assert_eq!(check(json!({})), Ok(())); + for (p, error) in [ + (json!({"segments": 32}), "unknown parameter `segments`"), + (json!({"angle": 400}), "`angle` must be between 0 and 360"), + (json!({"offset": 1001}), "`offset` must be between 0 and 1000"), + (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!({"surface": "glossy"}), "`surface` must be one of"), + (json!({"shadingColor": "white"}), "`shadingColor` must be one of"), + (json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"edge": "middle"}), "Revolve edge must be left or right"), + (json!({"cap": "yes"}), "Revolve cap must be a boolean"), + ] { + let e = check(p.clone()).unwrap_err(); + assert!(e.contains(error), "{p}: {e}"); + } + } +} diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ff5b1c9711..8d9ff9eea8 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1,4 +1,4 @@ -//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art, +//! Extrude & Bevel, Inflate, Rotate, Extrude & Bevel (Classic) and Rotate (Classic) as live, shaded vector art, //! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces. use std::sync::Arc; @@ -14,8 +14,8 @@ use vectorcraft_doc::color::Color; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry}; -mod classic; -pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; +pub(crate) mod classic; +pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLORS, classic_extrude_options, classic_shading}; mod source; pub(crate) use source::{resolve, resolve_for}; @@ -26,11 +26,22 @@ pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; /// The 3D effects this module draws (Revolve has its own). -pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE]; +pub const SOLIDS: [&str; 5] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE, CLASSIC_ROTATE]; -/// Is `id` one of the 3D and Materials effects (Revolve included)? +/// Is `id` one of the 3D and Materials effects (Revolve and Revolve (Classic) included)? pub fn is_three_d(id: &str) -> bool { - id == crate::REVOLVE || SOLIDS.contains(&id) + crate::revolve::is_revolve(id) || SOLIDS.contains(&id) +} + +/// Is `id` one of the 3D (Classic) effects: Extrude & Bevel (Classic), Revolve (Classic) or Rotate +/// (Classic)? +pub fn is_classic_three_d(id: &str) -> bool { + id == CLASSIC_EXTRUDE || id == crate::CLASSIC_REVOLVE || id == CLASSIC_ROTATE +} + +/// Does 3D effect `id` turn the art as a flat plane: Rotate or Rotate (Classic)? +fn is_plane(id: &str) -> bool { + id == ROTATE || id == CLASSIC_ROTATE } /// The 3D and Materials panel's object types and the effect that draws each: (type, effect id). @@ -193,6 +204,9 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { if id == CLASSIC_EXTRUDE { classic::validate(p)?; } + if id == CLASSIC_ROTATE { + classic::validate_rotate(p)?; + } crate::lighting::validate(p)?; shadow::validate(p)?; if let Some(v) = p.get("bevel") @@ -225,7 +239,7 @@ pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option<usize>) -> Result<(), String> { validate_solid_params(id, p)?; let label = label_of(id); - let n = &*source::resolve_for(doc, n, label, id == ROTATE)?; + let n = &*source::resolve_for(doc, n, label, is_plane(id))?; if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; @@ -417,7 +431,7 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]). pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { - if id == crate::REVOLVE { crate::revolve::validate_revolve_in(doc, n, p) } else { validate_solid_in(doc, n, id, p) } + if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) } } /// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of @@ -436,7 +450,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va Some(_) if is_container(n) || source::resolves(n) => { Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) } - Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index), + Some((item, index)) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index), Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)), None => validate_three_d_in(doc, n, id, p), } @@ -446,7 +460,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va /// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does /// not need to fit. pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> { - if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) } + if crate::revolve::is_revolve(id) { crate::revolve::validate_params_of(id, p) } else { validate_solid_params(id, p) } } /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and @@ -708,12 +722,12 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<ShadowAndSurface /// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on /// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the /// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow -/// off, for Extrude & Bevel (Classic) or with no shapes. A stroke's solid that is not built (past +/// off, for Extrude & Bevel (Classic) and Rotate (Classic) or with no shapes. A stroke's solid that is not built (past /// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error. /// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to /// the part of the shadow they cast too ([`shadow::scoped_art`]). fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<Cast<'a>>, String> { - if id == CLASSIC_EXTRUDE { + if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE { return Ok(None); } let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) }; @@ -775,12 +789,12 @@ type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); - let classic = fx.id == CLASSIC_EXTRUDE; + let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE; let colored = !classic && view_options(&p).has_colored_lights(); let masks = has_masks(shapes); // The paint copies of every solid together: past the allowance the source path is drawn. let mut paint_bytes = 0usize; - if fx.id == ROTATE { + if is_plane(&fx.id) { // Every part turns about the centre of the object's own bounds (a group's members about // the centre of theirs), so they stay together. let mut out = vec![]; @@ -792,7 +806,8 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) { return None; } - out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored)))); + let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; + out.push((s.scopes.as_slice(), Arc::new(art))); } } return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); @@ -1001,7 +1016,7 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) let view = view_options(&p); let mut out = vec![]; let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit); - if fx.id == ROTATE { + if is_plane(&fx.id) { for s in shapes { for item in &s.items { let (paint, opacity, _) = paint_of(item, s.opacity); diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 730fb8e53b..919de831ce 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -1,6 +1,6 @@ //! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic //! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading -//! color. +//! color. Rotate (Classic): the plane Rotate turns, drawn with the No Shading and Diffuse surfaces. use std::sync::Arc; use serde_json::Value; @@ -12,6 +12,10 @@ use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Ext use crate::util::{flag, num, text}; pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude"; +pub const CLASSIC_ROTATE: &str = "threeD.classicRotate"; + +/// The surfaces Rotate (Classic) takes: No Shading and Diffuse. +pub const ROTATE_SURFACES: [&str; 2] = ["none", "diffuse"]; /// The width of a Wireframe edge, in points. const WIRE: f64 = 0.5; @@ -104,8 +108,39 @@ const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces" /// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and /// `shadingCustom` name a surface, a shading color and a #rrggbb color. pub fn validate(p: &Value) -> Result<(), String> { + validate_classic(CLASSIC_EXTRUDE, p, &RANGES, &FLAGS) +} + +/// Rotate (Classic)'s numeric parameters: (key, lowest, highest, whole numbers only). +const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [ + ("rotationX", -180.0, 180.0, false), + ("rotationY", -180.0, 180.0, false), + ("rotationZ", -180.0, 180.0, false), + ("perspectiveAngle", 0.0, 160.0, false), + ("lightRotation", -180.0, 180.0, false), + ("lightHeight", 0.0, 90.0, false), + ("lightIntensity", 0.0, 100.0, false), + ("ambient", 0.0, 100.0, false), + ("blendSteps", 1.0, 256.0, true), +]; + +/// 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"])?; + 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(", "))); + } + Ok(()) +} + +/// Check the parameters `p` of classic 3D effect `id` (its defaults filled in): every key is one of +/// its parameters, the numbers `ranges` lists, as (key, lowest, highest, whole numbers only), are in +/// range, the `flags` are true or false, and `surface`, `shadingColor` and `shadingCustom` name a +/// surface, a shading color and a #rrggbb color. +pub(crate) fn validate_classic(id: &str, p: &Value, ranges: &[(&str, f64, f64, bool)], flags: &[&str]) -> Result<(), String> { let Some(map) = p.as_object() else { return Ok(()) }; - let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null); + let defaults = crate::default_params(id).unwrap_or(Value::Null); let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default(); for key in map.keys() { if !known.contains(&key.as_str()) { @@ -113,7 +148,7 @@ pub fn validate(p: &Value) -> Result<(), String> { return Err(format!("unknown parameter `{key}`; the command takes: {keys}")); } } - for (key, min, max, whole) in RANGES { + for &(key, min, max, whole) in ranges { let Some(v) = map.get(key) else { continue }; let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) }; if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) { @@ -123,7 +158,7 @@ pub fn validate(p: &Value) -> Result<(), String> { return Err(format!("`{key}` must be between {min} and {max} (got {x})")); } } - for key in FLAGS { + for &key in flags { if map.get(key).is_some_and(|v| !v.is_boolean()) { return Err(format!("`{key}` must be true or false")); } @@ -161,7 +196,7 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option<Color>) -> Color { /// 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 own transparency, so the shading follows the gradient's coverage. -pub(super) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { +pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { let shading = classic_shading(p); let lights = lights(p); let shade = shading_color(p); diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 66ca2c1d72..585f23bd6b 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -55,7 +55,7 @@ fn needs(n: &Node) -> bool { /// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut /// for the solid effect `n` carries ([`resolve_for`]). pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result<Cow<'a, Node>, String> { - let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE); + let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| super::is_plane(&fx.id)); resolve_for(doc, n, label, every_fill) } diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 248473f428..c960bbe671 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -889,3 +889,80 @@ fn classic_parameters_are_checked() { assert!(err.starts_with(says), "{p}: {err}"); } } + +/// The red square, stroked black, with Rotate (Classic) and `params`: its surfaces, the fill's +/// plane first, then the stroke's outline. +fn classic_rotate(params: Value) -> Vec<Node> { + let mut n = square_node(); + n.appearance.effects.push(fx(CLASSIC_ROTATE, params)); + solid_art(&n).unwrap().children().unwrap().iter().map(|s| (**s).clone()).collect() +} + +#[test] +fn classic_rotate_turns_every_fill_and_stroke_as_one_plane() { + let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0}); + let surfaces = classic_rotate(front.clone()); + assert_eq!(surfaces.len(), 2, "the fill's plane and the stroke's outline"); + assert!(surfaces.iter().all(|s| s.children().unwrap().len() == 1), "each is one flat face"); + let fill = surfaces[0].visual_bounds().unwrap(); + assert!((fill.width() - 100.0).abs() < 1.0 && (fill.height() - 100.0).abs() < 1.0, "{fill:?}"); + // Turned 60° about the vertical axis, the square shows half its width and all its height, + // about the center of its bounds. + let turned = classic_rotate(json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}))[0].visual_bounds().unwrap(); + assert!((turned.width() - 50.0).abs() < 1.0 && (turned.height() - 100.0).abs() < 1.0, "{turned:?}"); + assert!((turned.center().x - 150.0).abs() < 1.0, "{turned:?}"); + // Turned past 90°, the back of the plane is drawn. + let back = classic_rotate(json!({"rotationX": 0, "rotationY": 150, "rotationZ": 0})); + assert!(back[0].visual_bounds().is_some_and(|b| (b.width() - 100.0 * 30f64.to_radians().cos()).abs() < 1.0)); +} + +#[test] +fn classic_rotate_shades_with_no_shading_or_diffuse() { + let rgb = |p: Value| fill_rgb(&classic_rotate(p)[0].children().unwrap()[0]); + // No Shading keeps the paint, the color itself kept. + let mut n = square_node(); + n.appearance.effects.push(fx(CLASSIC_ROTATE, json!({"surface": "none"}))); + let art = solid_art(&n).unwrap(); + let AppearanceItem::Fill(f) = &faces(&art)[0].appearance.items[0] else { panic!("a fill first") }; + assert_eq!(f.paint, Paint::solid(RED)); + // Diffuse: straight on with the light in front, the face is fully lit; turned away from the + // light, it is the red darkened toward black. + let lit = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "lightHeight": 90}); + assert!(close3(rgb(lit), RED.to_rgb())); + let [r, g, b] = rgb(json!({"rotationX": 0, "rotationY": 70, "rotationZ": 0, "lightHeight": 90})); + assert!(r < 0.8 - 0.05 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4, "{r} {g} {b}"); + // No light: the ambient share of the red. A custom shading color darkens toward that color; + // None keeps the paint. + let dark = rgb(json!({"lightIntensity": 0, "ambient": 20, "blendSteps": 256})); + assert!((dark[0] - 0.8 * 0.2).abs() < 0.01, "{dark:?}"); + let blue = rgb(json!({"lightIntensity": 0, "ambient": 20, "shadingColor": "custom", "shadingCustom": "#0000ff"})); + assert!(blue[2] > blue[0] / 8.0 + 0.01, "{blue:?}"); + assert!(close3(rgb(json!({"lightIntensity": 0, "shadingColor": "none"})), RED.to_rgb())); +} + +#[test] +fn classic_rotate_parameters_are_checked() { + let n = square_node(); + let check = |p: Value| validate_solid(&n, CLASSIC_ROTATE, &crate::merged_params(CLASSIC_ROTATE, &p)); + assert!(check(json!({})).is_ok()); + assert!(check(json!({"surface": "none", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 1})).is_ok()); + for (p, says) in [ + (json!({"surface": "plastic"}), "`surface` must be one of none, diffuse"), + (json!({"surface": "wireframe"}), "`surface` must be one of none, diffuse"), + (json!({"surface": 2}), "`surface` must be one of"), + (json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"), + (json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"rotationX": 181}), "`rotationX` must be between -180 and 180 (got 181)"), + (json!({"perspectiveAngle": 161}), "`perspectiveAngle` must be between 0 and 160 (got 161)"), + (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!({"highlightSize": 50}), "unknown parameter `highlightSize`"), + (json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"), + (json!({"shadow": true}), "unknown parameter `shadow`"), + (json!({"lights": []}), "lights must contain one to ten lights"), + ] { + let err = check(p.clone()).unwrap_err(); + assert!(err.starts_with(says), "{p}: {err}"); + } +} diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 532251bd73..e63b38a462 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -209,6 +209,57 @@ fn warp_arc_bends_the_top_edge() { assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}"); } +/// A Warp of `version` 1 bends its bounds as its style's Bézier net; one without a version, as +/// saved before the version existed, draws as it always did. Flag at 60 % on a 200 × 100 box: the +/// net moves the top edge up to 6 × 0.6 × 100 × max u(1 - u)(1 - 2u) = 34.64 out, the earlier map +/// moves it 0.6 × 25 = 15. +#[test] +fn a_warp_draws_with_the_map_its_version_names() { + let rect = shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 120.0)); + // u(1 - u)(1 - 2u) peaks at √3 / 18. + let peak = 360.0 * 3f64.sqrt() / 18.0; + for (version, out) in [(json!(1), peak), (json!(null), 15.0), (json!(0), 15.0)] { + let b = run("warp.flag", json!({"bend": 60, "version": version}), &rect).bounds().unwrap(); + assert!(close(b.y0, 20.0 - out, 0.1) && close(b.y1, 120.0 + out, 0.1), "version {version}: {b:?}"); + assert!(close(b.x0, 10.0, 1e-9) && close(b.x1, 210.0, 1e-9), "version {version}: {b:?}"); + } +} + +/// A straight line has a box with no width or no height, and the net bends it as the middle of +/// the patch along that side: under Arc a vertical line stays vertical, and under Bulge, Fisheye, +/// Inflate and Squeeze a horizontal line stays straight. +#[test] +fn a_warp_bends_a_flat_box_as_the_middle_of_its_patch() { + let anchors = |p: &PathData| p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect::<Vec<_>>(); + let vertical = shapes::line(Point::new(100.0, 0.0), Point::new(100.0, 100.0)); + let out = run("warp.arc", json!({"bend": 50, "version": 1}), &vertical); + assert!(anchors(&out).iter().all(|a| close(a.p.x, 100.0, 1e-9) && close(a.h_in.x, 100.0, 1e-9) && close(a.h_out.x, 100.0, 1e-9)), "{out:?}"); + let horizontal = shapes::line(Point::new(0.0, 50.0), Point::new(200.0, 50.0)); + for style in ["bulge", "fisheye", "inflate", "squeeze"] { + let out = run(&format!("warp.{style}"), json!({"bend": 50, "version": 1}), &horizontal); + assert!( + anchors(&out).iter().all(|a| close(a.p.y, 50.0, 1e-9) && close(a.h_in.y, 50.0, 1e-9) && close(a.h_out.y, 50.0, 1e-9)), + "{style}: {out:?}" + ); + } +} + +/// A new Warp takes the nets' version unless one is given, and the catalog's defaults carry none, +/// so a saved warp's params don't gain one. +#[test] +fn new_warps_take_the_nets_version() { + for (s, _) in WARP_STYLES { + let id = format!("warp.{s}"); + let Some(e) = crate::new_effect(&id, &json!({"bend": 20})) else { panic!("{id}") }; + assert_eq!(e.params["version"], json!(crate::WARP_NET), "{id}"); + let Some(e) = crate::new_effect(&id, &json!({"version": 0})) else { panic!("{id}") }; + assert_eq!(e.params["version"], json!(0), "{id}"); + assert!(crate::merged_params(&id, &json!({"bend": 20})).get("version").is_none(), "{id}"); + } + let Some(e) = crate::new_effect("distort.twist", &json!({})) else { panic!("twist") }; + assert!(e.params.get("version").is_none()); +} + #[test] fn transform_copies_and_move() { let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square()); @@ -318,6 +369,317 @@ fn stacked_transform_copies_stay_within_the_image_budget() { assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001); } +/// A 40 × 40 px image placed at (0, 0) at one point a pixel, with id `id`. +fn warp_test_image(id: u64) -> std::sync::Arc<Node> { + use vectorcraft_doc::{ImageObject, NodeId}; + let im = ImageObject { key: "k".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + std::sync::Arc::new(Node::new(NodeId(id), NodeKind::Image(Box::new(im)))) +} + +/// How many images `art` holds, and the keys they have. +fn warp_test_images(art: &Node) -> (usize, std::collections::BTreeSet<String>) { + let (mut n, mut keys) = (0, std::collections::BTreeSet::new()); + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + n += 1; + keys.insert(im.key.clone()); + } + }); + (n, keys) +} + +#[test] +fn warped_images_stay_within_the_object_budget() { + use std::rc::Rc; + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1)); + let cost = vectorcraft_doc::live::mesh_warp_cost(crate::reshape::WARP_IMAGE_CELLS); + let none = std::collections::HashSet::new(); + let k = || std::collections::BTreeSet::from(["k".to_string()]); + let warp = |art: &mut Node, warps: usize| { + let mut warped = crate::reshape::WarpedImages::new(); + for _ in 0..warps { + crate::reshape::mark_warped_images(art, &lift, &none, false, &mut 0, &mut warped); + } + warped + }; + // The art has room for one image's pieces only: neither image is cut, and each gets its own + // key back. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]); + let warped = warp(&mut art, 1); + let max = art.count() + cost; + crate::reshape::cut_warped_images_within(&mut art, &warped, max); + assert_eq!(warp_test_images(&art), (2, k())); + // With room for both, each is cut into 200 pieces once, however many warps it took. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]); + let warped = warp(&mut art, 3); + let max = art.count() + 2 * cost; + crate::reshape::cut_warped_images_within(&mut art, &warped, max); + assert_eq!(warp_test_images(&art), (400, k())); + // Images clipped by their frames: the second frame (the second path) kept its shape, so its + // image stays whole, and the first is cut. The third's frame is a group of two paths, the + // second of them kept, with an image after them, and its image sits in a group of its own: + // both stay whole too. + let frame = |id: u64| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Default::default())); + let clip_group = |id: u64, children: Vec<Arc<Node>>| Arc::new(Node::new(NodeId(id), NodeKind::Group { children, clip: true })); + let nested = clip_group( + 4, + vec![ + Arc::new(Node::group(NodeId(4), vec![frame(4), frame(4), warp_test_image(5)])), + Arc::new(Node::group(NodeId(4), vec![warp_test_image(4)])), + ], + ); + let mut art = + Node::group(NodeId(1), vec![clip_group(2, vec![frame(2), warp_test_image(2)]), clip_group(3, vec![frame(3), warp_test_image(3)]), nested]); + let (mut at, mut warped) = (0, crate::reshape::WarpedImages::new()); + crate::reshape::mark_warped_images(&mut art, &lift, &std::collections::HashSet::from([1, 3]), false, &mut at, &mut warped); + assert_eq!(at, 4); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art), (203, k())); +} + +/// An image key from the document that looks like a key of the table is not read as one, and a +/// hidden image stays out of the bounds the later effects take, as it does before it is warped. +#[test] +fn warped_images_keep_document_keys_and_hidden_images_apart() { + use std::rc::Rc; + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1)); + let none = std::collections::HashSet::new(); + let mut warped = crate::reshape::WarpedImages::new(); + let lookalike = format!("{}0", warped.prefix()); + let mut b = (*warp_test_image(3)).clone(); + if let NodeKind::Image(im) = &mut b.kind { + im.key = lookalike.clone(); + } + let mut art = Node::group(NodeId(1), vec![warp_test_image(2), Arc::new(b)]); + warped.reserve(&art); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art), (400, std::collections::BTreeSet::from(["k".to_string(), lookalike]))); + // A layer of a 10 × 10 square and a hidden 40 × 40 image: the warped image is not measured. + let square = Arc::new(Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default())); + let mut hidden = (*warp_test_image(5)).clone(); + hidden.visible = false; + let mut layer = Node::layer(NodeId(6), "Layer 1", vectorcraft_doc::LayerColor::Preset(0)); + if let Some(children) = layer.children_mut() { + *children = vec![square, Arc::new(hidden)]; + } + let before = layer.geometric_bounds(); + let mut warped = crate::reshape::WarpedImages::new(); + warped.reserve(&layer); + crate::reshape::mark_warped_images(&mut layer, &lift, &none, false, &mut 0, &mut warped); + assert_eq!(warped.bounds(&layer), before); + // A shown image is measured by the pieces it is cut into. + let mut art = Node::group(NodeId(1), vec![warp_test_image(2)]); + let mut warped = crate::reshape::WarpedImages::new(); + warped.reserve(&art); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + let measured = warped.bounds(&art); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(measured, art.geometric_bounds()); + assert!(measured.is_some_and(|b| b.y0 < -3.9), "{measured:?}"); + // An image placed at a tiny scale (a placement with a tiny determinant) is cut too; one placed + // with no area is not. + for (scale, pieces) in [(1e-7, 200), (0.0, 1)] { + let mut image = (*warp_test_image(2)).clone(); + if let NodeKind::Image(im) = &mut image.kind { + im.xf = Affine::scale(scale); + } + let mut art = Node::group(NodeId(1), vec![Arc::new(image)]); + let mut warped = crate::reshape::WarpedImages::new(); + crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped); + crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX); + assert_eq!(warp_test_images(&art).0, pieces, "{scale}"); + } +} + +/// An image whose frame keeps its shape because the warp would pass the anchor budget keeps its +/// pixels as they are; within the budget, both bend. +#[test] +fn an_image_whose_frame_passes_the_budget_stays_whole() { + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let clipped = || { + let mut frame = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut frame.kind { + *clipping = true; + } + Node::new(NodeId(1), NodeKind::Group { children: vec![Arc::new(frame), warp_test_image(1)], clip: true }) + }; + let arc = fx("warp.arc", json!({"bend": 50, "version": 1})); + let k = || std::collections::BTreeSet::from(["k".to_string()]); + let mut art = clipped(); + crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget { anchors: 0, lines: 0 }, None); + assert_eq!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).map(|p| p.anchor_count()), Some(4)); + assert_eq!(warp_test_images(&art), (1, k())); + let mut art = clipped(); + crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget::EFFECTS, None); + assert!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).is_some_and(|p| p.anchor_count() > 4)); + assert_eq!(warp_test_images(&art), (200, k())); +} + +/// The first image under `n` (the first piece of a cut image). +fn first_image(n: &Node) -> Option<vectorcraft_doc::ImageObject> { + let mut first = None; + n.walk(&mut |c| { + if first.is_none() + && let NodeKind::Image(im) = &c.kind + { + first = Some((**im).clone()); + } + }); + first +} + +#[test] +fn effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces() { + let image = |effects: Vec<Effect>| { + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = effects; + n + }; + // Each piece's clipping path, and the image's frame (the outer clip group's clipping path). + let clips = |art: &Node| { + let (mut pieces, mut frame) = (vec![], None); + art.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let Some(path) = children.first().and_then(|p| p.path_data()) + { + if matches!(children.get(1).map(|i| &i.kind), Some(NodeKind::Image(_))) && path.anchor_count() == 3 { + pieces.push(path.anchor_count()); + } else if frame.is_none() { + frame = Some(path.anchor_count()); + } + } + }); + (pieces, frame) + }; + let arc = fx("warp.arc", json!({"bend": 50, "version": 1})); + for after in [ + fx("distort.roughen", json!({"size": 5, "detail": 10})), + fx("stylize.roundCorners", json!({"radius": 5})), + fx("path.offsetPath", json!({"offset": -2})), + ] { + let art = crate::reshape::reshape(None, &image(vec![arc.clone(), after.clone()]), None).unwrap(); + let (pieces, frame) = clips(&art); + assert_eq!(pieces.len(), 200, "{}", after.id); + assert!(frame.is_some_and(|n| n > 4), "{}: {frame:?}", after.id); + assert_eq!(warp_test_images(&art), (200, std::collections::BTreeSet::from(["k".to_string()])), "{}", after.id); + } + // Two warps cut the image once, through both maps; a Transform after them moves the pieces. + let flag = fx("warp.flag", json!({"bend": 30, "version": 1})); + let mv = fx("distort.transform", json!({"moveH": 100, "moveV": 0})); + let art = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone()]), None).unwrap(); + assert_eq!(warp_test_images(&art).0, 200); + let moved = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone(), mv]), None).unwrap(); + assert_eq!(warp_test_images(&moved).0, 200); + let box1 = Rect::new(0.0, 0.0, 40.0, 40.0); + let f1 = crate::warp::warp_map(box1, crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &arc.params)); + // The flag bends the box the arc leaves, which the arc's frame measures. + let box2 = crate::reshape::reshape(None, &image(vec![arc.clone()]), None).unwrap().geometric_bounds().unwrap(); + let f2 = crate::warp::warp_map(box2, crate::warp::WarpStyle::Flag, &merged_params("warp.flag", &flag.params)); + // The first piece's corners: the pixels (0, 0), (4, 0) and (4, 4). + for (art, by) in [(&art, 0.0), (&moved, 100.0)] { + let piece = first_image(art).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] { + let want = f2(f1(p)) + vectorcraft_geom::Vec2::new(by, 0.0); + assert!((piece.xf * p).distance(want) < 1e-6, "{p:?}: {:?} {want:?}", piece.xf * p); + } + } +} + +/// A warp with no version (VectorCraft's earlier map) bends an image's pixels with that map, as it +/// bends the frame. +#[test] +fn an_images_pixels_take_the_map_their_warps_version_names() { + for version in [json!(null), json!(0), json!(1)] { + let mut params = json!({"bend": 50}); + if !version.is_null() { + params["version"] = version.clone(); + } + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = vec![fx("warp.arc", params.clone())]; + let art = crate::reshape::reshape(None, &n, None).unwrap(); + let f = crate::warp::warp_map(Rect::new(0.0, 0.0, 40.0, 40.0), crate::warp::WarpStyle::Arc, &merged_params("warp.arc", ¶ms)); + let piece = first_image(&art).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] { + assert!((piece.xf * p).distance(f(p)) < 1e-6, "{version}: {p:?}"); + } + // The frame has an anchor where the map takes each corner. + let frame = art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).unwrap(); + for corner in [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 40.0), Point::new(0.0, 40.0)] { + let near = frame.subpaths.iter().flat_map(|s| &s.anchors).any(|a| a.p.distance(f(corner)) < 1e-6); + assert!(near, "{version}: {corner:?}"); + } + } +} + +/// Under a group's Warp effect, a nested group's own placed gradient follows the nested group's +/// bounds to where its image's pieces draw them. +#[test] +fn a_nested_groups_gradient_follows_its_warped_image() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeId}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let geom = GradientGeom { start: (5.0, 20.0).into(), end: (35.0, 20.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut inner = Node::group(NodeId(5), vec![warp_test_image(1)]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed))); + let square = Node::path(NodeId(2), shapes::rectangle(Rect::new(60.0, 0.0, 100.0, 40.0)), Appearance::default()); + let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(square)]); + g.appearance.effects.push(fx("warp.arc", json!({"bend": 50, "version": 1}))); + let art = crate::group::evaluate_container(None, &g).unwrap(); + assert_eq!(warp_test_images(&art).0, 200); + let (from, mut to) = (Rect::new(0.0, 0.0, 40.0, 40.0), None); + art.walk(&mut |c| { + if c.id == NodeId(5) && to.is_none() { + to = c.geometric_bounds(); + } + }); + let to = to.unwrap(); + assert!(to.y0 < from.y0 - 1.0, "the pieces draw the image bent: {to:?}"); + let follow = |p: Point| Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height()); + let mut checked = 0; + art.walk(&mut |c| { + if c.id != NodeId(5) { + return; + } + for item in &c.appearance.items { + let Paint::Gradient(gp) = item.paint() else { continue }; + let got = gp.geom.unwrap(); + assert!(got.start.distance(follow(geom.start)) < 1e-6 && got.end.distance(follow(geom.end)) < 1e-6, "{got:?} in {to:?}"); + checked += 1; + } + }); + assert!(checked > 0); +} + +/// An image under a geometry effect other than a Warp draws as the image does: colorized by +/// a grayscale image's fill color, and smoothed or not. +#[test] +fn an_image_under_a_geometry_effect_keeps_its_color_and_smoothing() { + let mut n = (*warp_test_image(1)).clone(); + n.appearance.effects = vec![fx("distort.roughen", json!({"size": 5, "detail": 10}))]; + n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)))); + n.extras_mut().pixelated = true; + let art = crate::reshape::reshape(None, &n, None).unwrap(); + let mut seen = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Image(_)) { + seen.push((c.extras().colorize.clone(), c.extras().pixelated)); + } + }); + assert_eq!(seen, vec![(n.extras().colorize.clone(), true)]); +} + /// What `effects` make of `path` within `budget`. fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData { apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget) diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index 84f196a8b4..4b117d4142 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -1,25 +1,49 @@ //! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion. //! -//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is -//! split into short pieces whose control points are mapped (see `map_nonlinear`). +//! A warp with `version` [`WARP_NET`] is its style's Bézier patch over the box ([`WarpNet`]); +//! one without (saved before the version existed) is a map on normalized box coordinates +//! (x, y) ∈ [-1, 1]² (y down), [`warp_point`]. The path is split into short pieces whose control +//! points are mapped (see `map_nonlinear`). use serde_json::Value; use vectorcraft_geom::{PathData, Point, Rect}; use crate::util::{map_nonlinear_within, num, text}; -pub use vectorcraft_doc::live::{WarpStyle, warp_point}; +pub use vectorcraft_doc::live::{WARP_NET, WarpNet, WarpStyle, uses_net, warp_point, warp_version}; + +/// The map version warp params `p` ask for: their `version`, 0 (the first map) when absent. +pub fn map_version(p: &Value) -> u32 { + let v = num(p, "version", 0.0); + if v.is_finite() && v >= 0.0 { v.min(f64::from(u32::MAX)) as u32 } else { 0 } +} /// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]). pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData { + let piece = b.width().hypot(b.height()) / 24.0; + map_nonlinear_within(path, piece, max, warp_map(b, style, p)) +} + +/// The point map a warp of `style` with params `p` applies to box `b`: what [`warp`] moves each +/// point of a path to. +pub(crate) fn warp_map(b: Rect, style: WarpStyle, p: &Value) -> Box<dyn Fn(Point) -> Point> { let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0; let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0; let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0; let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical"); + if uses_net(map_version(p)) { + let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical); + let (w, h) = (b.width(), b.height()); + // A box with no width or no height takes the middle of the patch along that side. + return Box::new(move |q: Point| { + let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 }); + b.origin() + net.at(u, v).to_vec2() + }); + } let c = b.center(); let hw = (b.width() / 2.0).max(1e-9); let hh = (b.height() / 2.0).max(1e-9); - let f = |q: Point| { + Box::new(move |q: Point| { let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh); let (x2, y2) = if vertical { let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x); @@ -28,18 +52,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) - warp_point(style, bend, dh, dv, hh / hw, x, y) }; Point::new(c.x + x2 * hw, c.y + y2 * hh) - }; - let piece = b.width().hypot(b.height()) / 24.0; - map_nonlinear_within(path, piece, max, f) + }) } /// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with -/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the -/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline` -/// tests both sides of it). +/// no bend and no distortion. With the first map, Arc (`warp.arc`) takes a bend under 0.0001 % as +/// none: the threshold must match the one in [`warp_point`]'s Arc case +/// (`warp_bends_when_the_warp_moves_the_outline` tests both sides of it). With [`WarpNet`] any +/// bend moves the outline. pub(crate) fn bends(id: &str, p: &Value) -> bool { let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0; let bend = share("bend", 50.0); - let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 }; + let bent = if id == "warp.arc" && !uses_net(map_version(p)) { bend.abs() >= 1e-6 } else { bend != 0.0 }; bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0 } diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 4c4671f6b0..0815404835 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -5,6 +5,7 @@ mod arrange; mod cleanup; mod color; mod contour; +mod cutting; mod document; mod image; mod pathtools; @@ -23,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> { v.extend(cleanup::specs()); v.extend(color::specs()); v.extend(contour::specs()); + v.extend(cutting::specs()); v.extend(document::specs()); v.extend(image::specs()); v.extend(pathtools::specs()); diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs new file mode 100644 index 0000000000..8412af9a68 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -0,0 +1,11 @@ +//! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil +//! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together +//! ([`stencil`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's. + +mod stencil; + +use super::super::CommandSpec; + +pub fn specs() -> Vec<CommandSpec> { + stencil::specs() +} diff --git a/crates/engine/src/cmd/addons/cutting/stencil.rs b/crates/engine/src/cmd/addons/cutting/stencil.rs new file mode 100644 index 0000000000..a8b4cfe591 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/stencil.rs @@ -0,0 +1,167 @@ +//! Stencil Bridges (`addon.cutting.bridges`), a command of the built-in Cutting Tools add-on: it +//! cuts bridges out of the selected art so that a stencil cut from it holds together. Each island +//! of the stencil sheet (the inside of an O, a hole in a shape) stays joined to the sheet by strips +//! of a set width across the art (<https://en.wikipedia.org/wiki/Stencil> describes islands and +//! bridges). The art becomes one compound path with the gaps cut, and type is measured by its +//! glyph outlines. The original goes to a new hidden layer or is replaced. The geometry is +//! `vectorcraft_pathops::addons::stencil`. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{FillRule, PathData}; +use vectorcraft_pathops::addons::stencil::{BRIDGE_WIDTH_RANGE, BridgeDirection, BridgeSpec, MAX_BRIDGES_PER_ISLAND, stencil_bridges}; + +use super::super::super::edit::selected_roots; +use super::super::super::object::choice; +use super::super::super::pathops::{length_given, node_path, pf_leaves, shape_node, style_of}; +use super::super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or}; +use crate::{EngineError, Result, Session}; + +/// Stencil Bridges' command (the id in [`specs`]). +pub const BRIDGES: &str = "addon.cutting.bridges"; +/// The ways bridges run, as `direction` takes them. +pub const DIRECTIONS: [&str; 3] = ["vertical", "horizontal", "shortest"]; +/// A bridge's width when none is given, in points. +pub const DEFAULT_WIDTH: f64 = 2.0; +/// The name of the hidden layer the original art goes to (numbered when it is taken). +const ORIGINAL_LAYER: &str = "Stencil Original"; + +pub fn specs() -> Vec<CommandSpec> { + vec![cmd!( + "addon.cutting.bridges", + "Stencil Bridges…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{width?: pt 0.01..1000 (2), count?: whole number 1..16 of bridges each island gets (1; fewer where it has no room and more where other islands are joined to the sheet through it), direction?: \"vertical\" | \"horizontal\" | \"shortest\" (the shortest crossing among 12 directions 15° apart) (\"vertical\"), keepOriginal?: true (the selected objects that make up the art go to a new hidden layer named \"Stencil Original\"; false: they are replaced)} cut bridges out of the selected art so that a stencil cut from it holds together: every island (an area the art encloses, such as the inside of an O or a hole in a shape) is joined to the area around the art by strips `width` wide across the art, the shortest crossings first, then spread around the island. The art is measured by the area its outlines enclose, whatever their paint (open paths closed as a fill closes them; type by its glyph outlines; groups by their members; hidden objects left out) and becomes one compound path with the gaps cut, painted as the topmost selected shape, in the place of the topmost selected object that has outlines. Selected objects with no outlines in the art stay where they are. Art with no islands, or none a bridge of the width fits, is left as it is, with a warning and no undo step; islands the cut result still encloses are counted in `unbridged`, with a warning. Art that reaches more than 10000000 pt from the origin, has more than 200000 anchors, whose combined area has more than 4000 outlines, whose outlines need more than 200000 straight segments, or that takes more work to measure than a fixed budget allows is refused. One undo step; the new path is selected → {id, islands, bridges, unbridged, original (the hidden layer's id, or null), warning?}", + has_selection, + bridges + )] +} + +fn bridges(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = BRIDGES; + let width = length_given(p, "width", C)?.unwrap_or(DEFAULT_WIDTH); + let (lo, hi) = BRIDGE_WIDTH_RANGE; + if !(lo..=hi).contains(&width) { + return Err(bad(C, format!("`width` must be from {lo} to {hi} pt"))); + } + let count = number_or(p, "count", 1.0, C)?; + if count.fract() != 0.0 || !(1.0..=MAX_BRIDGES_PER_ISLAND as f64).contains(&count) { + return Err(bad(C, format!("`count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND}"))); + } + let direction = match choice(p, C, "direction", &DIRECTIONS)?.unwrap_or("vertical") { + "horizontal" => BridgeDirection::Horizontal, + "shortest" => BridgeDirection::Shortest, + _ => BridgeDirection::Vertical, + }; + let keep = switch_or(p, "keepOriginal", true, C)?; + let selected = selected_roots(s)?; + let (bridged, style, roots) = { + let d = &s.doc()?.doc; + // The art's outlines, the selected objects they come from, and the topmost object that + // has some, whose paint the result takes. + let mut parts: Vec<(PathData, FillRule)> = vec![]; + let mut roots = vec![]; + let mut top = None; + for id in selected { + let n = d.node(id).ok_or(EngineError::NoNode(id))?; + let mut leaves = vec![]; + shown_leaves(d, n, &mut leaves); + let before = parts.len(); + for leaf in leaves { + if let Some(part) = node_path(&leaf).filter(|(p, _)| !p.is_empty()) { + parts.push(part); + top = Some(leaf); + } + } + if parts.len() > before { + roots.push(id); + } + } + let Some(top) = top else { + let label = "Stencil Bridges"; + return Err(EngineError::Other(format!("{label}: select paths, compound paths, groups or text"))); + }; + let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect(); + let spec = BridgeSpec { width, count: count as usize, direction }; + let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?; + (bridged, style_of(&top), roots) + }; + let summary = |id: Option<NodeId>, original: Option<NodeId>| { + json!({ + "id": id.map(|i| i.0), "islands": bridged.islands, "bridges": bridged.bridges, "unbridged": bridged.unbridged, + "original": original.map(|i| i.0), + }) + }; + if bridged.islands == 0 { + let mut r = summary(None, None); + // The warnings are written as `EngineError::Other` so that the i18n message scan + // (`untranslated_report`) finds them. + r["warning"] = json!(EngineError::Other("The selected art has no islands to bridge".into()).to_string()); + return Ok(r); + } + if bridged.bridges == 0 { + let mut r = summary(None, None); + r["warning"] = json!(EngineError::Other("No bridge of this width fits across the selected art".into()).to_string()); + return Ok(r); + } + let path = bridged.path.clone(); + let (id, original) = s.edit("Stencil Bridges", |d, sel| { + let mut made = shape_node(d, path, None); + style.apply(&mut made); + let mut taken = vec![]; + let id = d.wrap(&roots, |nodes| { + taken = nodes; + made + })?; + let original = if keep { Some(hide_originals(d, taken)?) } else { None }; + sel.set([id]); + Ok((id, original)) + })?; + let mut r = summary(Some(id), original); + if bridged.unbridged > 0 { + r["warning"] = json!(EngineError::Other("Some islands could not be joined to the area around the art".into()).to_string()); + } + Ok(r) +} + +/// The leaf shapes of `n` as [`pf_leaves`] gives them, leaving out hidden objects and the members +/// of hidden groups. +fn shown_leaves(d: &Document, n: &Node, out: &mut Vec<Node>) { + if !n.visible { + return; + } + match &n.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.is_compound_shape() => { + for c in children { + shown_leaves(d, c, out); + } + } + _ => pf_leaves(d, n, out), + } +} + +/// Put `nodes` in a new hidden layer above the others, in their order, and return its id. +fn hide_originals(d: &mut Document, nodes: Vec<Arc<Node>>) -> Result<NodeId> { + let mut taken = std::collections::HashSet::new(); + d.walk(|n| { + if n.is_layer() { + taken.extend(n.name.clone()); + } + }); + let name = std::iter::once(ORIGINAL_LAYER.to_string()) + .chain((2..).map(|i| format!("{ORIGINAL_LAYER} {i}"))) + .find(|name| !taken.contains(name)) + .unwrap_or_else(|| ORIGINAL_LAYER.to_string()); + let layer = d.add_layer(Some(&name)); + for (i, n) in nodes.into_iter().enumerate() { + d.insert(Some(layer), i, Arc::unwrap_or_clone(n))?; + } + d.node_mut(layer).ok_or(EngineError::NoNode(layer))?.visible = false; + Ok(layer) +} diff --git a/crates/engine/src/cmd/addons/cutting/stencil/tests.rs b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs new file mode 100644 index 0000000000..ad072ca13d --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/stencil/tests.rs @@ -0,0 +1,328 @@ +//! Tests for `addon.cutting.bridges`: bridges cut out of the selected art keep a stencil's islands +//! attached. + +use kurbo::Shape; +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; + +use super::super::super::super::pathops::node_path; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap(); +} + +/// `outer` less each of `holes` (x, y, width, height), as one compound path, selected. +fn shape_with_holes(s: &mut Session, outer: (f64, f64, f64, f64), holes: &[(f64, f64, f64, f64)]) -> NodeId { + let mut ids = vec![rect(s, outer.0, outer.1, outer.2, outer.3)]; + for h in holes { + ids.push(rect(s, h.0, h.1, h.2, h.3)); + } + select(s, &ids); + let r = s.execute("object.pathfinder.minusFront", &json!({})).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + select(s, &[id]); + id +} + +/// A square O: 100 pt across at (100, 100), with bars 20 pt thick. +fn letter_o(s: &mut Session) -> NodeId { + shape_with_holes(s, (100.0, 100.0, 100.0, 100.0), &[(120.0, 120.0, 60.0, 60.0)]) +} + +fn id_of(r: &Value) -> NodeId { + NodeId(r["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// Is `(x, y)` inside the area object `id` fills? +fn fills(s: &Session, id: NodeId, x: f64, y: f64) -> bool { + let (path, rule) = node_path(&node(s, id)).unwrap(); + let w = path.to_bezpath().winding(kurbo::Point::new(x, y)); + match rule { + vectorcraft_geom::FillRule::EvenOdd => w % 2 != 0, + vectorcraft_geom::FillRule::NonZero => w != 0, + } +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn an_o_gets_a_bridge_and_the_original_goes_to_a_hidden_layer() { + let mut s = session(); + let o = letter_o(&mut s); + let fill = node(&s, o).appearance.fill_paint(); + let steps = undo_steps(&s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0))); + assert!(r.get("warning").is_none(), "{r}"); + let made = id_of(&r); + assert_eq!(s.doc().unwrap().selection.objects, vec![made]); + assert_eq!(undo_steps(&s), steps + 1, "one undo step"); + // The bridge crosses the top bar in the middle; the rest of the O stays, painted as it was. + assert!(!fills(&s, made, 150.0, 110.0)); + assert!(fills(&s, made, 130.0, 110.0) && fills(&s, made, 150.0, 190.0) && fills(&s, made, 110.0, 150.0)); + assert_eq!(node(&s, made).appearance.fill_paint(), fill); + // The original, unchanged, is on a new hidden layer. + let layer = NodeId(r["original"].as_u64().unwrap()); + let l = node(&s, layer); + assert_eq!((l.name.as_deref(), l.visible), (Some("Stencil Original"), false)); + let held = s.doc().unwrap().doc.children(Some(layer)).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>()); + assert_eq!(held, Some(vec![o])); + // The island is joined to the sheet: the result has none left. + let again = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!(again["islands"], json!(0)); + assert!(again["warning"].as_str().is_some_and(|w| w.contains("no islands")), "{again}"); + assert_eq!(undo_steps(&s), steps + 1, "no islands: no undo step"); + // Undo puts the O back where it was and takes the layer away. + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(layer).is_none() && d.node(made).is_none() && d.node(o).is_some_and(|n| n.visible)); +} + +#[test] +fn keep_original_off_replaces_the_art() { + let mut s = session(); + let o = letter_o(&mut s); + let layers = s.doc().unwrap().doc.layers.len(); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap(); + assert_eq!(r["original"], Value::Null); + let d = &s.doc().unwrap().doc; + assert!(d.node(o).is_none() && d.node(id_of(&r)).is_some()); + assert_eq!(d.layers.len(), layers); +} + +#[test] +fn the_bridge_is_as_wide_as_asked() { + for width in [2.0, 8.0] { + let mut s = session(); + letter_o(&mut s); + let r = s.execute("addon.cutting.bridges", &json!({"width": width})).unwrap(); + let made = id_of(&r); + let half = width / 2.0; + assert!(!fills(&s, made, 150.0 - half * 0.9, 110.0) && !fills(&s, made, 150.0 + half * 0.9, 110.0), "{width}"); + assert!(fills(&s, made, 150.0 - half * 1.2, 110.0) && fills(&s, made, 150.0 + half * 1.2, 110.0), "{width}"); + } +} + +#[test] +fn the_width_takes_units() { + let mut s = session(); + letter_o(&mut s); + let r = s.execute("addon.cutting.bridges", &json!({"width": "2 mm"})).unwrap(); + let made = id_of(&r); + // 2 mm is about 5.67 pt. + assert!(!fills(&s, made, 152.5, 110.0) && fills(&s, made, 154.0, 110.0)); +} + +#[test] +fn the_shortest_crossing_takes_the_thinnest_bar() { + let mut s = session(); + // The left bar is 5 pt thick, the others 20. + shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(105.0, 120.0, 75.0, 60.0)]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "shortest"})).unwrap(); + let made = id_of(&r); + assert!(!fills(&s, made, 102.5, 150.0), "the bridge crosses the thin bar"); + assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 190.0, 150.0)); +} + +#[test] +fn horizontal_bridges_cross_the_sides() { + let mut s = session(); + letter_o(&mut s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "horizontal"})).unwrap(); + let made = id_of(&r); + assert!(!fills(&s, made, 110.0, 150.0) || !fills(&s, made, 190.0, 150.0)); + assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 150.0, 190.0)); +} + +#[test] +fn a_shape_with_two_holes_gets_a_bridge_for_each() { + let mut s = session(); + shape_with_holes(&mut s, (100.0, 100.0, 100.0, 50.0), &[(110.0, 110.0, 30.0, 30.0), (160.0, 110.0, 30.0, 30.0)]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 3, "count": 2})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(4), json!(0))); + let made = id_of(&r); + for x in [125.0, 175.0] { + assert!(!fills(&s, made, x, 105.0) && !fills(&s, made, x, 145.0), "{x}: a bridge above and below each hole"); + } +} + +#[test] +fn type_is_bridged_by_its_glyph_outlines_and_stays_type() { + let mut s = session(); + let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "O", "size": 144})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[t]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 3})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0)), "{r}"); + assert!(matches!(node(&s, id_of(&r)).kind, NodeKind::Path { .. } | NodeKind::Compound { .. })); + // The type itself is not converted: it is on the hidden layer, still type. + assert!(matches!(node(&s, t).kind, NodeKind::Text(_))); + assert!(!node(&s, NodeId(r["original"].as_u64().unwrap())).visible); +} + +#[test] +fn the_result_is_painted_as_the_topmost_object_with_outlines() { + let mut s = session(); + let o = letter_o(&mut s); + // Type holding only a space draws no outlines, so it does not set the paint, though it is on top. + let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[o]); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let red = node(&s, o).appearance.fill_paint(); + select(&mut s, &[o, t]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!(node(&s, id_of(&r)).appearance.fill_paint(), red); +} + +#[test] +fn hidden_members_of_a_group_are_left_out() { + let mut s = session(); + let o = letter_o(&mut s); + // A hidden square that would fill the O's hole, grouped with it. + let cover = rect(&mut s, 120.0, 120.0, 60.0, 60.0); + select(&mut s, &[o, cover]); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[cover]); + s.execute("object.hide", &json!({})).unwrap(); + select(&mut s, &[g]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone()), (json!(1), json!(1)), "{r}"); +} + +#[test] +fn art_without_islands_changes_nothing() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + select(&mut s, &[a]); + let (steps, count) = (undo_steps(&s), s.doc().unwrap().doc.node_count()); + let r = s.execute("addon.cutting.bridges", &json!({})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["id"].clone()), (json!(0), json!(0), Value::Null)); + assert!(r["warning"].as_str().is_some(), "{r}"); + assert_eq!((undo_steps(&s), s.doc().unwrap().doc.node_count()), (steps, count)); +} + +#[test] +fn an_island_too_narrow_for_the_width_is_reported() { + let mut s = session(); + shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(149.0, 120.0, 2.0, 60.0)]); + let steps = undo_steps(&s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(0), json!(1))); + assert!(r["warning"].as_str().is_some_and(|w| w.contains("No bridge")), "{r}"); + assert_eq!(undo_steps(&s), steps); +} + +#[test] +fn islands_left_without_a_bridge_are_reported_beside_the_ones_bridged() { + let mut s = session(); + // A wide hole on the left and a 2 pt slot on the right. + shape_with_holes(&mut s, (100.0, 100.0, 200.0, 100.0), &[(120.0, 120.0, 60.0, 60.0), (249.0, 120.0, 2.0, 60.0)]); + let steps = undo_steps(&s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(1), json!(1))); + assert!(r["warning"].as_str().is_some_and(|w| w.contains("could not be joined")), "{r}"); + assert_eq!(undo_steps(&s), steps + 1); + assert!(!fills(&s, id_of(&r), 150.0, 110.0), "the wide hole is bridged"); +} + +#[test] +fn each_run_keeps_its_original_on_a_layer_of_its_own() { + let mut s = session(); + letter_o(&mut s); + let first = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + shape_with_holes(&mut s, (300.0, 100.0, 100.0, 100.0), &[(320.0, 120.0, 60.0, 60.0)]); + let second = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap(); + let name = |r: &Value| node(&s, NodeId(r["original"].as_u64().unwrap())).name; + assert_eq!((name(&first).as_deref(), name(&second).as_deref()), (Some("Stencil Original"), Some("Stencil Original 2"))); +} + +#[test] +fn bad_params_are_refused() { + let mut s = session(); + letter_o(&mut s); + let steps = undo_steps(&s); + for (key, p) in [ + ("width", json!({"width": 0})), + ("width", json!({"width": 5000})), + ("width", json!({"width": "wide"})), + ("count", json!({"count": 0})), + ("count", json!({"count": 1.5})), + ("count", json!({"count": 17})), + ("count", json!({"count": "two"})), + ("direction", json!({"direction": "diagonal"})), + ("direction", json!({"direction": 90})), + ("keepOriginal", json!({"keepOriginal": "yes"})), + ] { + let e = s.execute("addon.cutting.bridges", &p).unwrap_err().to_string(); + assert!(e.contains(key), "{p}: {e}"); + } + assert_eq!(undo_steps(&s), steps); + assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run"); +} + +#[test] +fn selected_objects_without_outlines_stay_where_they_are() { + for keep in [true, false] { + let mut s = session(); + let o = letter_o(&mut s); + // Type holding only a space has no outlines. + let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap()); + let layer = |s: &Session, id: NodeId| s.doc().unwrap().doc.layer_of(id); + let before = layer(&s, t); + select(&mut s, &[o, t]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": keep})).unwrap(); + assert_eq!(r["bridges"], json!(1), "{r}"); + assert!(node(&s, t).visible && layer(&s, t) == before, "keep {keep}: the type stays where it was"); + if keep { + let held = + s.doc().unwrap().doc.children(Some(NodeId(r["original"].as_u64().unwrap()))).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>()); + assert_eq!(held, Some(vec![o])); + } + } +} + +#[test] +fn the_result_takes_the_place_of_the_topmost_selected_object() { + let mut s = session(); + let o = letter_o(&mut s); + let between = rect(&mut s, 400.0, 100.0, 50.0, 50.0); + let o2 = shape_with_holes(&mut s, (500.0, 100.0, 100.0, 100.0), &[(520.0, 120.0, 60.0, 60.0)]); + let above = rect(&mut s, 400.0, 300.0, 50.0, 50.0); + select(&mut s, &[o, o2]); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap(); + let made = id_of(&r); + let d = &s.doc().unwrap().doc; + let order: Vec<NodeId> = d.children(d.layer_of(made)).unwrap().iter().map(|n| n.id).collect(); + let at = |id: NodeId| order.iter().position(|i| *i == id).unwrap(); + assert!(at(between) < at(made) && at(made) < at(above), "{order:?}"); +} + +#[test] +fn the_result_is_one_path_when_one_outline_is_left() { + for (count, plain) in [(1, true), (2, false)] { + let mut s = session(); + letter_o(&mut s); + let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "count": count})).unwrap(); + let kind = node(&s, id_of(&r)).kind; + // One bridge leaves a C, one outline; two leave two pieces. + assert_eq!(matches!(kind, NodeKind::Path { .. }), plain, "count {count}"); + assert_eq!(matches!(kind, NodeKind::Compound { .. }), !plain, "count {count}"); + } +} diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index bc1d4e4f25..537e6bfe75 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -21,7 +21,7 @@ use super::super::menucmds::whole_or; use super::super::object::choice; use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges}; use super::super::typecmd::refresh_bounds; -use super::super::typemenu::text_ids; +use super::super::typemenu::{bake_dynamic_text, text_ids}; use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or, targets}; use crate::{EngineError, Result, Session}; @@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> { "Release Type on a Path…", ["Object", "Plug-ins", "Type Tools"], None, - "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", + "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", has_selection, release_path ), @@ -230,8 +230,9 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> { /// `t`, type on a path, as point type on one straight line: the same runs and character and /// paragraph attributes, its baseline starting where its first character sat on the path (in the -/// object's own text space, so its transform stays). Type on a Path Options are dropped. None for -/// other type, or when it can't be placed. +/// object's own text space, so its transform stays). Type on a Path Options are dropped, and +/// Dynamic Text is turned off at the sizes it draws the text at. None for other type, or when it +/// can't be placed. fn released_text(t: &TextObject) -> Option<TextObject> { let TextKind::OnPath { path, .. } = &t.kind else { return None }; let db = vectorcraft_text::FontDb::global(); @@ -244,6 +245,8 @@ fn released_text(t: &TextObject) -> Option<TextObject> { (p.x.is_finite() && p.y.is_finite()).then_some(p) })?; let mut out = t.clone(); + // Point type has no brackets to fit: it keeps the sizes the path drew it at. + bake_dynamic_text(&mut out); out.kind = TextKind::Point; out.path_effect = Default::default(); out.path_align = Default::default(); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 1f64e582ad..fb86fa7852 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -32,8 +32,8 @@ use super::opacitymask::percent; use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; use super::{ - CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets, - ok, paint_targets, str_param, switch_or, targets, + CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets, + object_param, ok, paint_targets, str_param, switch_or, targets, }; use crate::EngineError; @@ -726,8 +726,8 @@ fn clear(n: &mut Node) { fn transfer(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "appearance.transfer"; - let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?; + let source = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing `target` id"))?; if source == target { return Err(bad(C, "`source` and `target` are the same object")); } @@ -1201,7 +1201,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> { /// Sample enabled attributes with transform-aware character dimensions and atomic validation. fn copy_from(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "appearance.copyFrom"; - let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; + let clicked = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?); let defaults_only = switch_or(p, "defaultsOnly", false, C)?; if defaults_only && (reverse || append || p.get("ids").is_some()) { @@ -1512,6 +1512,9 @@ impl Picked { stroke_width: st.stroke_width, overprint_fill: st.overprint_fill, overprint_stroke: st.overprint_stroke, + // The space before a line's first character belongs to where the character is, + // not to how it looks: a sample from one doesn't move the target's (#5111). + kerning_before: st.kerning_before, ..cs.clone() }; } diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 300d9f6aa1..981f85e6f5 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -11,7 +11,9 @@ use std::collections::HashSet; use std::sync::{Arc, LazyLock}; use serde_json::{Value, json}; -use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation}; +use vectorcraft_brush::{ + self as brush, ArtBrush, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation, +}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol}; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes}; @@ -169,6 +171,7 @@ pub struct BuiltinBrushLibrary { pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, + BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, @@ -290,6 +293,31 @@ fn calligraphic_nibs() -> BrushLibrary { built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes) } +/// A bristle brush: `shape`, then size (pt), bristle length, density, thickness, paint opacity +/// and stiffness (%). +fn bristle(name: &str, shape: BristleShape, [size, length, density, thickness, opacity, stiffness]: [f64; 6]) -> Brush { + Brush { name: name.into(), kind: BrushKind::Bristle(Bristle { shape, size, length, density, thickness, opacity, stiffness }) } +} + +/// Bristle Brushes: one bristle brush for each tip shape, from a fine liner to wide fans for +/// blending and texture. +fn bristle_brushes() -> BrushLibrary { + use BristleShape::*; + let brushes = vec![ + bristle("Fine Liner", RoundPoint, [2.0, 140.0, 30.0, 30.0, 90.0, 75.0]), + bristle("Pointed Flat", FlatPoint, [5.0, 120.0, 55.0, 35.0, 80.0, 60.0]), + bristle("Stubby Round", RoundBlunt, [8.0, 60.0, 70.0, 50.0, 85.0, 80.0]), + bristle("Dry Scrub", FlatBlunt, [10.0, 40.0, 25.0, 60.0, 60.0, 95.0]), + bristle("Soft Dome", RoundCurve, [12.0, 160.0, 85.0, 20.0, 45.0, 25.0]), + bristle("Filbert", FlatCurve, [9.0, 110.0, 65.0, 40.0, 70.0, 45.0]), + bristle("Angled Round", RoundAngle, [7.0, 130.0, 50.0, 35.0, 75.0, 55.0]), + bristle("Angled Flat", FlatAngle, [11.0, 100.0, 60.0, 45.0, 80.0, 65.0]), + bristle("Fan Blender", RoundFan, [16.0, 220.0, 40.0, 15.0, 35.0, 20.0]), + bristle("Grass Fan", FlatFan, [20.0, 260.0, 15.0, 25.0, 65.0, 35.0]), + ]; + built("Bristle Brushes", Document::new(100.0, 100.0), brushes) +} + fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brush { let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() }); Brush { name: name.into(), kind } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index a44b532028..fb8af2b11e 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -34,9 +34,9 @@ mod symbols; pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art, - brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, + brush_remove, brush_replace_art, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; -pub(crate) use dynamic::{edit_part, part_only, selected_part}; +pub(crate) use dynamic::{edit_part, fit_parts, part_only, selected_part}; use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; use symbols::selected_instances; pub(crate) use symbols::{ @@ -76,6 +76,15 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, brush_place_art ), + cmd!( + "brush.replaceArt", + "Replace Brush Art", + [], + None, + "{name, ids?|id?, applyToStrokes?: true} make the selected art (several objects become one group, as in brush.new) the art of a scatter, art or pattern brush, keeping its other options, as Alt-dragging (Option-dragging on macOS) art onto the brush in the Brushes panel does; a pattern brush takes a group named for one of its tiles, or a group of such groups (as brush.placeArt places them), tile by tile (of two named for one tile, the frontmost), each with the repeat frame an unpainted rectangle at its back marks (other tiles stay as they are), and other art as its side tile; when strokes use the brush and applyToStrokes is false, the brush and its strokes stay as they were and a copy of the brush with the new art, named `<name> copy`, goes after it and becomes the current brush; otherwise the strokes redraw with the new art; one undo step; refused for a calligraphic or bristle brush, with nothing selected, and for art (or a tile) with no width → {name: the brush that has the new art}", + has_doc, + brush_replace_art + ), cmd!( "brush.apply", "Apply Brush", diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 4d0cb028bc..1f63076cfd 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -239,7 +239,7 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result<Value> { && v.get(k).is_none() { let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?; - if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) { + if !has_width(&art, &s.doc()?.doc.symbol_boxes()) { return Err(bad(C, "the selected art has no width")); } v[k] = serde_json::to_value(&art).unwrap_or_default(); @@ -249,6 +249,12 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "name": name })) } +/// Whether art `n` has width for a brush to be made of, with symbol instances measured by their +/// symbols' art in `symbols`. +fn has_width(n: &Node, symbols: &vectorcraft_doc::SymbolBoxes) -> bool { + n.geometric_bounds_with(symbols).is_some_and(|b| b.width() > 1e-6) +} + /// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); @@ -367,6 +373,12 @@ fn art_brushes(n: &Node, out: &mut Vec<String>) { pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> { let d = &s.doc()?.doc; let lib = brush::library(d); + let names = brush_unused_in(d, &lib); + Ok(json!({ "names": names })) +} + +/// The brushes of `lib` no stroke in `d` paints with, in library order ([`brush_unused`]). +fn brush_unused_in(d: &Document, lib: &[Brush]) -> Vec<String> { let mut todo = vec![]; let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); @@ -379,8 +391,7 @@ pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> { used.insert(name); } } - let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); - Ok(json!({ "names": names })) + lib.iter().map(|b| b.name.clone()).filter(|n| !used.contains(n)).collect() } pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> { @@ -484,7 +495,7 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> { } /// The name `brush.placeArt` gives each pattern brush tile it places, by tile, in the order it -/// places them. +/// places them; `brush.replaceArt` reads a group of that name as that tile. const TILE_NAMES: [&str; 5] = ["Side Tile", "Outer Corner Tile", "Inner Corner Tile", "Start Tile", "End Tile"]; /// The space between the pattern brush tiles `brush.placeArt` places, in points. @@ -506,6 +517,18 @@ fn tile(p: &brush::PatternBrush, i: usize) -> Option<(&Node, Option<vectorcraft_ } } +/// Gives tile `i` of `p` ([`TILE_NAMES`] order) the art `art` and the repeat frame `frame`. The tile +/// no longer comes from a pattern swatch, and a corner draws its own art. +fn set_tile(p: &mut brush::PatternBrush, i: usize, art: Node, frame: Option<vectorcraft_geom::Rect>) { + match i { + 0 => (p.side, p.side_frame, p.side_swatch) = (art, frame, None), + 1 => (p.outer_corner, p.outer_corner_frame, p.outer_corner_swatch, p.outer_source) = (Some(art), frame, None, None), + 2 => (p.inner_corner, p.inner_corner_frame, p.inner_corner_swatch, p.inner_source) = (Some(art), frame, None, None), + 3 => (p.start, p.start_frame, p.start_swatch) = (Some(art), frame, None), + _ => (p.end, p.end_frame, p.end_swatch) = (Some(art), frame, None), + } +} + /// The art `brush.placeArt` places for brush `b`, in the brush's own coordinates: a scatter or art /// brush's art, or a pattern brush's tiles in a row, each in a group named for its tile /// ([`TILE_NAMES`]), with an unpainted rectangle at the back of a tile that has a repeat frame @@ -570,6 +593,118 @@ pub(super) fn brush_place_art(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "id": id.0 })) } +/// The repeat frame an unpainted rectangle at the back of a tile group marks ([`placed_art`]): the +/// rectangle's bounds, when `n` is a path of one closed subpath through the four corners of its +/// bounds, without curves, whose fills and strokes paint nothing and that has no other appearance. +fn frame_marker(n: &Node) -> Option<vectorcraft_geom::Rect> { + let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind else { return None }; + let unpainted = n.appearance.effects.is_empty() + && n.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => f.paint.is_none(), + AppearanceItem::Stroke(st) => st.paint.is_none(), + }); + let [sp] = path.subpaths.as_slice() else { return None }; + let r = n.geometric_bounds()?; + let corners = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)]; + let at_corners = sp.anchors.len() == 4 + && sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p) + && corners.iter().all(|c| sp.anchors.iter().any(|a| (a.p.x, a.p.y) == *c)); + (unpainted && sp.closed && at_corners && r.width() > 1e-9 && r.height() > 1e-9).then_some(r) +} + +/// The tiles in `art` for a pattern brush, by tile ([`TILE_NAMES`] order), with their repeat frames: +/// a group named for a tile, as `brush.placeArt` places them, is the art of its tile (an unpainted +/// rectangle at its back marks the tile's frame, [`frame_marker`], and is left out of the art; a +/// group left with one member gives that member), and so is each member of a group whose members +/// are all such groups; any other art is the side tile. +fn tiles_of(art: Node) -> Vec<(usize, Node, Option<vectorcraft_geom::Rect>)> { + let named = |c: &Node| matches!(c.kind, NodeKind::Group { clip: false, .. }) && c.name.as_deref().is_some_and(|n| TILE_NAMES.contains(&n)); + let tiles = match &art.kind { + _ if named(&art) => vec![Arc::new(art)], + NodeKind::Group { children, clip: false } if !children.is_empty() && children.iter().all(|c| named(c)) => children.clone(), + _ => return vec![(0, art, None)], + }; + let mut out: Vec<(usize, Node, Option<vectorcraft_geom::Rect>)> = vec![]; + for t in tiles { + let Some(i) = t.name.as_deref().and_then(|n| TILE_NAMES.iter().position(|x| *x == n)) else { continue }; + let mut g = (*t).clone(); + let mut frame = None; + if let NodeKind::Group { children, .. } = &mut g.kind + && let Some(f) = children.first().and_then(|c| frame_marker(c)) + { + frame = Some(f); + children.remove(0); + } + g.name = None; + // The group `brush.placeArt` put around the tile's art: the art itself goes back, so a + // tile does not gain a group each time it goes out and back. + if let NodeKind::Group { children, clip: false } = &g.kind + && let [only] = children.as_slice() + && g == Node::group(g.id, children.clone()) + { + g = (**only).clone(); + } + // A tile named twice: the frontmost one counts. + out.retain(|(j, ..)| *j != i); + out.push((i, g, frame)); + } + out +} + +/// `brush.replaceArt`: the selected art (or `ids`/`id`; several objects become one group, as in +/// `brush.new`) becomes the art of scatter, art or pattern brush `name`, keeping its other options, +/// as one undo step. A pattern brush takes the tiles `brush.placeArt` places by their names +/// ([`tiles_of`]) and other art as its side tile. When strokes use the brush and `applyToStrokes` is +/// false, the brush and its strokes stay as they were and a copy of the brush with the new art is +/// added after it and becomes the current brush; otherwise the strokes redraw with the new art. +pub(super) fn brush_replace_art(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "brush.replaceArt"; + let name = name_param(p, C)?.to_string(); + let apply = super::switch_or(p, "applyToStrokes", true, C)?; + let mut lib = brush::library(&s.doc()?.doc); + let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + if lib[i].art().is_empty() { + return Err(bad(C, no_art(&name))); + } + let art = selection_art(s, p)?.ok_or_else(|| bad(C, "nothing selected"))?; + let d = &s.doc()?.doc; + let symbols = d.symbol_boxes(); + let check = |n: &Node| if has_width(n, &symbols) { Ok(()) } else { Err(bad(C, "the selected art has no width")) }; + let mut b = lib[i].clone(); + match &mut b.kind { + BrushKind::Scatter(sc) => { + check(&art)?; + sc.art = art; + } + BrushKind::Art(a) => { + check(&art)?; + a.art = art; + } + BrushKind::Pattern(pb) => { + for (t, art, frame) in tiles_of(art) { + check(&art)?; + set_tile(pb, t, art, frame); + } + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return Err(bad(C, no_art(&name))), + } + let used = !brush_unused_in(d, &lib).contains(&name); + if apply || !used { + lib[i] = b; + save_library(s, "Replace Brush Art", lib, &[])?; + return Ok(json!({ "name": name })); + } + b.name = brush::unique_name(&lib, &format!("{} copy", name.trim())); + let copy = b.name.clone(); + lib.insert(i + 1, b); + s.edit("Replace Brush Art", |d, _| { + d.unknown.insert(brush::CURRENT_KEY.into(), json!(copy)); + brush::store(d, &lib); + Ok(()) + })?; + Ok(json!({ "name": copy })) +} + pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> { let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); let ids: Vec<NodeId> = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 888e450085..f5cef8351e 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -2,7 +2,7 @@ //! appearance of objects in the symbol's art and stays linked to the symbol //! (`symbol.instance.setAppearance`, `symbol.instance.reset`). The changes live on the instance //! ([`vectorcraft_doc::symbol_overrides`]); editing the symbol keeps those whose object is still -//! there ([`fit_overrides`]). +//! there and draws them ([`fit_overrides`]). use std::sync::Arc; @@ -27,7 +27,7 @@ pub(super) fn type_id(dynamic: bool) -> &'static str { } /// Fit the appearance changes of instances `ids` to their symbols: an instance of a static (or -/// missing) symbol keeps none, an instance of a dynamic one those naming an object of its art. +/// missing) symbol keeps none, an instance of a dynamic one those its art still draws ([`fit_parts`]). pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) { for id in ids { let art = match d.node(*id) { @@ -40,7 +40,7 @@ pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) { let Some(n) = d.node_mut(*id) else { continue }; let mut e = n.extras_mut(); match art { - Some(art) => symbol_overrides::retain_parts(&mut e.symbol_overrides, &art), + Some(art) => fit_parts(&mut e.symbol_overrides, &art), None => e.symbol_overrides.clear(), } } @@ -177,17 +177,42 @@ fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<( fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> { let asked = p.get("fill").is_some_and(|v| !v.is_null()); let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })); - let draws_nothing = |f: &vectorcraft_color::Paint| match f { - vectorcraft_color::Paint::Gradient(_) => true, - vectorcraft_color::Paint::Pattern { .. } => true, - vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false, - }; - if asked && is_instance && fill.is_some_and(draws_nothing) { + if asked && is_instance && fill.is_some_and(|f| !stains(f)) { return Err(bad(cmd, "fill on a symbol instance must be a solid color or none")); } Ok(()) } +/// Can a symbol instance's fill be `f`? An instance stains its art with a solid fill only. +fn stains(f: &vectorcraft_color::Paint) -> bool { + match f { + vectorcraft_color::Paint::Gradient(_) => false, + vectorcraft_color::Paint::Pattern { .. } => false, + vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => true, + } +} + +/// Keep the appearance changes `overrides` that name an object of `art` (a symbol's new art), each +/// without a fill or stroke that object does not draw ([`paint_refusal`], [`stains`]); a change +/// left with nothing in it is dropped. +pub(crate) fn fit_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) { + symbol_overrides::retain_parts(overrides, art); + for o in overrides.iter_mut() { + let Some(n) = symbol_overrides::part(art, &o.path) else { continue }; + let instance = matches!(n.kind, NodeKind::SymbolInstance { .. }); + if paint_refusal(n, "fill", FIT).is_some() || (instance && o.fill.as_ref().is_some_and(|f| !stains(f))) { + o.fill = None; + } + if paint_refusal(n, "stroke", FIT).is_some() { + o.stroke = None; + } + } + overrides.retain(|o| !o.is_empty()); +} + +/// The command name [`fit_parts`] gives [`paint_refusal`], whose refusal it does not show. +const FIT: &str = "symbol.update"; + /// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or /// an object of `paint.setFill`'s paint params sets it. fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<Option<vectorcraft_color::Paint>>> { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 60a6b81378..53e1b655a1 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -29,8 +29,8 @@ use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids, - number_or, point_param, str_param, switch_or, + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, named_ids, number_or, + object_param, point_param, str_param, switch_or, }; mod live_paint; diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 3f9b62df21..e5e7757486 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,7 @@ use super::{ Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, - Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json, - is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, + json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -303,7 +303,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { }; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); - let group = id_param(p, "group"); + let group = object_param(p, "group", C)?; let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; let (g, face) = s.edit("Live Paint Bucket", |d, sel| { let gid = match group { @@ -367,7 +367,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "livePaint.strokeEdge"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; + let g = object_param(p, "group", C)?.ok_or_else(|| bad(C, "missing group"))?; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; @@ -390,7 +390,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> { } pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> { - let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; + let g = match object_param(p, "group", "livePaint.info")? { + Some(g) => g, + None => object_param(p, "id", "livePaint.info")?.ok_or_else(|| bad("livePaint.info", "missing group"))?, + }; let d = &s.doc()?.doc; let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; let faces: Vec<Value> = b::faces(n) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3c829e8a3e..37473cc59f 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -783,12 +783,14 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> { /// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of /// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed -/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own. +/// with Line-end Punctuation Half Width and Standard burasagari, and Korean words kept whole +/// (Korean Line Break Anywhere off). Imported text keeps its own. pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle { vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Auto, mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf, burasagari: vectorcraft_doc::Burasagari::Standard, + korean_line_break_anywhere: false, ..Default::default() } } diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 17638477a9..2dbb7a9a18 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -15,8 +15,8 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, - matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, matrix_param, + named_id, named_ids, number_or, object_param, ok, point_indices, point_param, str_param, switch_or, targets, }; use crate::EngineError; @@ -305,7 +305,7 @@ fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool { fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.widthPoint.set"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?); if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 { @@ -362,7 +362,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> { fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.widthPoint.copy"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); // When the profile comes out as it was (the copy replaces a side of a discontinuous point that has // its widths already), the document and its undo history stay as they are. @@ -382,7 +382,7 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> { fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.widthPoint.remove"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let indices = match (p.get("indices"), p.get("index")) { (Some(a), _) => point_indices(a), (None, Some(i)) => point_indices(&json!([i])), diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 7d1c1208d8..d469c7a613 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -18,8 +18,8 @@ use super::create::{add_look, insert_look}; use super::newart::NewArt; use super::pathops::{Beside, remove_each}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param, - switch_or, + CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, index, number_or, object_param, ok, + str_param, switch_or, }; use crate::EngineError; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index dbca59b00d..10254f3cd3 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -1,7 +1,7 @@ use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; use super::{ Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2, - add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or, + add_look, bad, check_numbers, f64_or, json, number_or, object_param, ok, po, str_param, switch_or, }; // ---------- Curvature ---------- @@ -41,7 +41,7 @@ enum CurvatureOp { pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> { use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move}; const C: &str = "path.curvatureEdit"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); check_numbers(p, &["x", "y", "t"], C)?; let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { @@ -106,7 +106,7 @@ pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.removeAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let curvature = switch_or(p, "curvature", false, C)?; @@ -220,7 +220,7 @@ fn remove_anchors_of( pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.convertAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let smooth = str_param(p, "to") == Some("smooth"); @@ -257,7 +257,7 @@ pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.reshapeSegment"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let seg = usize_req(p, "segment", C)?; let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 126fa57a1b..645c184b14 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -4,7 +4,7 @@ use super::common::{ }; use super::{ Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, - Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po, + Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, }; /// Split a filled path into connected pieces (each outer contour with its holes). @@ -170,7 +170,7 @@ pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn( pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "path.split"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let anchor = usize_opt(p, "anchor", C)?; let seg = usize_opt(p, "segment", C)?; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 660f52121e..f13b0a0ba4 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -4,7 +4,8 @@ use super::common::{ }; use super::{ Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, - Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, + Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, insert_look, json, number_or, object_param, po, remove_each, str_param, + switch_or, }; // ---------- Pencil / Paintbrush ---------- @@ -158,7 +159,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { let keep_selected = switch_or(p, "keepSelected", true, C)?; let filled = switch_or(p, "fill", false, C)?; if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { - let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; + let id = object_param(ext, "id", C)?.ok_or_else(|| bad(C, "extend needs id"))?; let at_start = str_param(ext, "end") == Some("start"); let (pd, _) = path_of(&s.doc()?.doc, id)?; let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; @@ -179,7 +180,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { // open subpath, that subpath starting at the end the stroke ends on, it ends at its end). let connect = match p.get("connect").filter(|v| !v.is_null()) { Some(cn) => { - let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?; + let other = object_param(cn, "id", C)?.ok_or_else(|| bad(C, "connect needs id"))?; if other == id { return Err(bad(C, "connect needs a path other than the one extended")); } @@ -299,7 +300,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> { return Ok(json!({ "id": id.0 })); } if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { - let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; + let id = object_param(rd, "id", C)?.ok_or_else(|| bad(C, "redraw needs id"))?; let within = number_or(rd, "within", 12.0, C)?; if !within.is_finite() || within < 0.0 { return Err(bad(C, "redraw within must be a finite distance of 0 or more")); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 108b8593c9..4f3dc3403a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; use super::{ CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param, - f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index, - number_or, ok, point_param, str_param, switch_or, + f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or, + object_param, ok, point_param, str_param, switch_or, }; use crate::{Clipboard, EngineError, inspect}; @@ -267,7 +267,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> { } fn doc_node(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; + let id = object_param(p, "id", "document.node")?.ok_or_else(|| bad("document.node", "missing id"))?; let mut opts = slice_opts(p, "document.node")?; let summary = switch_or(p, "summary", false, "document.node")?; if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 9cadcc7876..b570ce332a 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -676,12 +676,10 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> { if !effect.is_some_and(|e| effects::is_three_d(&e.id)) { return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); } - // Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options. - if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) { - return Err(bad( - C, - format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0), - )); + // The classic 3D effects have no lighting presets; their lights are set in their own options. + if let Some(e) = effect.filter(|e| effects::is_classic_three_d(&e.id)) { + let name = effects::effect_info(&e.id).map_or("This effect", |info| info.label.trim_end_matches('…')); + return Err(bad(C, format!("object {}: {name} at index {index} has no lighting presets; set its lights with effect.setParams", id.0))); } } let mut options = p.clone(); diff --git a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs index 812ce09664..2dff0d23f1 100644 --- a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs +++ b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs @@ -115,3 +115,32 @@ fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params; assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0))); } + +#[test] +fn lighting_presets_refuse_revolve_classic() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("path.create", &json!({"d":"M100 40 L130 60 L130 140 L100 160"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color":"#3366cc"})).unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.classicRevolve","item":null,"params":{"surface":"diffuse"}})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"topLeft"})).unwrap_err().to_string(); + assert!(err.contains(&format!("object {id}: Revolve (Classic) at index 0 has no lighting presets")), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert!(!s.doc().unwrap().is_dirty()); +} + +#[test] +fn lighting_presets_refuse_rotate_classic() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x":100,"y":100,"width":80,"height":60})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.classicRotate","item":null,"params":{"lightRotation":40}})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"diffuse"})).unwrap_err().to_string(); + assert!(err.contains(&format!("object {id}: Rotate (Classic) at index 0 has no lighting presets")), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert!(!s.doc().unwrap().is_dirty()); +} diff --git a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs index 122d937b98..104363a142 100644 --- a/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs +++ b/crates/engine/src/cmd/effectcmd/three_d_source_tests.rs @@ -4,7 +4,7 @@ use vectorcraft_doc::{NodeId, NodeKind}; use crate::Session; -const TYPES: [&str; 5] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.revolve"]; +const TYPES: [&str; 6] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.classicRotate", "threeD.revolve"]; fn id_of(v: &Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs index 36ccd7cdb0..9a59d0f2f8 100644 --- a/crates/engine/src/cmd/fileinfo.rs +++ b/crates/engine/src/cmd/fileinfo.rs @@ -1,7 +1,8 @@ //! File → File Info (`file.info`): the document title and its descriptive metadata (author, //! description, keywords, rating, copyright), with the read-only created and modified dates. The -//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info -//! it gives ([`read_pdf`]). +//! title is the one the file the document was opened from gave itself ([`DocMetadata::title`]), +//! else the document's name. The SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or +//! `.ai` opens with the File Info it gives ([`read_pdf`]). use serde_json::{Map, Value, json}; use vectorcraft_doc::metadata::{MAX_RATING, iso8601}; @@ -22,7 +23,7 @@ const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artb pub(crate) fn info_json(d: &Document) -> Value { let m = &d.metadata; json!({ - "title": d.title, + "title": d.info_title(), "author": m.author, "authorTitle": m.author_title, "description": m.description, @@ -48,14 +49,22 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { _ => return Err(bad(C, "params must be an object")), }; let d = &s.doc()?.doc; - let (mut title, mut m) = (d.title.clone(), d.metadata.clone()); + let (mut title, mut m) = (d.info_title().to_string(), d.metadata.clone()); for (k, v) in o { apply(&mut title, &mut m, k, v)?; } let d = &s.doc()?.doc; - if title != d.title || m != d.metadata { + // A title File Info changes becomes the document's own, as it always has (the tab and Save As + // offer it where the document has no file), and replaces the one its file gave. + let retitled = title != d.info_title(); + if retitled { + m.title = None; + } + if retitled || m != d.metadata { s.edit("File Info", |d, _| { - d.title = title; + if retitled { + d.title = title; + } d.metadata = m; Ok(()) })?; @@ -64,8 +73,8 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> { } /// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each -/// field that `file.info` would take (a value it refuses is left out), and the created and -/// modified dates. +/// field that `file.info` would take (a value it refuses is left out), its title ([`imported_title`]) +/// as the File Info title, and the created and modified dates. pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain }); let fields = [ @@ -83,6 +92,7 @@ pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) { let _ = apply(&mut title, m, k, &v); } } + m.title = f.title.as_deref().and_then(imported_title); m.created = f.created.or(m.created); m.modified = f.modified.or(m.modified); } @@ -141,6 +151,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> { Ok(s.to_string()) } +/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too) +/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters +/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the +/// Untitled-N name New gives (what a document that never had a title writes to its files). +pub(crate) fn imported_title(raw: &str) -> Option<String> { + let words: Vec<String> = + raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect(); + let title: String = words.join(" ").chars().take(MAX_TEXT).collect(); + let title = title.trim_end(); + super::fileio::is_own_title(title).then(|| title.to_string()) +} + /// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring /// case), empty ones dropped. fn keywords(v: &Value) -> Result<Vec<String>> { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 02b82afedd..329988cb34 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,7 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `hpgl`: HP-GL export options and plots. //! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no //! built-in format reads). //! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`). @@ -40,6 +41,7 @@ mod load; mod mesh3d; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; mod heifread; +mod hpgl; mod jp2read; mod metafile; mod naming; @@ -141,6 +143,7 @@ pub(crate) use export_names::{ artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return, }; use export_names::{distinct_names_by, write_encoded}; +pub(crate) use formats::is_own_title; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 5e920e1b3c..4953785189 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -536,6 +536,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, def | "dxf" | "emf" | "wmf" + | "hpgl" | "ai" ) }; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 591c9097dd..7f95b8e203 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_selection, export::export_selection ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 84188c313a..0f91007515 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -464,7 +464,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> { } // Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so // themselves: SVG only when it outlines or embeds fonts). - if (f.raster || matches!(f.id, "eps" | "emf" | "wmf")) + if (f.raster || matches!(f.id, "eps" | "emf" | "wmf" | "hpgl")) && let Some(w) = crate::cmd::fonts::substitution_warning(doc) { enc.warnings.push(w); @@ -503,7 +503,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). - let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None }; + let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None }; // `rect`: that area of the page, as if it were the only artboard (the document is not changed). if let Some(rect) = page_rect(f, p)? { let mut q = super::export::without_artboards(p); @@ -536,6 +536,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { "eps" => return super::eps::encode(doc, p), "emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf), "wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf), + "hpgl" => return super::hpgl::encode(doc, p, use_artboards), "ai" => return super::save::encode_ai(C, f, doc, p), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; @@ -591,7 +592,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?; // File Info as PNG text chunks. let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) { - vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title)) + vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(doc.info_title())) } else { bytes }; diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 290f211ad4..fe962a36ad 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -370,7 +370,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> { let o = EpsOptions { region, origin, - title: doc.title.clone(), + title: doc.info_title().to_owned(), created, native: Some(native(&editing, if whole { None } else { board }, set.include_linked)), ..set.eps.clone() diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index b04104f70f..d5137c8b1a 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -1,6 +1,7 @@ use super::super::unique_name; use super::{ - Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, screens, svg, text, unsupported, + Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, hpgl, json, metafile, native, pdf, screens, svg, text, + unsupported, }; /// One option a format's encoder reads from the export params. @@ -479,8 +480,17 @@ pub const FORMATS: &[Format] = &[ }, Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS }, Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS }, - // HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them. - reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false), + // HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths and written as pen moves. + Format { + id: "hpgl", + label: "HP-GL", + extensions: &["plt", "hpgl", "hgl", "hpg"], + mime: "application/vnd.hp-hpgl", + read: true, + write: true, + raster: false, + options: hpgl::OPTIONS, + }, Format { id: "tga", label: "Targa", @@ -920,13 +930,20 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String { /// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep /// the stored title. pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool { - !doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + is_own_title(doc.info_title()) +} + +/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives? +pub(crate) fn is_own_title(title: &str) -> bool { + !title.trim().is_empty() && !title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) } pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> { let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?; let mut d = doc.clone(); d.title = file_stem(path); + // A File Info title that is no title of its own (blank, or an Untitled-N name) would hide it. + d.metadata.title = None; Some(d) } diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs new file mode 100644 index 0000000000..ff6e63fbd7 --- /dev/null +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -0,0 +1,126 @@ +//! HP-GL for every path that writes plot files (`vectorcraft_cad::hpgl`): `document.export {format: +//! hpgl}` and Export As (one plot of an artboard, or one per artboard with Use Artboards), with the +//! options parsed from the params. + +use serde_json::Value; +use vectorcraft_cad::hpgl::write::{DEFAULT_TOLERANCE, MAX_PENS, TOLERANCE_RANGE}; +use vectorcraft_cad::hpgl::{HpglOptions, Origin, Version}; +use vectorcraft_doc::Document; + +use super::super::{Result, bad, number_or, switch_or, text_param}; +use super::{ArtboardPick, Encoded, FormatOption}; + +const C: &str = "document.export"; + +/// The options `document.formats` lists for HP-GL. +pub(super) const OPTIONS: &[FormatOption] = &[ + super::ARTBOARD, + super::ARTBOARDS, + super::RANGE, + super::USE_ARTBOARDS, + FormatOption { + name: "hpglVersion", + ty: "string", + default: "\"hpgl\"", + description: "hpgl (IN, SP, PU and PD only; a coordinate outside -32,768 to 32,767 gets a warning) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)", + }, + FormatOption { + name: "curveTolerance", + ty: "number", + default: "0.1", + description: "how far the straight moves a curve becomes may lie from it, in points (0.01 to 10)", + }, + FormatOption { + name: "pens", + ty: "array", + default: "null", + description: "the pen table, pen 1's color first: up to 255 \"#rrggbb\" colors, each path drawn with the pen of the nearest color; null: a pen for each color in the order the art first draws in it, numbered alike in every file of one export", + }, + FormatOption { + name: "origin", + ty: "string", + default: "\"bottomLeft\"", + description: "bottomLeft or center: where (0, 0) lies on the artboard (or the art's bounds)", + }, + FormatOption { + name: "sortPaths", + ty: "boolean", + default: "false", + description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order", + }, + FormatOption { + name: "insideFirst", + ty: "boolean", + default: "false", + description: "draw a path only after the pen's paths that lie inside it (a letter's counter before the letter, a hole before the part), the paths otherwise in paint order; with sortPaths, the nearest of the paths free to draw comes next", + }, +]; + +/// The HP-GL options in `p` (the region is set per plot). +fn parse(p: &Value) -> Result<HpglOptions> { + let mut o = HpglOptions::default(); + if let Some(s) = text_param(p, "hpglVersion", C)? { + let key = "hpglVersion"; + o.version = Version::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Version::ALL.map(Version::id).join(", "))))?; + } + let x = number_or(p, "curveTolerance", DEFAULT_TOLERANCE, C)?; + if !TOLERANCE_RANGE.contains(&x) { + let (key, min, max) = ("curveTolerance", TOLERANCE_RANGE.start(), TOLERANCE_RANGE.end()); + return Err(bad(C, format!("`{key}` must be between {min} and {max} (got {x})"))); + } + o.tolerance = x; + match p.get("pens") { + None | Some(Value::Null) => {} + Some(v) => { + let colors: Option<Vec<[u8; 3]>> = v.as_array().filter(|a| (1..=MAX_PENS).contains(&a.len())).and_then(|a| a.iter().map(hex).collect()); + o.pens = colors.ok_or_else(|| bad(C, "`pens` must be an array of 1 to 255 #rrggbb colors"))?; + } + } + if let Some(s) = text_param(p, "origin", C)? { + let key = "origin"; + o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?; + } + o.sort = switch_or(p, "sortPaths", false, C)?; + o.inside_first = switch_or(p, "insideFirst", false, C)?; + Ok(o) +} + +/// A `#rrggbb` color. +fn hex(v: &Value) -> Option<[u8; 3]> { + let s = v.as_str()?.strip_prefix('#').filter(|s| s.len() == 6 && s.bytes().all(|b| b.is_ascii_hexdigit()))?; + let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()); + Some([byte(0)?, byte(2)?, byte(4)?]) +} + +/// Encode `doc` as HP-GL: a plot of the chosen artboard (default the first), or with +/// `use_artboards` one plot per chosen artboard (default all), with one pen numbering for them all. +pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> Result<Encoded> { + let opts = parse(p)?; + let f = super::format("hpgl").ok_or_else(|| bad(C, "no HP-GL format"))?; + let pick: ArtboardPick = super::encode::options(f, p)?; + let n = doc.artboards.len(); + let boards = match use_artboards { + Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()), + _ => vec![pick.one(n).map_err(|e| bad(C, e))?], + }; + let regions = boards + .iter() + .map(|&b| doc.artboards.get(b).map(|a| a.rect).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))) + .collect::<Result<Vec<_>>>()?; + let plots = vectorcraft_cad::hpgl::export_regions(doc, &opts, ®ions).map_err(|e| bad(C, e))?; + let mut enc = Encoded::default(); + for (b, out) in boards.into_iter().zip(plots) { + enc.files.push((Some(b), out.bytes)); + for w in out.warnings { + if !enc.warnings.contains(&w) { + enc.warnings.push(w); + } + } + } + if enc.files.is_empty() { + return Err(bad(C, "the document has no artboard")); + } + let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect(); + enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); + Ok(enc) +} diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index a197363b23..1fd3e10a9f 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -145,7 +145,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } } } - let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text }; + let o = vectorcraft_eps::LegacyAiOptions { title: doc.info_title().to_owned(), created, hidden, outline_text }; let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?; for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fde1ed273f..3040cfc43f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; use vectorcraft_render::effects::pixel::LoadedTexture; +use super::super::fileinfo::imported_title; use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param}; use super::pdfimport::LoadOptions; use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, jxlread, psdread, read_file, readable_format_for_name}; @@ -550,6 +551,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); } + // The title the file gives itself (an SVG's `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` + // or Info `Title`, which the readers put in File Info) is separate from the document's name. + // Restored File Info is preserved exactly; newly imported SVG candidates and a restored + // document's legacy name fallback are cleaned as imported text. The name stays the file's (#3626). + if !restored && matches!(format.id, "svg" | "svgz") { + doc.metadata.title = doc.metadata.title.as_deref().and_then(imported_title); + } + let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title); + doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title)); // Imports are named after the file; a native document keeps its own title (the tab shows the // file name once it has a path). if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() { @@ -678,6 +688,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St if template { doc.template = false; doc.title = s.next_untitled(); + doc.metadata.title = None; } // A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start // closed, its top-level layers as its groups (#1475). Ours carry their own. @@ -711,7 +722,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St st.import_losses = losses; st.links_from = read_from; let opened = &s.documents()[index]; - let (title, file_info_title) = (opened.title(), opened.doc.title.clone()); + let (title, file_info_title) = (opened.title(), opened.doc.info_title().to_string()); Ok(super::merge( json!({ "index": index, "title": title, "fileInfoTitle": file_info_title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json(), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index d010dab3d0..5d7783665b 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -731,7 +731,7 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res // With the artboards' background colors, as the pages draw them. let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?; let o = vectorcraft_eps::AiEditingOptions { - title: doc.title.clone(), + title: doc.info_title().to_owned(), created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time), }; let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?; @@ -885,7 +885,13 @@ impl SaveJob { /// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several), /// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?: /// [path…], linked?: [path…]}`. - pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { + pub fn write(&self, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { + self.write_unless(|_| None, write) + } + + /// [`SaveJob::write`], once `stop` has seen the paths of every file it writes to a path, in + /// order: when `stop` returns a value, nothing is written and that value is returned. + pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> { let (plan, doc) = (&self.plan, &*self.doc); let mut enc = self.encode()?; let warnings = std::mem::take(&mut enc.warnings); @@ -899,6 +905,9 @@ impl SaveJob { return Ok(out); }; let files = enc.named(doc, path); + if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>()) { + return Ok(stopped); + } for (p, bytes) in &files { write(p, bytes)?; } diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index bcc4bcb178..b48aef68c3 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -196,7 +196,7 @@ pub(super) fn name_without_device(name: &str) -> String { pub(crate) const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"]; /// The writable formats a format row can't name. -pub(crate) const UNWRITTEN_FORMATS: [&str; 5] = ["template", "txt", "dxf", "emf", "wmf"]; +pub(crate) const UNWRITTEN_FORMATS: [&str; 6] = ["template", "txt", "dxf", "emf", "wmf", "hpgl"]; /// One format row: what to write, at which size, with which suffix, into which sub-folder. struct Row { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index ebe3b76da9..c90e077eb4 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -431,6 +431,7 @@ fn export_extensions_name_every_writable_format() { "eps", "emf", "wmf", + "plt", "tga", "psd" ] @@ -466,6 +467,7 @@ fn formats_query_lists_readers_writers_and_options() { "eps", "emf", "wmf", + "hpgl", "tga", "psd" ] diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 271f7b86e2..f4767fd81d 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -1,8 +1,9 @@ //! HP-GL plotter files through the commands: `document.open` by extension or by content, File › -//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't -//! HP-GL refused with a message. +//! Place as one group of paths, the dialogs' filters and the format list, files that aren't +//! HP-GL refused with a message, and `document.export` of one artboard, each or the art, with its +//! options checked. -use serde_json::json; +use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use super::*; @@ -100,5 +101,131 @@ fn the_dialogs_and_the_format_list_name_hpgl() { assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter)); let r = Session::new().execute("document.formats", &json!({})).unwrap(); let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone(); - assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false))); + assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(true), Some(false))); + for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths", "insideFirst"] { + assert!(f["options"].get(o).is_some(), "{o}: {f}"); + } + assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl"); + assert_eq!(writable_format(None, Some("x/part.hpgl")).unwrap().id, "hpgl"); +} + +fn bytes(v: &Value) -> Vec<u8> { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +/// Two 100 pt artboards side by side, a 30 pt square on each, the second one red. +fn two_boards() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2})).unwrap(); + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + let red = s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("paint.setStroke", &json!({"ids": [red], "color": "#ff0000"})).unwrap(); + s +} + +#[test] +fn export_writes_a_plot_of_one_artboard_or_each() { + let mut s = two_boards(); + let r = s.execute("document.export", &json!({"format": "hpgl"})).unwrap(); + assert_eq!(r["format"], "hpgl"); + // The first artboard's square, 1016 units to the inch from its bottom-left corner. + assert_eq!(String::from_utf8(bytes(&r)).unwrap(), "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;"); + let second = String::from_utf8(bytes(&s.execute("document.export", &json!({"format": "hpgl", "artboard": 1})).unwrap())).unwrap(); + assert_eq!(second, "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;", "its own square, its own pen 1"); + assert_eq!(bytes(&s.execute("document.serialize", &json!({"format": "hpgl"})).unwrap()), bytes(&r)); + let r = s.execute("document.export", &json!({"format": "hpgl", "useArtboards": true})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert!(files[1]["name"].as_str().unwrap().ends_with("-Artboard-2.plt"), "{files:?}"); + // One pen numbering for every file: the red square is pen 2 in the second. + assert_eq!(String::from_utf8(bytes(&files[1])).unwrap(), "IN;PU;SP2;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;"); + // The bounds of the art: both squares, the second pen red, read back as two paths. + let art = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false, "hpglVersion": "hpgl2"})).unwrap()); + assert!(art.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;"), "{:?}", String::from_utf8_lossy(&art)); + let back = Session::new().execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&art)})).unwrap(); + assert_eq!(back["format"], "hpgl", "{back}"); + // One file holds one artboard. + assert!(s.execute("document.export", &json!({"format": "hpgl", "artboards": [0, 1]})).is_err()); +} + +/// A plot read back opens with the same squares: as wide as the art drew them, a point at most +/// apart from the 30 pt sides plus the 0.35 mm pen. +#[test] +fn a_plot_reads_back_as_its_art() { + let mut s = two_boards(); + let plot = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).unwrap()); + let mut back = Session::new(); + back.execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&plot)})).unwrap(); + let doc = &back.doc().unwrap().doc; + let mut paths = 0; + doc.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + assert_eq!(paths, 2); + let board = doc.artboards[0].rect; + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + let pen = 0.35 * 72.0 / 25.4; + assert!((board.width() - (second + 30.0 + pen)).abs() < 0.1, "{board:?}"); + assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}"); +} + +#[test] +fn export_options_are_checked() { + let mut s = two_boards(); + let refused = |s: &mut Session, p: Value| s.execute("document.export", &p).expect_err("refused").to_string(); + let cases = [ + (json!({"format": "hpgl", "hpglVersion": "hpgl3"}), "`hpglVersion` must be one of hpgl, hpgl2"), + (json!({"format": "hpgl", "hpglVersion": 2}), "`hpglVersion` must be a string"), + (json!({"format": "hpgl", "curveTolerance": 0}), "`curveTolerance` must be between 0.01 and 10 (got 0)"), + (json!({"format": "hpgl", "curveTolerance": "fine"}), "`curveTolerance` must be a number"), + (json!({"format": "hpgl", "pens": []}), "`pens` must be an array of 1 to 255 #rrggbb colors"), + (json!({"format": "hpgl", "pens": ["#ff0000", "red"]}), "`pens` must be an array of 1 to 255 #rrggbb colors"), + (json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"), + (json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"), + (json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"), + (json!({"format": "hpgl", "insideFirst": 1}), "`insideFirst` must be true or false"), + ]; + for (p, want) in cases { + let e = refused(&mut s, p.clone()); + assert!(e.contains(want), "{p}: {e}"); + } + let many: Vec<String> = (0..256).map(|i| format!("#{:02x}0000", i % 256)).collect(); + assert!(refused(&mut s, json!({"format": "hpgl", "pens": many})).contains("`pens`")); + // Given options take effect: the center as the origin, a pen table (black is pen 2), and the + // square starting at its corner nearest the origin. + let p = json!({"format": "hpgl", "origin": "CENTER", "pens": ["#ff0000", "#000000"], "curveTolerance": 10, "sortPaths": true}); + let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap(); + assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;"); +} + +/// `insideFirst` draws the square inside the other one first, though it was made last. +#[test] +fn inside_first_draws_the_inner_square_first() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap(); + let starts = |s: &mut Session, p: Value| { + let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap(); + (plot.find("PU141,1270;").unwrap(), plot.find("PU423,988;").unwrap()) + }; + let (outer, inner) = starts(&mut s, json!({"format": "hpgl"})); + assert!(outer < inner); + let (outer, inner) = starts(&mut s, json!({"format": "hpgl", "insideFirst": true})); + assert!(inner < outer); +} + +/// Export Selection plots the selected square alone (the red one, made last), from the +/// bottom-left corner of its bounds. +#[test] +fn export_selection_writes_a_plot_of_the_selection() { + let mut s = two_boards(); + let r = s.execute("document.exportSelection", &json!({"format": "hpgl"})).unwrap(); + assert_eq!(r["format"], "hpgl"); + let plot = String::from_utf8(bytes(&r)).unwrap(); + assert_eq!(plot.matches("PD").count(), 1, "{plot}"); + assert!(plot.starts_with("IN;PU;SP1;PU7,430;PD"), "{plot}"); } diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs index ec3ce57e3a..bf51ebdac7 100644 --- a/crates/engine/src/cmd/findfiles/tests.rs +++ b/crates/engine/src/cmd/findfiles/tests.rs @@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() { assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}"); let p = search(&root, "missing", Limits::default(), 1, &[]); assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}"); + // Each failure has the same reason without the folder's path. + let briefs = [ + ("missing", "the folder can't be read"), + ("file.ttf", "the path isn't a folder"), + ("refused", "VectorCraft doesn't search other apps' or the system's folders"), + ("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"), + ]; + for (picked, why) in briefs { + assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}"); + } } #[test] @@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() { let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); if enforced { - assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}"); + assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}"); } else { assert_eq!(found(&root, &p), ["locked/y.ttf"]); } diff --git a/crates/engine/src/cmd/findfiles/walk.rs b/crates/engine/src/cmd/findfiles/walk.rs index 8d35515717..ae05a04838 100644 --- a/crates/engine/src/cmd/findfiles/walk.rs +++ b/crates/engine/src/cmd/findfiles/walk.rs @@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024; /// How a search ends when every walker thread stopped on a bug. const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)"; +/// Why a search couldn't start, without the folder's path ([`Progress::failure`]). +const UNREADABLE: &str = "the folder can't be read"; +const NOT_A_FOLDER: &str = "the path isn't a folder"; +const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users"; +const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders"; +const UNLISTED: &str = "the folder can't be listed"; + /// What a search looks for. pub trait Visitor: Send + Sync { /// Whether the regular file named `name` is to be read: asked of the files a folder lists until @@ -77,8 +84,9 @@ pub enum End { /// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item /// keeps at most [`Limits::per_item`] files, without ending the search. Limit, - /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. - Failed(String), + /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why, + /// and the same without the folder's path. + Failed(String, &'static str), } /// Where a search stands ([`Search::progress`]). @@ -114,7 +122,7 @@ impl Progress { None => "searching", Some(End::Done | End::Found) => "done", Some(End::Stopped | End::Time | End::Limit) => "stopped", - Some(End::Failed(_)) => "failed", + Some(End::Failed(..)) => "failed", } } @@ -131,10 +139,38 @@ impl Progress { /// Why a failed search failed. pub fn error(&self) -> Option<&str> { match &self.end { - Some(End::Failed(e)) => Some(e), + Some(End::Failed(e, _)) => Some(e), _ => None, } } + + /// Why a failed search failed, without the folder's path. + pub fn failure(&self) -> Option<&'static str> { + match &self.end { + Some(End::Failed(_, why)) => Some(why), + _ => None, + } + } + + /// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]), + /// for a reply to it: the files found inside them. Returns the folder searched (`given` until a + /// walker checked it) and why the search failed, when that folder lies inside them too; + /// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in + /// force, everything is kept. + pub fn keep_readable(&mut self, given: &str) -> (Option<String>, Option<String>) { + let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() }; + if crate::file_access::current().is_none() { + return (Some(folder), self.error().map(str::to_string)); + } + // A path that isn't valid text is never shown: its lossy form names another file. + let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + self.hits.retain(|(path, _)| readable(path)); + if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) { + (Some(folder), self.error().map(str::to_string)) + } else { + (None, self.failure().map(str::to_string)) + } + } } /// Walker threads shared out among searches: all of them together run at most `cap` threads. @@ -306,7 +342,7 @@ pub(crate) fn start_in( return Err("the search couldn't start: try again in a moment".into()); } if w.alive == 0 && w.end.is_none() { - shared.finish(&mut w, End::Failed(INTERNAL.into())); + shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } } Ok(Search { shared }) @@ -401,13 +437,13 @@ impl Shared { fn prepare(&self, root: &Path) { let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system)); let path = root.display(); - let checked = match std::fs::canonicalize(root) { - Err(e) => Err(format!("{path}: {e}")), - Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")), + let (checked, brief) = match std::fs::canonicalize(root) { + Err(e) => (Err(format!("{path}: {e}")), UNREADABLE), + Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER), Ok(folder) => match rules.refusal(&folder) { - None => Ok(folder), - Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), - Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), + None => (Ok(folder), INTERNAL), + Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED), + Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS), }, }; let mut w = self.lock(); @@ -416,7 +452,7 @@ impl Shared { w.folder = folder.clone(); w.stack.push((folder, 0)); } - Err(e) => self.finish(&mut w, End::Failed(e)), + Err(e) => self.finish(&mut w, End::Failed(e, brief)), } } @@ -430,7 +466,7 @@ impl Shared { let mut w = self.lock(); // The picked folder itself: the search can't run, and says why. if depth == 0 { - self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()))); + self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED)); } else { w.unreadable += 1; w.folders += 1; @@ -610,7 +646,7 @@ fn walker(s: &Shared) { if done.is_err() { let mut w = s.lock(); match job { - Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())), + Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)), Job::List(..) => w.unreadable += 1, } } @@ -640,7 +676,7 @@ impl Drop for Alive { let mut w = self.0.lock(); w.alive = w.alive.saturating_sub(1); if w.alive == 0 && w.end.is_none() { - self.0.finish(&mut w, End::Failed(INTERNAL.into())); + self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)); } drop(w); self.0.pool.release(1); diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs index 309347985c..0ccf1ab94a 100644 --- a/crates/engine/src/cmd/fontfiles.rs +++ b/crates/engine/src/cmd/fontfiles.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> { "Find Font Files", [], None, - "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find ), @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Add Font Files", [], None, - "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", + "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}", always, add ), @@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { /// Where the session's last search stands (`text.findFontFiles` without `folder`). fn state(s: &Session) -> Value { let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) }; - let p = f.search.progress(); + let mut p = f.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&f.folder); let mut files: Vec<Vec<String>> = vec![vec![]; f.fonts.len()]; for (path, items) in &p.hits { for i in items { @@ -293,16 +295,18 @@ fn state(s: &Session) -> Value { json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files }) }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": f.id, "state": p.state(), "folder": folder, "fonts": fonts, + "id": f.id, "state": p.state(), "fonts": fonts, "searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out @@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> { // VectorCraft's own Fonts folder: no automation root applies to it. crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?; let report = copy_into(&files, dir); - let pairs = |v: &[(PathBuf, PathBuf)]| { - v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>() + // The caller sees the Fonts folder and the copies in it only where its read roots reach. + let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok()); + let shown = |p: &Path| -> String { + let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() }; + shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default() }; + let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::<Vec<_>>(); let mut out = json!({ - "folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept), - "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::<Vec<_>>(), + "folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept), + "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::<Vec<_>>(), }); if !report.copied.is_empty() || !report.kept.is_empty() { // The fonts are cataloged and type set in them redraws, as Refresh Font List does it. diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 49b1015bf4..593d325460 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect}; use super::{ CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc, - has_selection, id_param, ids_param, index, ok, str_param, switch_or, + has_selection, ids_param, index, object_param, ok, str_param, switch_or, }; use crate::EngineError; @@ -426,7 +426,7 @@ fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> { /// The Layers panel's target circle. fn target(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.target"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; if !st.doc.is_editable(id) { @@ -594,7 +594,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> { fn set_current(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.setCurrent"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc_mut()?; let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?; st.active_layer = Some(layer); @@ -700,7 +700,7 @@ fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool { /// The selection column: select a row's art. fn select_art(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.selectAll"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let add = switch_or(p, "add", false, C)?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; @@ -747,9 +747,12 @@ enum Place { /// Drag rows in the Layers panel. fn move_rows(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "layer.move"; - let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = match ids_param(p, "ids", C)? { + Some(ids) => ids, + None => vec![object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing ids"))?], + }; ids.truncate(MAX_ROWS); - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing target"))?; let place = match str_param(p, "place").unwrap_or("inside") { "above" => Place::Above, "below" => Place::Below, @@ -831,8 +834,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> { fn move_node(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "node.move"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let parent = id_param(p, "parent"); + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; + let parent = object_param(p, "parent", C)?; let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index); let d = &s.doc()?.doc; let n = d.node(id).ok_or(EngineError::NoNode(id))?; diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index 5b5a6aa48a..f2ce9d4d12 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> { "Find Linked Files", [], None, - "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", + "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}", always, find )] @@ -52,6 +52,8 @@ pub(crate) struct LinkSearch { pub(crate) id: u64, /// The folder as given. pub(crate) folder: String, + /// The path of the document the links were taken from, which a relative link path starts from. + pub(crate) doc: Option<String>, pub(crate) links: Vec<Sought>, /// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]). pub(crate) sizes: Arc<Mutex<HashMap<PathBuf, u64>>>, @@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { if links.is_empty() { return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" })); } + let doc = super::links::LinkBase::of(st).doc.map(str::to_string); // A new search stops the last one. s.link_search = None; let threads = s.search_threads.unwrap_or_else(findfiles::threads); @@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { let visitor = Arc::new(LinkFiles::new(&links, sizes.clone())); let search = findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?; - s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search }); + s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search }); Ok(state(s)) } @@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize { /// Where the session's last search stands (`links.findFiles` without `folder`). fn state(s: &Session) -> Value { let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) }; - let p = l.search.progress(); + let mut p = l.search.progress(); + // Only what the caller asking now may read: a search another caller started can hold more. + let (folder, error) = p.keep_readable(&l.folder); let n = l.links.len(); let sizes = lock(&l.sizes).clone(); // Per linked file: the files found, each with whether it is the same file. @@ -267,19 +272,23 @@ fn state(s: &Session) -> Value { f }) .collect(); - json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files }) + let mut row = json!({ "name": link.name, "ids": link.ids, "files": files }); + super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref()); + row }) .collect(); - let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() }; let mut out = json!({ - "id": l.id, "state": p.state(), "folder": folder, "links": links, + "id": l.id, "state": p.state(), "links": links, "searched": { "folders": p.folders, "files": p.files, "read": p.read }, "skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0, }); + if let Some(folder) = folder { + out["folder"] = json!(folder); + } if let Some(why) = p.stopped() { out["stopped"] = json!(why); } - if let Some(e) = p.error() { + if let Some(e) = error { out["error"] = json!(e); } out diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..89c0afefcf 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec< /// May a reply to the caller asking now carry the link path `path` (of the document saved at /// `doc_path`)? Always without read roots in force; with them, only when the file it names (a /// relative path taken from the document's folder) lies inside them. -fn path_shown(path: &str, doc_path: Option<&str>) -> bool { +pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool { crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok()) } /// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`. -fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { +pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { if path_shown(path, doc_path) { row[key] = json!(path); } else if let Some(o) = row.as_object_mut() { diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 464f241159..618865cede 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -21,7 +21,7 @@ use super::expand::gradient_sampler; use super::pathops::Beside; use super::{ CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi, - has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param, + has_selection, named_ids, number_or, object_param, ok, str_param, switch_or, text_param, }; mod blend; diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 609936ecf7..d47e81befd 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -324,7 +324,11 @@ pub(super) fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result<Value> { if anchors.contains(&index) { return Err(bad(C, "a key object sits on that point")); } - if sp.anchors.len() <= 2 { + // A closed spine pinned open keeps a copy of its first point at its end (#6809), so the + // points left must be two apart, not only two in number. + let mut rest = sp.anchors.iter().enumerate().filter(|(i, _)| *i != index).map(|(_, a)| a.p); + let first = rest.next(); + if sp.anchors.len() <= 2 || first.is_none_or(|f| rest.all(|q| q.distance(f) < 0.01)) { return Err(bad(C, "a spine keeps at least two points")); } sp.anchors.remove(index); diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 5480f7ae58..1013c606c5 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> { "Make with Warp…", ["Object", "Envelope Distort"], Some("Cmd+Alt+Shift+W"), - "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", + "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", has_selection, env_make_warp ), @@ -172,7 +172,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reset with Warp…", ["Object", "Envelope Distort"], None, - "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content", + "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content", has_envelope, env_reset_warp ), @@ -190,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> { "Envelope Info", [], None, - "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}", + "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}", has_doc, env_info ), @@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> { "Envelope Options…", ["Object", "Envelope Distort"], None, - "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", + "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", has_doc, env_options ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 1aceb49b54..b9a315325b 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -3,7 +3,7 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wr use super::mesh::{handle_param, index_param, whole}; use super::{ Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint, - PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param, + PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param, }; // ---------- Envelope ---------- @@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> { check_numbers(p, WARP_NUMBERS, cmd)?; - let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true); - if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur { - (style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0); + // A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version` + // names one. + let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET); + if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur { + (style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0); + } + if let Some(n) = p.get("version") { + version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?; } if let Some(s) = text_param(p, "style", cmd)? { let s = s.strip_prefix("warp.").unwrap_or(s); @@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop Some(o) => o == "horizontal", None => given, }; - Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) + Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version }) } /// The options and fidelity new envelopes get (Envelope Options with no envelope selected). @@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result<Value> { return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); }; let v = match &**kind { - EnvelopeKind::Warp { style, bend, h, v, horizontal } => { - json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) + EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => { + json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version}) } EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}), EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}), @@ -419,7 +424,7 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "object.envelope.setMeshPoint"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let index = index_param(p, "index", C)?; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = handle_param(p, C)?; diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 70c2d9f348..187d2149f2 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -4,7 +4,7 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selec use super::envelope::{outline_of, rows_cols}; use super::{ Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad, - check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param, + check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, json, live, object_param, ok, str_param, }; // ---------- Gradient mesh ---------- @@ -115,7 +115,7 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: /// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh /// envelope's grid too (see [`edit_grid`]). fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> { - let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(p, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f)) } @@ -147,7 +147,7 @@ const MAX_POINTS: usize = 1 << 20; /// points}…]}`. Each mesh comes once, with its points in order and without repeats. fn point_items(p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> { let one = |q: &Value| -> Result<(NodeId, BTreeSet<usize>)> { - let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(q, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; let points = match q.get("points") { None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]), Some(Value::Array(v)) if v.len() <= MAX_POINTS => v diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b8bdd24d05..83a0655284 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -482,6 +482,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "hyphenate", "hyphenateCapitalized", "romanHangingPunctuation", + "koreanLineBreakAnywhere", ], C, )?; @@ -552,6 +553,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "fontAxes", "burasagari", "romanHangingPunctuation", + "koreanLineBreakAnywhere", "kashida", "composer", ]; @@ -879,6 +881,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { if let Some(v) = flag("romanHangingPunctuation") { para.roman_hanging = v; } + if let Some(v) = flag("koreanLineBreakAnywhere") { + para.korean_line_break_anywhere = v; + } if let Some(v) = kashida { para.kashida = v; } diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 4b8ae5d2be..60fa90c32c 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> 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`")); } + // A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net. + let newest = vectorcraft_render::effects::WARP_NET; + if effect.starts_with("warp.") + && let Some(v) = given.get("version") + && vectorcraft_render::effects::warp_version(v).is_none() + { + return Err(bad(id, format!("`version` must be 0 or {newest}"))); + } check_effect_color(id, effect, params)?; check_effect_channel(id, effect, params) } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 91da35e51b..c05493e964 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -12,7 +12,7 @@ use super::draw2::usize_opt; use super::pathops::Beside; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path, - has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets, + has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets, }; use crate::EngineError; @@ -168,7 +168,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result< } fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; + let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = anchor_from_json(p, "path.appendAnchor")?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); s.edit("Pen", |d, sel| { @@ -206,7 +206,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> { /// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only /// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape. fn close(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) { None => Ok(None), @@ -303,7 +303,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge } fn set_handle(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?; + let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?; let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?; let out = str_param(p, "which") != Some("in"); @@ -321,7 +321,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> { /// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point /// tool's Shift-drag). fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?; + let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?); @@ -341,7 +341,7 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> { } fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?; + let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?; let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?; let data = PathData::new( subs.iter() @@ -1562,7 +1562,7 @@ fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usiz } fn reshape(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?; + let id = object_param(p, "id", "path.reshape")?.ok_or_else(|| bad("path.reshape", "missing id"))?; let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?); check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?; let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); @@ -1684,7 +1684,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> { } fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?; + let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0); let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0); diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index a6e7f67286..5d56a2b646 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> { "Image Info", [], None, - "{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", + "{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}", has_doc, image_info ), @@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> { let [a, b, c, d, ..] = im.xf.as_coeffs(); let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l); let size = n.geometric_bounds().unwrap_or_default(); + // A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info. + let doc_path = super::links::LinkBase::of(st).doc; Ok(json!({ "id": id.0, "name": n.display_name(), "linked": im.link.is_some(), - "link": im.link.as_ref().map(|l| &l.path), + "link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)), "colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)), "pixelWidth": im.width, "pixelHeight": im.height, diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 1481f0b08c..329cb59cf2 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.strokeToFill", "addon.color.swatchLegend", "addon.contour.set", + "addon.cutting.bridges", "addon.image.removeBackground", "addon.pathtools.addPoints", "addon.pathtools.close", @@ -110,6 +111,7 @@ pub(super) const ALLOWED: &[&str] = &[ "brush.options", "brush.placeArt", "brush.remove", + "brush.replaceArt", "brush.selectionArt", "brush.setCurrent", "brush.unused", @@ -643,6 +645,8 @@ pub(super) const ALLOWED: &[&str] = &[ "type.opticalMarginAlignment", "type.orientation.horizontal", "type.orientation.vertical", + "type.pathCenter.bottom", + "type.pathCenter.top", "type.pathOptions", "type.resetHorizontalScale", "type.resetTracking", diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 7f5f33e34b..fce76bcc27 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -19,7 +19,9 @@ //! while their copies are restored or discarded the area is held, so two apps launched together //! never both take it. An area's lock file or folder can be removed while its app runs, so each //! recovery save and heartbeat checks that the lock still holds the area, and locks it again or -//! takes a new area. +//! takes a new area. The copies it had in the old area stay there until it has written them again +//! in the new one, and an entry `<new area>/keeps-<old area>` ([`mark_left`]) tells other apps +//! that those copies belong to a running app, so none of them offers or discards them meanwhile. //! //! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so //! when unsure an area counts as held, and its app repairs what another app did meanwhile: each @@ -61,6 +63,9 @@ const BLOB_EXT: &str = ".blob"; /// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix /// seconds, as text). const BEAT: &str = "heartbeat"; +/// The start of an entry `<area>/keeps-<left area>`: the app holding `<area>` still keeps the copies +/// it left in `<left area>` ([`mark_left`]). +const KEEPS: &str = "keeps-"; /// The least time without a heartbeat after which an area's app counts as gone (seconds); else /// three Data Recovery intervals. const MIN_STALE: i64 = 180; @@ -535,6 +540,8 @@ pub struct Recovery { /// store: each stays until its document has a copy in the new area, or needs none. They are /// still this session's: never offered or discarded as a crash's here. left: BTreeMap<u64, (Arc<dyn RecoveryStore>, String)>, + /// The `<own area>/keeps-<left area>` entries this session wrote in its area ([`mark_left`]). + marked: BTreeSet<String>, } impl Recovery { @@ -609,6 +616,15 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option<i64>) /// beat only lets another app offer the copies once three intervals have passed. pub fn heartbeat(s: &mut Session) -> Vec<u64> { let mut lost = keep_hold(s); + // An entry that couldn't be written before, or that was removed since (by the user, a + // cleaner), is written now. + if let Some(own) = &s.recovery.own + && !s.recovery.marked.is_empty() + && let Ok(names) = own.store.list() + { + s.recovery.marked.retain(|n| names.contains(n)); + } + mark_left(s); if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) { let _ = beat(own.store.as_ref(), &own.area); } @@ -678,6 +694,36 @@ fn drop_left(s: &mut Session, uid: u64) { let meta = store.remove(&format!("{file}{META_EXT}")); if doc.is_ok() && meta.is_ok() { s.recovery.left.remove(&uid); + mark_left(s); + } +} + +/// Record in this session's area each area it left that still holds copies of its documents: an +/// entry `<own area>/keeps-<left area>`, so another app leaves those copies alone while this one +/// runs ([`keeper`]). The entries of areas that hold none of its copies any more go. Best effort: +/// an entry not written or removed now is written or removed at the next heartbeat. +fn mark_left(s: &mut Session) { + let Some(own) = &s.recovery.own else { return }; + let (store, area) = (own.store.clone(), own.area.clone()); + let want: BTreeSet<String> = s + .recovery + .left + .values() + .filter(|(st, _)| st.location() == store.location()) + .filter_map(|(_, f)| f.split_once('/').map(|(a, _)| a)) + .filter(|a| *a != area) + .map(|a| format!("{area}/{KEEPS}{a}")) + .collect(); + let gone: Vec<String> = s.recovery.marked.difference(&want).cloned().collect(); + for n in gone { + if store.remove(&n).is_ok() { + s.recovery.marked.remove(&n); + } + } + for n in want { + if !s.recovery.marked.contains(&n) && store.write(&n, b"").is_ok() { + s.recovery.marked.insert(n); + } } } @@ -726,6 +772,9 @@ pub fn claim(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<String> } }; s.recovery.own = Some(Own { store: store.clone(), area: area.clone(), hold, beats }); + // The entries written before are in the area this session had (released or left). + s.recovery.marked.clear(); + mark_left(s); return Ok(area); } Err(EngineError::Other("no free recovery area".into())) @@ -742,6 +791,8 @@ pub fn release(s: &mut Session) { drop_left(s, uid); } let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return }; + // Its `keeps-` entries go with the area's other entries. + s.recovery.marked.clear(); let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area); for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { let _ = store.remove(name); @@ -987,6 +1038,7 @@ impl RecoveryJob { let _ = store.remove(&format!("{file}{DOC_EXT}")); let _ = store.remove(&format!("{file}{META_EXT}")); } + mark_left(s); } // Best effort, as in `remove_copy`. _ => { @@ -1208,6 +1260,10 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< } match store.lock(area) { Ok(Lock::Held(h)) => { + // Copies an app left here when it had to leave the area stay its own while it runs. + if let Some(who) = keeper(s, store, area) { + return who; + } hold.push(h); Owner::Gone } @@ -1216,6 +1272,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) { return Owner::Running; } + if let Some(who) = keeper(s, store, area) { + return who; + } // Best effort: without it another app might offer the copies too, which loses nothing. if take && let Ok(t) = beat(store, area) { s.recovery.adopted.insert(area.to_string(), t); @@ -1225,6 +1284,42 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< } } +/// Who keeps the copies left in `area` by an app that had to leave it, from the entries +/// `<its area>/keeps-<area>` ([`mark_left`]): this session, or another app that is running. `None`: +/// no app that runs keeps them. An entry counts only in an area this account can lock or tell is +/// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be +/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing). +fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option<Owner> { + let Ok(names) = store.list() else { return Some(Owner::Running) }; + let mark = format!("/{KEEPS}{area}"); + let mut who = None; + for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) { + match holder(s, store, k) { + Owner::Mine => return Some(Owner::Mine), + Owner::Running => who = Some(Owner::Running), + Owner::Gone => {} + } + } + who +} + +/// Whose area `area` is, without taking it over: this session's, a running app's, or nobody's (a +/// lock taken to tell goes at once). An area whose lock fails (not a recovery area of this +/// account, a lock file that can't be opened) is nobody's. +fn holder(s: &Session, store: &dyn RecoveryStore, area: &str) -> Owner { + if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) { + return Owner::Mine; + } + match store.lock(area) { + Ok(Lock::Held(_)) | Err(_) => Owner::Gone, + Ok(Lock::Busy) => Owner::Running, + Ok(Lock::Unsupported) => { + let runs = store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()); + if runs { Owner::Running } else { Owner::Gone } + } + } +} + /// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The /// area is removed while it is still held, so an app taking a new area of that name meanwhile /// finds it held or gone and takes the next name. diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8567a7ec21..8c86f0dfe3 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> { "Run Script", [], None, - "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script), fuelLeft, fuelUsed, hostCalls and the fields above", + "{path?: a .js or .jsx file | source?: the script's text (exactly one of the two), name?: the name errors and the report use (default: the script's #script line, else the path's file name, or \"script\"), args?: {…} (at most 1 MiB as JSON; vc.args in the script), answers?: [{kind: alert|confirm|prompt, value: \"ok\" (alert) | true or false (confirm) | text or null (prompt)}] (for a run with nobody to ask: the answers to its dialogs, in order; at most 10000 and 1 MiB as JSON; refused in the app, where the person answers; an empty object is an empty list), maxFuel?: whole number from 1 to 100000000000 (instructions in all; default 1000000000)} run a JavaScript file against the open documents, through the vc object: vc.run(id, params) runs a command and returns its result, a failed command throws an error the script can catch and puts back the documents and the session state it changed, while any file it wrote stays written and is named; vc.document is a snapshot of the active document or null; vc.commands lists the commands a script may run; vc.log(value) adds to the report and the Script Console (script.console); $.write(...values) writes the values to the Script Console as String(value) gives them, and $.writeln(...values) does the same and ends the line; $.evalFile(path, timeout?) runs a file with the same globals and returns its last value (timeout, in milliseconds from 0, is taken and not waited on), $.fileName is the file being run (empty for text), $.includePath the #includepath list and $.engineName the engine's name (VectorCraft without #targetengine); alert(message[, title, errorIcon]), confirm(message[, noAsDflt, title]) and prompt(message, preset[, title]) open a dialog with a Stop button (default title \"Script Alert\"; message and preset as String(value) gives them; errorIcon and noAsDflt as Boolean(value) gives them; No is the default with noAsDflt): alert returns undefined, confirm true for Yes and false for No, prompt the field's text for OK and null for Cancel; in the app the run waits at the call for the person's answer and Stop ends it as script.stop does, a dialog cannot open inside a callback of a built-in, a getter, a conversion or a file $.evalFile runs and throws there (an #include file's code is the script's own and can), and a run with nobody to ask takes the next of answers, and ends when none is left or the next is for another kind of dialog, or when the script ends with answers unused; vc.args is args. Before anything runs, the script's preprocessor lines (# or //@ at the start of a line, outside strings and comments) are read: #include \"file\" and //@include put that file's text in place of the line, looked for beside the file that includes it, then in each #includepath folder in turn (separated by ;, relative to that file's folder; a later #includepath replaces the list), then in each JSINCLUDE folder; #script names the run; #target takes any value and changes nothing; #strict on makes an assignment to a read-only or getter-only property, or a new property on an object that is not extensible (preventExtensions, seal, freeze, vc, $), throw a TypeError, without the rest of ES5 strict mode, and #strict off turns that off (the last #strict line of the script and its includes sets the run; off without one; a $.evalFile file's own line holds for its code); #targetengine name runs the script in the named engine, whose globals and functions stay for the next run that names it until script.engine.drop drops it or the session ends (script.engines lists them; a name of letters, digits and _ not starting with a digit, or a quoted one of 1 to 64 bytes without control characters, matched as written; only in the script itself, once, before its code and #include lines; main, and a script without the line, get a new engine each run; a session keeps 8 named engines, a ninth name is refused, each has 64 MiB of heap, and an uncaught error, a limit or script.stop keeps the globals the run set while the documents go back; host functions kept in a global act for the run that calls them; an error in code an earlier run compiled is at that run's file, line and column, within 8 MiB of source maps kept for each engine, the oldest going first; not on the web); #engine is refused as unsupported, any other #name as unknown, and another //@name is a comment. A script run from a file reads its includes and $.evalFile's files only in that file's folder and the folders below it, refusing a path with a .. name or a link that leads out (a JSINCLUDE folder outside is passed over); a script given as text reads none. File(path) and Folder(path) name files and folders and read them in the same folders, and inside the automation read roots when they are set: exists, length, created, modified, getFiles(mask or function; at most 2048 entries), resolve, and open('r') with read, readln, readch, seek, tell and close; a path outside, a network or device path and a link that leads out give false or null with error \"Script file access denied\", a file is read whole at most 8 MiB (checked from its size first) and a run reads at most 32 MiB, and writing, the file dialogs and execute return false or null with error set. An error in another file is at that file's line and column; vc.api.version and vc.api.has(feature) describe the API. The run is one transaction: on success each changed document gets one undo step, Run Script, and each preference it changed keeps its value, and on an uncaught error, a limit or script.stop every open document and the session go back as they were; files a command wrote before that stay written and are named. While it runs, other commands that change anything end with a busy error. Commands that change anything outside the documents (script.*, except script.check, plugin.*, prefs.*, actionSet.*, shortcutSet.*, help.*, print.*, library.*, importMatches.*, edit.undoGroup.*, file.recovery.*, stroke.widthProfile.*, saving, importing or deleting a preset, text.addFontFiles, text.findFontFiles, text.rescanFonts, links.findFiles, file.place.queue, color.loadProfile, color.removeProfile, file.print, view.proofSetup, view.proofColors, view.overprintPreview, view.separationsPreview) throw. VectorCraft's own limits: 8 MiB of source a file and 32 MiB in all, 64 included or evaluated files 16 deep, 1024 paths looked up for them, 256 MiB of heap (128 MiB on the web), 2000000 host calls, 200000000 instructions without a pause, 64 levels, 16 MiB and 1048576 entries for a value crossing to or from a command, 10000 log entries and 4 MiB of log, 16 KiB of a dialog's message, 256 bytes of its title and 16 KiB of a prompt's preset (past these a RangeError), and 100000 objects and 512 MiB of new document data, past which a person is asked to continue or stop and a run with nobody to ask ends → {ok: true, name, changed: [document names], written: [paths], log: [values], fuelUsed, hostCalls, runTimeMs (the time the script ran, its commands included, waits between frames and for a person's answer left out; 0 without a clock), timingAvailable (false on the web), hostCallCounts: {vcRun, documentReads, commandListReads, vcLog, consoleWrites, fileCalls, fileExecutes}, slowestCommands: [{id, calls, totalMs, maxMs}] (at most 5, the longest in all first, then by id), console: {lines: [text], droppedLines, truncatedLines} (the run's own output, vc.log lines and error), dialogs: [{kind, message, title, errorIcon (alert), noAsDefault (confirm), preset (prompt), answer: \"ok\" | true | false | text | null | \"stop\" | \"missing\"}] (in the order they opened, at most 10000 and 1 MiB), dialogCount, dialogsTruncated}, or, in the app, {running: true, name} and the report later in ui.inspect's scriptReport; a run that fails ends with its error, and its report there also has kind, message, line, column, file (when the error is in another file than the script, or in code an earlier run of its engine compiled), fuelLeft, fuelUsed, hostCalls and the fields above", always, run ), diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 29a357f730..746be9c244 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -13,8 +13,8 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin use vectorcraft_geom::{Rect, shapes}; use super::{ - CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, id_param, ids_param, index, number_or, switch_or, - targets, + CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, ids_param, index, number_or, object_param, + switch_or, targets, }; /// The most slices one Divide Slices makes. @@ -680,7 +680,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> { fn set_rect(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "object.slice.setRect"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let r = rect_param(p, C)?; let d = &s.doc()?.doc; if d.slice(id).is_none() { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index e97659bfc9..4153fedd51 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -206,11 +206,31 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool { ids.is_empty() && p.get("ids").is_none() && p.get("id").is_none() } +/// The objects `stroke.set` edits with no `item`: a selected group or layer with a stroke row of +/// its own edits that row, as the Stroke panel does with the group targeted (#6739); the other +/// targets reach their painted leaves, as before. +fn own_stroke_or_leaves(s: &Session, p: &Value) -> Result<Vec<vectorcraft_doc::NodeId>> { + let picked = super::targets(s, p)?; + let d = &s.doc()?.doc; + let own = |id: &vectorcraft_doc::NodeId| { + d.node(*id).is_some_and(|n| { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.is_compound_shape() && n.appearance.stroke().is_some() + }) + }; + let (mut out, rest): (Vec<_>, Vec<_>) = picked.into_iter().partition(own); + for id in super::leaf_targets(s, &rest)? { + if !out.contains(&id) { + out.push(id); + } + } + Ok(out) +} + fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "stroke.set"; let change = StrokeChange::parse(p, C)?; let item = item_target(s, p, C)?.of_kind(s, false); - let mut ids = item.targets(s, p)?; + let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? }; let new_art = no_targets(&ids, p); if item == ItemTarget::Top { // Images and symbol instances take no stroke from the panel. @@ -261,7 +281,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> { return Ok(()); } if index.is_none() && n.appearance.stroke().is_none() { - n.appearance.set_stroke(Paint::solid(Color::BLACK)); + // Options alone (cap, join, miter limit, dashes, alignment) don't make a stroke + // visible: the row made for them has no paint. A weight, an arrowhead or a profile + // still gives the object the black stroke the panel draws (#6739). + let visible = change.weight.is_some() || sa.is_some() || ea.is_some() || profile.is_some(); + n.appearance.set_stroke(if visible { Paint::solid(Color::BLACK) } else { Paint::None }); } if let Some(st) = n.appearance.stroke_at_mut(index) { set(st); diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index a5f677f5dd..606ab40fcf 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -19,7 +19,7 @@ use vectorcraft_doc::symbol_overrides::SymbolOverride; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, SymbolRegistration}; use vectorcraft_geom::{Affine, Point}; -use super::brushsym::{natural_size, place_xf}; +use super::brushsym::{fit_parts, natural_size, place_xf}; use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param}; /// The name of the undo step that enters symbol editing mode. @@ -425,8 +425,8 @@ fn each_changed_instance(n: &mut Node, name: &str, f: &mut dyn FnMut(&mut Vec<Sy } /// Each instance's appearance changes to objects of symbol `name`'s art follow those objects -/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes to -/// objects the art still has. That covers the instances on the layers and in the art of the +/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes the art +/// still draws ([`fit_parts`]). That covers the instances on the layers and in the art of the /// document's other symbols, graph designs, pattern tiles (the tiles pattern editing's Cancel puts /// back included) and brushes, opacity-mask art included. The editing layer holds no instance of /// the symbol, since [`sync`] refuses one. @@ -435,7 +435,7 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc<Node>], bool), new: let mut follow = |o: &mut Vec<SymbolOverride>| { follow_paths(o, old, new); match &art { - Some(art) => vectorcraft_doc::symbol_overrides::retain_parts(o, art), + Some(art) => fit_parts(o, art), None => o.clear(), } }; diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 04a2c5504a..c047b94900 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> { "Set Tab Stops", ["Window", "Tabs"], None, - "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", + "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, or no more than each targeted paragraph already holds, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", has_selection, set ), @@ -38,10 +38,6 @@ pub fn specs() -> Vec<CommandSpec> { fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { const C: &str = "text.tabs.set"; let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?; - let max = TabStop::MAX_STOPS; - if arr.len() > max { - return Err(bad(C, format!("at most {max} tab stops"))); - } let mut stops = vec![]; for s in arr { let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?; @@ -90,10 +86,39 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { } fn set(s: &mut Session, p: &Value) -> Result<Value> { + // More than the limit is taken only when every targeted paragraph already holds at least as + // many stops, so a paragraph that holds more can still lose them one at a time. The supplied + // list is counted before it is read. + let max = TabStop::MAX_STOPS; + let supplied = p.get("stops").and_then(Value::as_array).map_or(0, Vec::len); + if supplied > max && supplied > fewest_held(s, p)? { + return Err(bad("text.tabs.set", format!("at most {max} tab stops"))); + } let stops = parse_stops(p)?; apply(s, p, stops, "Tab Stops") } +/// The fewest tab stops a paragraph that `text.tabs.set` with `p` targets holds (0 with none). +fn fewest_held(s: &Session, p: &Value) -> Result<usize> { + let ids = text_targets(s, p, "text.tabs.set")?; + let range = TextRange::parse(p, "text.tabs.set")?; + let doc = &s.doc()?.doc; + let mut fewest: Option<usize> = None; + for id in &ids { + if let Some(NodeKind::Text(t)) = doc.node(*id).map(|node| &node.kind) { + let held = match para_span(range, t) { + None => t.para_styles().map(|pa| pa.tabs.len()).min(), + Some(r) => r.map(|i| t.para_at(i).tabs.len()).min(), + }; + fewest = match (fewest, held) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + } + } + Ok(fewest.unwrap_or(0)) +} + fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> { let ids = text_targets(s, p, "text.tabs.set")?; let range = TextRange::parse(p, "text.tabs.set")?; @@ -159,14 +184,52 @@ mod tests { let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string(); assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters")); assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character")); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb"})).unwrap(); let many: Vec<Value> = (0..257).map(|i| json!({"position": i})).collect(); - assert!(err(Value::from(many.clone())).contains("at most 256 tab stops")); - let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap(); - assert_eq!(ok.len(), 256); + let e = s.execute("text.tabs.set", &json!({ "stops": many.clone() })).unwrap_err().to_string(); + assert!(e.contains("at most 256 tab stops"), "{e}"); + // The supplied list is counted, not the stops left once those at one position merge. + let same: Vec<Value> = (0..257).map(|_| json!({"position": 5})).collect(); + assert!(s.execute("text.tabs.set", &json!({ "stops": same })).unwrap_err().to_string().contains("at most 256 tab stops")); + assert_eq!(s.execute("text.tabs.set", &json!({ "stops": &many[..256] })).unwrap()["stops"], json!(256)); let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap(); assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€')); } + /// A paragraph that already holds more than 256 stops (from a file) can lose them one at a + /// time: a list no longer than every targeted paragraph's own is taken, a longer one is not, and + /// a paragraph with fewer stops targeted beside it keeps the limit. + #[test] + fn stops_can_be_removed_from_a_paragraph_holding_more_than_the_limit() { + use vectorcraft_doc::{NodeId, NodeKind, TabAlign, TabStop}; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap()); + let held: Vec<TabStop> = + (0..300).map(|i| TabStop { position: f64::from(i), align: TabAlign::Left, leader: String::new(), align_on: '.' }).collect(); + s.edit("Load", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.edit_paras(None, |pa| pa.tabs = held.clone()); + } + Ok(()) + }) + .unwrap(); + let list = |n: i32| json!({ "stops": (0..n).map(|i| json!({"position": i})).collect::<Vec<_>>() }); + assert!(s.execute("text.tabs.set", &list(301)).unwrap_err().to_string().contains("at most 256 tab stops")); + assert_eq!(s.execute("text.tabs.set", &list(299)).unwrap()["stops"], json!(299)); + assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"].as_array().unwrap().len(), 299); + // The second paragraph (bytes 4 on) now holds 2 stops: 298 for both paragraphs is refused, + // and 298 for the first alone is taken. + s.execute("text.tabs.set", &json!({"stops": [{"position": 1}, {"position": 2}], "start": 4, "end": 4})).unwrap(); + let mut both = list(298); + assert!(s.execute("text.tabs.set", &both).is_err()); + both["start"] = json!(0); + both["end"] = json!(0); + assert_eq!(s.execute("text.tabs.set", &both).unwrap()["stops"], json!(298)); + } + #[test] fn set_get_clear_tabs() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 00f1dd8adb..edfab850f5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -11,8 +11,8 @@ use super::menucmds::whole_or; use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; use super::{ - CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param, - index, number_or, point_param, str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, index, + number_or, object_param, point_param, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec<CommandSpec> { @@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", 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: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", 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: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> { "Step Type", [], None, - "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", + "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the space after the character before it, or at the start of a line (of the text, a paragraph, or after a forced line break) the space before the character after it (kerningBefore, as text.setRangeStyle), and nothing at an empty line's start; leading its paragraph), else every selected type object → {ids}", has_doc, type_step ), @@ -148,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> { "Reset Tracking and Kerning", [], Some("Cmd+Alt+Q"), - "{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}", + "{ids?: [id…]} set tracking to 0 and clear manual kerning (kerning and kerningBefore) without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning there, as type.step steps it), else in every selected type object, as one undo step → {ids}", has_selection, reset_tracking ), @@ -272,7 +272,7 @@ fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> { fn edit_range(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.editRange"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let (a, b) = range_of(p, len, C)?; @@ -337,7 +337,7 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result<Vec<NodeId fn start_list(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.startList"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let at = offset_or(p, "at", len, C)?.min(len); @@ -365,7 +365,7 @@ fn start_list(s: &mut Session, p: &Value) -> Result<Value> { } fn get_range(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?; + let id = object_param(p, "id", "text.getRange")?.ok_or_else(|| bad("text.getRange", "missing `id`"))?; if switch_or(p, "story", false, "text.getRange")? { return get_story(s, p, id); } @@ -430,6 +430,8 @@ pub(crate) struct CharChange { leading: Option<Option<f64>>, tracking: Option<f64>, kerning: Option<Option<f64>>, + /// The space before a character that starts a line by itself (`kerningBefore`). + kerning_before: Option<f64>, optical_kerning: Option<bool>, baseline_shift: Option<f64>, h_scale: Option<f64>, @@ -691,7 +693,7 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> { impl CharChange { /// `setup` gives the superscript, subscript and small caps proportions. pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> { - check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; + check_numbers(p, &["size", "tracking", "kerningBefore", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; check_switches( p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"], @@ -720,6 +722,7 @@ impl CharChange { Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || v.eq_ignore_ascii_case("auto") => Some(None), Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)), }, + kerning_before: num("kerningBefore"), optical_kerning: match p.get("kerning") { Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true), Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false), @@ -777,6 +780,7 @@ impl CharChange { && self.leading.is_none() && self.tracking.is_none() && self.kerning.is_none() + && self.kerning_before.is_none() && self.optical_kerning.is_none() && self.baseline_shift.is_none() && self.h_scale.is_none() @@ -883,6 +887,9 @@ impl CharChange { if let Some(v) = self.kerning { st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0)); } + if let Some(v) = self.kerning_before { + st.kerning_before = v.clamp(-1000.0, 10000.0); + } if let Some(v) = self.optical_kerning { st.optical_kerning = v; } @@ -1254,7 +1261,7 @@ fn drawn_glyph( fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setGlyphAlternate"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let gid = match p.get("gid") { None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")), Some(Value::Null) => None, @@ -1344,7 +1351,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> { fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.glyphAlternates"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let (ch, bytes, face) = glyph_target(&t, p, C)?; let default = face.glyph_for(ch); @@ -1359,7 +1366,7 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> { fn touch_type(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.touchType"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; check_numbers(p, &["start"], C)?; let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?; if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -1585,7 +1592,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> { fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.createInPath"; - let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; + let pid = object_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; let on_path = match str_param(p, "mode").unwrap_or("area") { "area" => false, "onPath" | "path" => true, @@ -1694,17 +1701,16 @@ pub(crate) fn editing_story(s: &Session) -> Vec<NodeId> { s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default() } +/// A text range a type shortcut changes: its type object, its bytes, and the caret's byte when it +/// came from a caret ([`type_ranges`]). +pub(crate) type TypeRange = (NodeId, usize, usize, Option<usize>); + /// The text ranges a type shortcut changes: the range of `id`, else all the text of the type /// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the /// story after Select All in threaded text, [`editing_story`]), else all the text of every selected /// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte -/// (None: nothing changes there). Each range comes with whether it came from a caret. -pub(crate) fn type_ranges( - s: &mut Session, - p: &Value, - c: &str, - at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>, -) -> Result<Vec<(NodeId, usize, usize, bool)>> { +/// (None: nothing changes there). Each range comes with the caret's byte when it came from a caret. +pub(crate) fn type_ranges(s: &mut Session, p: &Value, c: &str, at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>) -> Result<Vec<TypeRange>> { // (text, its range; None: all of it). let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? { // An object that isn't type is refused as `ids` of it is. @@ -1728,10 +1734,10 @@ pub(crate) fn type_ranges( let text = text_ref(s, id)?.plain_text(); let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len()))); let (a, b, caret) = if a < b { - (a, b, false) + (a, b, None) } else { match at_caret(&text, a) { - Some((a, b)) => (a, b, true), + Some((x, y)) => (x, y, Some(a)), None => continue, } }; @@ -1763,24 +1769,31 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> { Step::Tracking | Step::Kerning => prefs.tracking_increment, Step::BaselineShift => prefs.baseline_shift_increment, }; - // The ranges stepped. At a caret: kerning is the space after the character before it, leading - // the paragraph's; the rest wait for a selection. + // The ranges stepped. At a caret: kerning is the space after the character before it, or at + // the start of a line the space before the character after it (#5111), leading the + // paragraph's; the rest wait for a selection. let ranges = type_ranges(s, p, C, |text, a| match step { - Step::Kerning => Some((edit::prev_char(text, a), a)), + Step::Kerning => edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)), Step::Leading => { let para = edit::paragraph_at(text, a); Some((para.start, para.end)) } _ => None, })?; + // At a caret, the kerning stepped: (whether it is the space before the character, its value). let mut caret_kerning = Vec::with_capacity(ranges.len()); for &(id, a, b, caret) in &ranges { - let value = if matches!(step, Step::Kerning) && caret { - let t = text_ref(s, id)?; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b)) - } else { - None + let value = match caret { + Some(c) if matches!(step, Step::Kerning) => { + let t = text_ref(s, id)?; + if edit::caret_kerning_target(&t.plain_text(), c).is_some_and(|(_, before)| before) { + Some((true, edit::style_at(&t.runs, a + 1).kerning_before)) + } else { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + Some((false, effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))) + } + } + _ => None, }; caret_kerning.push(value); } @@ -1829,10 +1842,13 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> { st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?); } Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), - Step::Kerning => { - let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0)); - st.kerning = Some((current + inc).clamp(-1000.0, 10000.0)); - } + Step::Kerning => match caret_kerning.get(range_index).copied().flatten() { + Some((true, current)) => st.kerning_before = (current + inc).clamp(-1000.0, 10000.0), + caret => { + let current = caret.map_or_else(|| st.kerning.unwrap_or(0.0), |(_, v)| v); + st.kerning = Some((current + inc).clamp(-1000.0, 10000.0)); + } + }, Step::BaselineShift => { st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?; } @@ -1849,17 +1865,22 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> { /// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in /// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret, -/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there. -/// The automatic method remains selected. +/// only the kerning there changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns +/// there: of the character before it, or at the start of a line the space before the character +/// after it ([`edit::caret_kerning_target`]). The automatic method remains selected. fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> { - let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?; + let ranges = type_ranges(s, p, "type.resetTracking", |text, a| edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)))?; if !ranges.is_empty() { s.edit("Reset Tracking and Kerning", |d, _| { for &(id, a, b, caret) in &ranges { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; - edit::style_range(&mut t.runs, a, b, |st| { - st.kerning = None; - if !caret { + let before = caret.and_then(|c| edit::caret_kerning_target(&t.plain_text(), c)).map(|(_, before)| before); + edit::style_range(&mut t.runs, a, b, |st| match before { + Some(true) => st.kerning_before = 0.0, + Some(false) => st.kerning = None, + None => { + st.kerning = None; + st.kerning_before = 0.0; st.tracking = 0.0; } }); @@ -1975,7 +1996,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> { fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.discardEmpty"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; if edit::runs_len(&text_ref(s, id)?.runs) > 0 { return Ok(json!({"removed": false})); } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index dc424e1157..40d8af22ad 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -188,8 +188,10 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> { /// from a style instead leaves the text's own value alone. pub fn char_attrs(v: &CharStyle) -> Map<String, Value> { let mut m = to_attrs(v); - // A chosen glyph belongs to one character, not to a reusable character style. + // A chosen glyph belongs to one character, and the space before a line's first character to + // where it stands (#5111), not to a reusable character style. m.remove("glyphAlternate"); + m.remove("kerningBefore"); for (k, x) in [ ("font_version", json!(v.font_version)), ("fontAxes", json!(v.font_axes)), @@ -243,6 +245,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> { ("kinsoku_type", json!(v.kinsoku_type)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), + ("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)), ] { m.insert(k.into(), x); } @@ -530,6 +533,12 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma if m.contains_key("glyphAlternate") { return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)")); } + if m.contains_key("kerningBefore") { + return Err(bad( + &c, + "`kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it)", + )); + } let mut st = with_attrs(&CharStyle::default(), m, &c)?; let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key); for (key, value, lo, hi) in diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index e38798401e..77ecd9d786 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1336,7 +1336,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { /// The same for character styles: Auto kerning and the Roman baseline alignment, which the /// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to -/// one character, so it is not a reusable style attribute. +/// one character, and the space before a line's first character to where it stands (#5111), so +/// neither is a reusable style attribute. #[test] fn char_style_redefined_to_the_defaults_updates_its_text() { use vectorcraft_doc::CharAlign; @@ -1356,6 +1357,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() { assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); let mut reusable = settable::<CharStyle>(); reusable.remove("glyphAlternate"); + reusable.remove("kerningBefore"); assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute"); s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute"); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 33dad39d93..06368c6c7d 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; use super::pathops::{length_given, number_given, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, - str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param, + ok, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec<CommandSpec> { @@ -1273,7 +1273,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> { }; let mut items = Vec::with_capacity(raw.len()); for it in raw { - let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(it, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(it.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1304,7 +1304,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> { fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.reshapePath"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(p.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1335,7 +1335,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> { fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setPathHandle"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?; let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in"); diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 571c9c3469..5545e1be94 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -397,6 +397,49 @@ fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() { assert!(text(&s).para_at(1).roman_hanging); } +/// Paragraph panel menu › Korean Line Break Anywhere: new type has it off and saves that; +/// `text.setFormat {koreanLineBreakAnywhere}` sets it per paragraph in one undo step and refuses a +/// value that is not true or false, a paragraph saved before the option opens with it on, and +/// paragraph styles carry it. `text.setRangeStyle`, a character command, does not take it. +#[test] +fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "가나다\n라마"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert!(!text(&s).para_at(0).korean_line_break_anywhere, "new type has it off"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["korean_line_break_anywhere"], false); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"koreanLineBreakAnywhere": true, "start": 0, "end": 0})).unwrap(); + assert!(text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("korean_line_break_anywhere").is_none()); + let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap(); + assert!(back.korean_line_break_anywhere); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!text(&s).para_at(0).korean_line_break_anywhere); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text(&s).para_at(0).korean_line_break_anywhere); + // Saved before the option: on, the line breaks it was laid out with. + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert!(old.korean_line_break_anywhere); + assert!(s.execute("text.setFormat", &json!({"ids": [id], "koreanLineBreakAnywhere": "yes"})).is_err()); + assert!(s.execute("text.setRangeStyle", &json!({"id": id, "koreanLineBreakAnywhere": false})).is_err()); + assert!(text(&s).para_at(0).korean_line_break_anywhere, "a refused call changes nothing"); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Anywhere", "attrs": {"korean_line_break_anywhere": true}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Anywhere", "id": id})).unwrap(); + assert!(text(&s).para_at(1).korean_line_break_anywhere); + s.execute("paraStyle.new", &json!({"name": "Words", "attrs": {"korean_line_break_anywhere": false}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Words", "id": id})).unwrap(); + assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere); +} + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one /// undo step, the paragraph is saved with it only when it is not None and opens with it, an /// unknown value changes nothing, and paragraph styles carry it. diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index bb0e426c9b..e2f08d9cd6 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -60,6 +60,24 @@ pub fn specs() -> Vec<CommandSpec> { has_selection, path_options ), + cmd!( + "type.pathCenter.top", + "Center on Top", + ["Type", "Type on a Path"], + None, + "{ids?|id?} slide the selected type on a closed path along it so that the middle of the span between its start and end brackets lands on the path's topmost point as the object is drawn (after its transform, so a rotated, scaled or skewed path is measured as it appears; on a level stretch such as a rectangle's top edge, its middle; with several separate topmost points, the middle of the shortest part of the path that holds them all), keeping the span's length (a full turn stays a full turn), and set the first paragraph's alignment to Center unless it is Center or Justify Center, so the type is centered there. Point type and area type in the selection are left as they are. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them, and nothing changes. When every object is already centered there, nothing changes and no undo step is recorded → {ids, start, end} (the first object's brackets)", + has_closed_path_type, + path_center_top + ), + cmd!( + "type.pathCenter.bottom", + "Center on Bottom", + ["Type", "Type on a Path"], + None, + "{ids?|id?} as type.pathCenter.top, on the path's bottommost point as drawn. The type is not flipped: on the bottom of a path that runs clockwise it reads upside down until Type on a Path Options › Flip turns it → {ids, start, end} (the first object's brackets)", + has_closed_path_type, + path_center_bottom + ), cmd!( "type.dynamicText", "Dynamic Text", @@ -311,7 +329,10 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> { continue; } let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + // The frame is worked out in line space, where the lines are; vertical type turns it + // into text space with the lines. + let text_bounds = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() }; + let mut b = lay.line_xf.inverse().transform_rect_bbox(text_bounds); // Across, the frame spans the lines as they are aligned: from their starts to their ends // without the tracking after their last characters (`LineInfo::trailing`), not to the // ink, which tracking can pull past an advance. Area type aligns them alike. @@ -332,7 +353,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> { Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0), _ => (0.0, slack), }; - let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)); + let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0))); t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; refresh_bounds(t); } @@ -368,6 +389,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { // Point type is in no thread: its first paragraph starts in it. t.first_para_continues = false; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + // The lines are in line space; the frame's bounds are taken there too. + let fb = lay.line_xf.inverse().transform_rect_bbox(fb); let plain = t.plain_text(); let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8); // Soft wraps → explicit breaks (replace the space before the wrap when there is one). @@ -414,7 +437,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { // converting back to area type does not turn it on again. t.dynamic_text = false; t.kind = TextKind::Point; - t.xf *= Affine::translate((ox, baseline)); + // The origin is in the area type's line space; point type's line space puts its own + // origin there (vertical point type turns its lines about a different point). + let point_xf = if lay.vertical { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).line_xf } else { Affine::IDENTITY }; + t.xf *= lay.line_xf * Affine::translate((ox, baseline)) * point_xf.inverse(); refresh_bounds(t); } Ok(()) @@ -422,6 +448,136 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> { Ok(ids_json(&ids)) } +/// Whether type on a path `t` runs along one closed path (no open or compound path). +fn on_one_closed_path(t: &TextObject) -> bool { + matches!(&t.kind, TextKind::OnPath { path, .. } if path.subpaths.len() == 1 && path.is_closed()) +} + +/// Why `type.pathCenter.top` and `.bottom` refuse type on a path `ids`: those on an open or +/// compound path, then those in a thread; None when they take every one. +fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option<String> { + let open = ids.iter().filter(|id| !path_text(d.node(**id)).is_some_and(on_one_closed_path)).map(|id| id.0.to_string()).collect::<Vec<_>>(); + if !open.is_empty() { + let open = open.join(", "); + return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}")); + } + let frames = threaded_ids(d, ids); + (!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}")) +} + +/// `type.pathCenter.top` and `.bottom` are enabled for a selection holding type on a path, all of +/// it on one closed path and none of it in a thread. +fn has_closed_path_type(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let d = &st.doc; + let selected: Vec<NodeId> = st.selection.objects.to_vec(); + let paths: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect(); + if paths.is_empty() { + return Err("select type on a closed path".into()); + } + path_center_refusal(d, &paths).map_or(Ok(()), Err) +} + +/// The brackets that center type on a path `t` on its path's topmost point (`bottom`: its +/// bottommost) as `t.xf` draws it, its span keeping its length; None for a path with no length or +/// no height. +fn centered_brackets(t: &TextObject, bottom: bool) -> Option<(f64, Option<f64>)> { + let TextKind::OnPath { path, end, .. } = &t.kind else { return None }; + let (from, to) = t.kind.path_span()?; + let arc = vectorcraft_geom::ArcPath::new(path); + if arc.is_empty() { + return None; + } + let flip = if bottom { Affine::FLIP_Y } else { Affine::IDENTITY }; + let middle = arc.topmost(flip * t.xf)? / arc.len(); + let run = to - from; + let start = (middle - run * 0.5).rem_euclid(1.0).clamp(0.0, 1.0); + // Without an end bracket the type runs once round; a full turn's end stays on its start. + let end = end.map(|_| if run >= 1.0 - 1e-12 { start } else { (start + run).rem_euclid(1.0).clamp(0.0, 1.0) }); + Some((start, end)) +} + +/// Whether brackets `a` and `b`, fractions of a closed path's length, are at one place within +/// rounding (0 and 1 are one place). +fn same_bracket(a: f64, b: f64) -> bool { + let d = (a - b).rem_euclid(1.0); + d.min(1.0 - d) <= 1e-9 +} + +fn path_center_top(s: &mut Session, p: &Value) -> Result<Value> { + path_center(s, p, false) +} + +fn path_center_bottom(s: &mut Session, p: &Value) -> Result<Value> { + path_center(s, p, true) +} + +/// Type › Type on a Path › Center on Top and Center on Bottom: slide the selected type on closed +/// paths along them so the middle of each span lands on the path's top (`bottom`: its bottom) as +/// drawn, and center the first paragraph, in one undo step; none when nothing changes. +fn path_center(s: &mut Session, p: &Value, bottom: bool) -> Result<Value> { + let c = if bottom { "type.pathCenter.bottom" } else { "type.pathCenter.top" }; + let centered = |j: Justify| matches!(j, Justify::Center | Justify::JustifyCenter); + // Each object the command changes, with its new brackets when they move and whether its first + // paragraph is set to Center. + let (ids, changes) = { + let d = &s.doc()?.doc; + let ids: Vec<NodeId> = texts(s, p, c)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect(); + if ids.is_empty() { + return Err(bad(c, "select type on a closed path")); + } + if let Some(why) = path_center_refusal(d, &ids) { + return Err(bad(c, why)); + } + let (mut changes, mut flat) = (vec![], vec![]); + for id in &ids { + let Some(t) = path_text(d.node(*id)) else { continue }; + let TextKind::OnPath { start, end, .. } = &t.kind else { continue }; + let Some((to_start, to_end)) = centered_brackets(t, bottom) else { + flat.push(id.0.to_string()); + continue; + }; + let stays = same_bracket(*start, to_start) + && match (end, to_end) { + (None, None) => true, + (Some(a), Some(b)) => same_bracket(*a, b), + _ => false, + }; + let align = !centered(t.para_at(0).justify); + if !stays || align { + changes.push((*id, (!stays).then_some((to_start, to_end)), align)); + } + } + if !flat.is_empty() { + let flat = flat.join(", "); + return Err(bad(c, format!("type on a path with no length or no height cannot be centered: {flat}"))); + } + (ids, changes) + }; + if !changes.is_empty() { + s.edit(if bottom { "Center on Bottom" } else { "Center on Top" }, |d, _| { + for (id, brackets, align) in &changes { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + if let (Some(b), TextKind::OnPath { start, end, .. }) = (brackets, &mut t.kind) { + (*start, *end) = *b; + } + if *align { + t.edit_paras(Some(0..1), |para| para.justify = Justify::Center); + } + refresh_bounds(t); + } + Ok(()) + })?; + } + let d = &s.doc()?.doc; + let (start, end) = ids.first().and_then(|id| path_text(d.node(*id))).map_or((0.0, None), |t| match &t.kind { + TextKind::OnPath { start, end, .. } => (*start, *end), + _ => (0.0, None), + }); + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "start": start, "end": end})) +} + /// The options `type.pathOptions` sets; with none of them given it queries. const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"]; diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index c79ec0b86e..218871972f 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -8,7 +8,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::select::selectable_ids; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, object_param, str_param}; /// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in /// points, opacity in percent. @@ -152,7 +152,7 @@ fn paintable(doc: &Document) -> Vec<NodeId> { } fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> { - let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?; + let id = object_param(p, "id", "select.magicWand")?.ok_or_else(|| bad("select.magicWand", "missing id"))?; let st = s.doc()?; let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone(); let w = s.menu.wand.clone(); diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index 9e641a6e6a..d1c66d9d3c 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -645,7 +645,7 @@ fn flatten(rgba: &[u8], matte: [u8; 3]) -> impl Iterator<Item = [u8; 3]> + '_ { /// The File Info text `s.metadata` lets into the files: `(keyword, text)`. fn metadata(doc: &Document, s: &WebSettings) -> Vec<(&'static str, String)> { - doc.metadata.png_text(&doc.title).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() + doc.metadata.png_text(doc.info_title()).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect() } /// Encode straight RGBA pixels (`w`×`h`) as `s` says; `doc` gives the metadata. diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 9bad62a6c8..58832adf29 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -285,8 +285,8 @@ fn document_base(s: &Session) -> Value { json!({ "uid": st.uid, "title": st.title(), - // The File Info title (`file.info`), where `title` comes from the file name once the document has one. - "fileInfoTitle": d.title, + // The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name. + "fileInfoTitle": d.info_title(), "path": st.path, "dirty": st.is_dirty(), "revision": st.revision, diff --git a/crates/engine/src/script/include.rs b/crates/engine/src/script/include.rs index ce2e1d6783..4ec68dbbde 100644 --- a/crates/engine/src/script/include.rs +++ b/crates/engine/src/script/include.rs @@ -88,6 +88,13 @@ impl SourceMap { let name = self.files.get(seg.file).cloned().unwrap_or_else(|| Arc::from("")); (seg.file, name, seg.line.saturating_add(line - seg.out_line), column) } + + /// The memory the map holds, in bytes: its lists and its file names, each name counted in full + /// even when another map shares it. + pub(crate) fn bytes(&self) -> usize { + let names: usize = self.files.iter().map(|f| f.len() + 2 * size_of::<usize>()).sum(); + self.files.capacity() * size_of::<Arc<str>>() + self.segments.capacity() * size_of::<Segment>() + names + } } /// How a preprocessor failure ends the run. diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index c3b3084e21..0821a11e71 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -221,12 +221,69 @@ fn current(link: &Link) -> std::result::Result<Rc<Host>, HostError> { } /// A named engine: its interpreter, with the globals its runs left (`None` while a run has it), -/// the link its host functions use, and its heap after its last run. +/// the link its host functions use, its heap after its last run, and the source maps of the code +/// its earlier runs compiled. struct Engine { name: String, realm: Option<Realm>, link: Link, heap: usize, + maps: Rc<RefCell<KeptMaps>>, +} + +/// A named engine taken for a run: its realm (`None` for an engine made now), its link and its +/// kept source maps. +type TakenEngine = (Option<Realm>, Link, Rc<RefCell<KeptMaps>>); + +/// The most bytes of source maps ([`include::SourceMap::bytes`]) a named engine keeps from its +/// earlier runs. +pub const KEPT_MAP_BYTES: usize = 8 << 20; + +/// A source map an engine kept from an earlier run: the realm's tag for the source +/// ([`Realm::error_tag`]), the map, and for the run's own script, that run's name, which stands for +/// a script given as text. +struct KeptMap { + tag: u32, + map: include::SourceMap, + script: Option<Arc<str>>, + bytes: usize, +} + +/// The source maps a named engine kept from its earlier runs, by tag, oldest first, in at most +/// [`KEPT_MAP_BYTES`]: the oldest go first to make room, and a map larger than that is not kept. +#[derive(Default)] +struct KeptMaps { + maps: std::collections::VecDeque<KeptMap>, + bytes: usize, +} + +impl KeptMaps { + fn keep(&mut self, tag: u32, map: include::SourceMap, script: Option<Arc<str>>) { + let bytes = map.bytes() + script.as_ref().map_or(0, |s| s.len()); + let limit = kept_map_bytes(); + if bytes > limit || self.maps.back().is_some_and(|m| m.tag >= tag) { + return; + } + while self.bytes + bytes > limit { + let Some(old) = self.maps.pop_front() else { break }; + self.bytes -= old.bytes; + } + self.bytes += bytes; + self.maps.push_back(KeptMap { tag, map, script, bytes }); + } + + fn get(&self, tag: u32) -> Option<&KeptMap> { + self.maps.binary_search_by_key(&tag, |m| m.tag).ok().and_then(|i| self.maps.get(i)) + } +} + +/// The bytes of source maps a named engine keeps. +fn kept_map_bytes() -> usize { + #[cfg(test)] + if let Some(n) = TEST_KEPT_MAP_BYTES.with(Cell::get) { + return n; + } + KEPT_MAP_BYTES } /// The named engines of one thread's sessions, by their key, each in the order they were made. @@ -665,6 +722,14 @@ struct Host { /// The file, line and column of each error `$.evalFile` raised for a file it could not run, in /// order: the error's mark ([`HostError::Marked`]) is its index. eval_failures: RefCell<Vec<(Arc<str>, u32, u32)>>, + /// The realm's tag for the script ([`Realm::script_tag`]) once the run has started it, and + /// [`vectorcraft_js::UNKNOWN_SOURCE`] before. + script_tag: Cell<u32>, + /// The run's name as its report gives it, which [`Host::name`] shortens for console lines. + report_name: Arc<str>, + /// The source maps the named engine of the run kept from its earlier runs; `None` for a run + /// without one. + kept_maps: Option<Rc<RefCell<KeptMaps>>>, /// When the run started, where the platform has a clock. started: Option<std::time::Instant>, /// The time since [`Host::started`] at which a `$.sleep` ends. @@ -740,6 +805,23 @@ pub(crate) fn slowest(times: &HashMap<&'static str, (u64, u128, u128)>) -> Vec<c } impl Host { + /// Gives the named engine of the run the source maps of the code the run compiled: its script + /// and each file `$.evalFile` gave. A run that never started its script compiled nothing. + fn keep_maps(&self) { + let (Some(kept), tag) = (&self.kept_maps, self.script_tag.get()) else { return }; + if tag == vectorcraft_js::UNKNOWN_SOURCE { + return; + } + let mut kept = kept.borrow_mut(); + kept.keep(tag, std::mem::take(&mut *self.script_map.borrow_mut()), Some(self.report_name.clone())); + for (n, map) in std::mem::take(&mut *self.eval_maps.borrow_mut()).into_iter().enumerate() { + match u32::try_from(n + 1).ok().and_then(|n| tag.checked_add(n)).filter(|t| *t != vectorcraft_js::UNKNOWN_SOURCE) { + Some(t) => kept.keep(t, map, None), + None => break, + } + } + } + /// Adds a line to the run's console and, during a slice, to the session's. fn line(&self, entry: console::Entry, cut: bool) { let line = console::ConsoleLine { run: self.run, name: self.name.clone(), entry }; @@ -1358,34 +1440,55 @@ fn preprocessor_error(f: include::Failure) -> (RunError, Option<Arc<str>>) { (RunError { kind, message, line: f.line, column: f.column }, file) } -/// `e`, which the realm raised in source `tag` ([`Realm::error_source`]) with host error mark `mark` -/// ([`Realm::error_mark`]), at the line and column of the file it is in, with that file's name -/// unless it is the script's own. -fn located(host: &Host, tag: u32, mark: Option<u32>, e: &RunError) -> (RunError, Option<Arc<str>>) { - let map = |m: &include::SourceMap, own: bool| { +/// `e`, the error `realm` raised in the run of `host`, at the line and column of the file it is in, +/// with that file's name unless it is the run's own script. +fn located(host: &Host, realm: &Realm, e: &RunError) -> (RunError, Option<Arc<str>>) { + let map = |m: &include::SourceMap| { let (index, file, line, column) = m.locate(e.line, e.column); let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; - (RunError { kind: e.kind.clone(), message, line, column }, (!own || index > 0).then_some(file)) + (RunError { kind: e.kind.clone(), message, line, column }, index, file) }; // An error `$.evalFile` raised for a file it could not run, and no script caught, is at that // file's place. - if let Some((file, line, column)) = mark.and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) { + if let Some((file, line, column)) = realm.error_mark().and_then(|m| host.eval_failures.borrow().get(m as usize).cloned()) { let message = if e.kind == RunErrorKind::Syntax { relocated(&e.message, e.line, e.column, line, column) } else { e.message.clone() }; return (RunError { kind: e.kind.clone(), message, line, column }, Some(file)); } if e.line == 0 { return (e.clone(), None); } - // An error in code from another source, such as a function an earlier run of a named engine - // defined, has no place in this run's files, so it is given without one. + // An error in code from a source whose map is not at hand, such as code that `eval` compiled, + // is given without a place. let unplaced = || (RunError { kind: e.kind.clone(), message: e.message.clone(), line: 0, column: 0 }, None); - match tag { - 0 => map(&host.script_map.borrow(), true), + match realm.error_source() { + 0 => { + let (e, index, file) = map(&host.script_map.borrow()); + (e, (index > 0).then_some(file)) + } n if n != vectorcraft_js::UNKNOWN_SOURCE => match host.eval_maps.borrow().get(n as usize - 1) { - Some(m) => map(m, false), + Some(m) => { + let (e, _, file) = map(m); + (e, Some(file)) + } None => unplaced(), }, - _ => unplaced(), + // Code an earlier run of the named engine compiled, such as a function it defined, is at + // its place in that run's files: the run's script, a file it included, or a file + // `$.evalFile` gave. A script given as text is named by that run's name. + _ => { + let kept = host.kept_maps.as_ref().map(|k| k.borrow()); + match kept.as_ref().and_then(|k| k.get(realm.error_tag())) { + Some(kept) => { + let (e, index, file) = map(&kept.map); + let file = match &kept.script { + Some(name) if index == 0 && file.is_empty() => name.clone(), + _ => file, + }; + (e, Some(file)) + } + None => unplaced(), + } + } } } @@ -1438,17 +1541,17 @@ impl Session { } } - /// The realm of the named engine `name` for a run, with the link its host functions use: the - /// realm its last run left, or `None` for an engine made now. → the kind and text of the - /// failure when the engine cannot be had. - fn take_engine(&mut self, name: &str) -> std::result::Result<(Option<Realm>, Link), (include::FailKind, String)> { + /// The realm of the named engine `name` for a run, with the link its host functions use and the + /// source maps it kept: the realm its last run left, or `None` for an engine made now. → the + /// kind and text of the failure when the engine cannot be had. + fn take_engine(&mut self, name: &str) -> std::result::Result<TakenEngine, (include::FailKind, String)> { if cfg!(target_arch = "wasm32") { return Err((include::FailKind::Error, NO_ENGINES_ON_WEB.to_string())); } with_engines(&self.script_engines, |engines| { if let Some(e) = engines.iter_mut().find(|e| e.name == name) { let realm = e.realm.take().ok_or((include::FailKind::Error, ENGINE_BUSY.to_string()))?; - return Ok((Some(realm), e.link.clone())); + return Ok((Some(realm), e.link.clone(), e.maps.clone())); } if engines.len() >= MAX_ENGINES { return Err(( @@ -1457,15 +1560,20 @@ impl Session { )); } let link: Link = Rc::new(RefCell::new(None)); - engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0 }); - Ok((None, link)) + let maps = Rc::new(RefCell::new(KeptMaps::default())); + engines.push(Engine { name: name.to_string(), realm: None, link: link.clone(), heap: 0, maps: maps.clone() }); + Ok((None, link, maps)) }) } /// The end of a run in engine `engine` (`None` for a realm that is not kept): the link no longer - /// holds the run, and the engine keeps `realm`, or is dropped without one. + /// holds the run, and the engine keeps `realm`, with the source maps of the code the run + /// compiled, or is dropped without one. fn return_engine(&self, engine: Option<&str>, link: &Link, realm: Option<Realm>) { let host = link.borrow_mut().take(); + if let (Some(h), Some(_)) = (&host, &realm) { + h.keep_maps(); + } drop(host); let Some(name) = engine else { return }; let mut realm = realm; @@ -1668,9 +1776,9 @@ impl Session { }; let heap_bytes = if kept.is_some() { engine_heap() } else { HEAP_BYTES }; let limits = Limits { fuel: request.fuel, heap_bytes, ..Limits::default() }; - let (engine, realm, link) = match kept { - None => (None, None, Rc::new(RefCell::new(None))), - Some((engine, (realm, link))) => (Some(engine), realm, link), + let (engine, realm, link, kept_maps) = match kept { + None => (None, None, Rc::new(RefCell::new(None)), None), + Some((engine, (realm, link, maps))) => (Some(engine), realm, link, Some(maps)), }; // A realm made here gets the script extensions, `$` and the dialogs; a kept one has them // from its first run, and they act for the run that calls them. @@ -1716,6 +1824,9 @@ impl Session { script_map: RefCell::default(), eval_maps: RefCell::default(), eval_failures: RefCell::default(), + script_tag: Cell::new(vectorcraft_js::UNKNOWN_SOURCE), + report_name: Arc::from(name.as_str()), + kept_maps, started: clock(), wake_at: Cell::new(None), can_wait: realm.wait_flag(), @@ -1752,8 +1863,12 @@ impl Session { .and_then(|()| if fresh { realm.define_host_object("$", dollar_object(&link, info, &localization)) } else { Ok(()) }) .and_then(|()| if fresh { dialogs::install(&mut realm, &link) } else { Ok(()) }) .and_then(|()| if fresh { files::install(&mut realm, &link) } else { realm.reset_folder_current() }) - .and_then(|()| realm.start(&expanded.source)) - .map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e)) + .and_then(|()| { + let r = realm.start(&expanded.source); + host.script_tag.set(realm.script_tag()); + r + }) + .map_err(|e| located(&host, &realm, &e)) } }; if let Err((e, file)) = started { @@ -1832,7 +1947,7 @@ impl Session { fn script_failed(&mut self, job: Job, e: &RunError) -> Result<Value> { job.host.dialogs.stopped(); let fuel_left = job.realm.fuel_left(); - let (e, file) = located(&job.host, job.realm.error_source(), job.realm.error_mark(), e); + let (e, file) = located(&job.host, &job.realm, e); let mut report = failure_report(&job.name, &e, file.as_deref(), &job.host, fuel_left, job.realm.limits().fuel.saturating_sub(fuel_left)); close_run(self, &job.host, &mut report, ending(&e.kind)); self.end_job(job, e.kind == RunErrorKind::Internal); @@ -2127,6 +2242,8 @@ thread_local! { pub(crate) static TEST_GROWTH: Cell<Option<u64>> = const { Cell::new(None) }; /// Tests: the heap of a named engine in place of [`ENGINE_HEAP_BYTES`]. pub(crate) static TEST_ENGINE_HEAP: Cell<Option<usize>> = const { Cell::new(None) }; + /// Tests: the bytes of source maps a named engine keeps, in place of [`KEPT_MAP_BYTES`]. + pub(crate) static TEST_KEPT_MAP_BYTES: Cell<Option<usize>> = const { Cell::new(None) }; /// Tests: the `JSINCLUDE` folders in place of the environment's. pub(crate) static TEST_JSINCLUDE: RefCell<Option<Vec<std::path::PathBuf>>> = const { RefCell::new(None) }; /// Tests: the time since a run started, in place of its clock's. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index abf65d834a..3363bb4a0c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -20,6 +20,7 @@ mod brushlib; mod brushsym; mod build; mod cancel; +mod caret_kerning; mod charstroke; pub(super) mod clip; mod clipboard; @@ -205,6 +206,7 @@ mod typescale; mod typeundo; mod units; mod variables; +mod warp_images; mod webexport; mod widthpoints; mod widthprofiles; diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 8d12c83719..53cbb42ca9 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; -use vectorcraft_doc::{AppearanceItem, FillLayer, Node}; +use vectorcraft_doc::{AppearanceItem, FillLayer, LineJoin, Node}; use crate::*; @@ -185,6 +185,48 @@ fn group_rows_are_the_groups_own_stack() { assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00"); } +/// #6739: `stroke.set` without an item on a group with a stroke row of its own edits that row; +/// its members, which have no stroke (as paths a PDF import brings in), are left alone instead of +/// each getting a black 1 pt stroke. Options alone never make a stroke visible: a row created for +/// a join has no paint, while a weight still gives the usual black stroke. +#[test] +fn stroke_options_on_a_group_with_its_own_stroke_edit_that_stroke() { + let mut s = session(); + let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 250.0)); + for id in [a, b] { + run(&mut s, "appearance.removeItem", json!({"ids": [id.0], "index": 1})); + assert!(node(&s, id).appearance.stroke().is_none(), "the member has no stroke row"); + } + let g = NodeId(run(&mut s, "object.group", json!({"ids": [a.0, b.0]}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addStroke", json!({"ids": [g.0]})); + run(&mut s, "appearance.setItem", json!({"ids": [g.0], "index": 0, "color": "#ffffff", "weight": 2})); + let r = run(&mut s, "stroke.set", json!({"ids": [g.0], "join": "round"})); + assert_eq!(r["objects"], 1, "{r}"); + let gs = node(&s, g).appearance.stroke().unwrap().clone(); + assert_eq!((gs.join, gs.width, gs.paint.color().map(|c| c.to_hex())), (LineJoin::Round, 2.0, Some("#ffffff".to_string()))); + for id in [a, b] { + assert!(node(&s, id).appearance.stroke().is_none(), "a member keeps no stroke"); + } + // A group without a stroke of its own still reaches its members; for a join alone the row + // made on each has no paint, so nothing becomes visible. + let (c, d) = (rect(&mut s, 400.0), rect(&mut s, 550.0)); + for id in [c, d] { + run(&mut s, "appearance.removeItem", json!({"ids": [id.0], "index": 1})); + } + let h = NodeId(run(&mut s, "object.group", json!({"ids": [c.0, d.0]}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"ids": [h.0], "join": "bevel"})); + for id in [c, d] { + let st = node(&s, id).appearance.stroke().cloned().expect("a stroke row for the join"); + assert_eq!((st.paint, st.join), (Paint::None, LineJoin::Bevel), "no paint for a join alone"); + } + // A weight is a visible stroke: an object without one gets the black stroke, as before. + let e = rect(&mut s, 700.0); + run(&mut s, "appearance.removeItem", json!({"ids": [e.0], "index": 1})); + run(&mut s, "stroke.set", json!({"ids": [e.0], "weight": 3})); + let st = node(&s, e).appearance.stroke().cloned().unwrap(); + assert_eq!((st.paint, st.width), (Paint::solid(Color::BLACK), 3.0)); +} + #[test] fn text_items_take_item_paint() { let mut s = session(); diff --git a/crates/engine/src/tests/areafit.rs b/crates/engine/src/tests/areafit.rs index b6d6883430..7d8f759cf5 100644 --- a/crates/engine/src/tests/areafit.rs +++ b/crates/engine/src/tests/areafit.rs @@ -485,7 +485,7 @@ fn making_and_restyling_type_reports_its_overflow() { } /// The formats whose exports draw type. -const DRAWING_FORMATS: [&str; 8] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf"]; +const DRAWING_FORMATS: [&str; 9] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf", "hpgl"]; /// The warnings of `document.export {format, …options}` about type that doesn't fit its frame. fn export_overflow(s: &mut Session, format: &str, options: Value) -> Vec<String> { diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 491a471088..243ea564f4 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -117,13 +117,13 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); - assert_eq!(libs[4]["count"].as_u64(), Some(4)); + assert_eq!(libs[5]["count"].as_u64(), Some(4)); let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); assert_eq!( beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(), ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); - assert_eq!(libs[5]["count"].as_u64(), Some(4)); + assert_eq!(libs[6]["count"].as_u64(), Some(4)); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); @@ -132,6 +132,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { for (id, kind) in [ ("art-ribbons", "art"), ("calligraphic-nibs", "calligraphic"), + ("bristle-brushes", "bristle"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter"), ("game-trails", "art"), @@ -165,6 +166,77 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Bristle Brushes: one brush per tip shape, every value inside the range the brush keeps (a value +/// outside would be clamped without notice), names no other library or default brush uses; added +/// in one step, not twice, and saved as a library they reopen unchanged. +#[test] +fn the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges() { + use vectorcraft_brush::{Bristle, BristleShape}; + let mut s = session(); + let (_, lib) = cmd::brushlib::library(&s, "bristle-brushes").unwrap(); + assert_eq!(lib.name, "Bristle Brushes"); + let names: Vec<String> = lib.brushes.iter().map(|b| b.name.clone()).collect(); + assert!((8..=10).contains(&names.len()), "{names:?}"); + let within = |v: f64, (lo, hi): (f64, f64)| (lo..=hi).contains(&v); + let mut shapes = vec![]; + for b in &lib.brushes { + let BrushKind::Bristle(x) = &b.kind else { panic!("{} is not a bristle brush", b.name) }; + assert!(within(x.size, Bristle::SIZE_RANGE) && within(x.length, Bristle::LENGTH_RANGE), "{}", b.name); + for v in [x.density, x.thickness, x.opacity, x.stiffness] { + assert!(within(v, Bristle::PERCENT_RANGE), "{}: {v}", b.name); + } + let mut kept = b.clone(); + kept.sanitize(); + assert_eq!(&kept, b, "sanitizing changes {}", b.name); + assert!(b.name.chars().count() <= Bristle::MAX_NAME_CHARS, "{}", b.name); + if !shapes.contains(&x.shape) { + shapes.push(x.shape); + } + } + let every = [ + BristleShape::RoundPoint, + BristleShape::FlatPoint, + BristleShape::RoundBlunt, + BristleShape::FlatBlunt, + BristleShape::RoundCurve, + BristleShape::FlatCurve, + BristleShape::RoundAngle, + BristleShape::FlatAngle, + BristleShape::RoundFan, + BristleShape::FlatFan, + ]; + assert!(every.iter().all(|x| shapes.contains(x)), "{shapes:?}"); + // Names of its own: no default brush's, no other built-in library's, none twice. + let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect(); + let others: Vec<String> = cmd::brushlib::BUILTIN + .iter() + .filter(|b| b.id != "bristle-brushes") + .flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::<Vec<_>>()) + .collect(); + for (i, n) in names.iter().enumerate() { + assert!(!defaults.contains(&n.as_str()) && !others.contains(n) && !names[..i].contains(n), "{n}"); + } + + // Added to a document in one undo step; adding them again adds nothing. + let before = (undo_len(&s), brushes(&s)); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Bristle Brushes"})); + assert_eq!(strs(&r["added"]), names.iter().map(String::as_str).collect::<Vec<_>>()); + assert_eq!(undo_len(&s), before.0 + 1); + assert_eq!(brushes(&s), [before.1.clone(), names.clone()].concat()); + for b in &lib.brushes { + assert_eq!(&brush(&s, &b.name), b); + } + let again = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "bristle-brushes"})); + assert_eq!((strs(&r["added"]).len(), strs(&r["existing"]).len(), undo_len(&s)), (0, names.len(), again)); + // Saved as a library and loaded back: the same brushes, in the same order. + let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Bristles" })); + let b64 = saved["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Bristles.vectorcraft"})); + let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert_eq!(reopened.brushes, lib.brushes); +} + #[test] fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index d678815715..1a1fcd87b5 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -365,6 +365,255 @@ fn pattern_brushes_place_their_tiles_in_a_row() { assert_eq!(tiles[1].children().unwrap().len(), 1, "no frame, no rectangle"); } +/// `brush.replaceArt` reads the tiles `brush.placeArt` places back tile by tile, frames included: +/// every tile and the frame keep their sizes, and the frame keeps its place around the art. +#[test] +fn placed_pattern_tiles_go_back_into_the_brush_tile_by_tile() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let before = s.execute("brush.get", &json!({"name": name})).unwrap(); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name, "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap()); + let steps = undo_steps(&s); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [id.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let after = s.execute("brush.get", &json!({"name": name})).unwrap(); + let size = |v: &Value| { + let n: Node = serde_json::from_value(v.clone()).unwrap(); + let b = n.geometric_bounds().unwrap(); + (b.width(), b.height()) + }; + for key in ["side", "outer_corner", "start"] { + let (a, b) = (size(&before[key]), size(&after[key])); + assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{key}: {a:?} → {b:?}"); + } + assert!(after.get("end").is_none_or(Value::is_null) && after.get("inner_corner").is_none_or(Value::is_null)); + let frame: Rect = serde_json::from_value(after["side_frame"].clone()).unwrap(); + let art: Node = serde_json::from_value(after["side"].clone()).unwrap(); + let art = art.geometric_bounds().unwrap(); + assert!((frame.width() - 24.0).abs() < 1e-6 && (art.x0 - frame.x0 - 2.0).abs() < 1e-6, "{frame:?} around {art:?}"); + assert!(after["outer_corner_frame"].is_null() || after.get("outer_corner_frame").is_none()); +} + +/// Art that is not the tiles `brush.placeArt` places becomes a pattern brush's side tile, and the +/// other tiles stay; one tile group alone replaces only that tile. +#[test] +fn other_art_replaces_the_side_tile_of_a_pattern_brush() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let before = s.execute("brush.get", &json!({"name": name})).unwrap(); + let r = rect(&mut s, 100.0, 100.0, 30.0, 12.0); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [r.0]})).unwrap(); + let after = s.execute("brush.get", &json!({"name": name})).unwrap(); + let side: Node = serde_json::from_value(after["side"].clone()).unwrap(); + assert!(close(side.geometric_bounds().unwrap(), Rect::new(100.0, 100.0, 130.0, 112.0), 1e-6)); + assert!(after.get("side_frame").is_none_or(Value::is_null), "the old frame goes with the old art"); + assert_eq!((&after["outer_corner"], &after["start"]), (&before["outer_corner"], &before["start"])); + // One placed tile, its group dragged back alone: only the start tile changes. + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap()); + let start = node(&s, id).children().unwrap()[2].clone(); + assert_eq!(start.name.as_deref(), Some("Start Tile")); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [start.id.0]})).unwrap(); + let last = s.execute("brush.get", &json!({"name": name})).unwrap(); + assert_eq!((&last["side"], &last["outer_corner"]), (&after["side"], &after["outer_corner"])); + let art: Node = serde_json::from_value(last["start"].clone()).unwrap(); + assert!(close(art.geometric_bounds().unwrap(), start.geometric_bounds().unwrap(), 1e-6), "the start tile is the placed one"); + assert!(art.name.is_none(), "the tile's name stays on the canvas"); +} + +/// `brush.replaceArt` gives a brush new art in one undo step: strokes that use it redraw with it, +/// or with `applyToStrokes: false` keep the brush as it was while a copy with the new art becomes +/// the current brush. A brush no stroke uses changes in place either way. +#[test] +fn replace_art_applies_to_strokes_or_leaves_them_on_the_old_brush() { + let mut s = session(); + let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0); + s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap(); + let path = line(&mut s); + s.execute("brush.apply", &json!({"name": "Bar", "ids": [path.0]})).unwrap(); + let thin = brushed_bounds(&s, path).height(); + // A path painted with a brush made from the taller art: what the stroke should draw. + let tall = rect(&mut s, 10.0, 220.0, 40.0, 20.0); + s.execute("brush.new", &json!({"type": "art", "name": "Tall", "ids": [tall.0]})).unwrap(); + let reference = line(&mut s); + s.execute("brush.apply", &json!({"name": "Tall", "ids": [reference.0]})).unwrap(); + let want = brushed_bounds(&s, reference).height(); + assert!(want > thin * 2.0, "{want} against {thin}"); + // Apply to Strokes: the stroke redraws with the taller art. + let steps = undo_steps(&s); + let r = s.execute("brush.replaceArt", &json!({"name": "Bar", "ids": [tall.0]})).unwrap(); + assert_eq!(r["name"], "Bar"); + assert_eq!(undo_steps(&s), steps + 1); + assert!((brushed_bounds(&s, path).height() - want).abs() < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((brushed_bounds(&s, path).height() - thin).abs() < 1e-9, "undo puts the old art back"); + s.execute("edit.redo", &json!({})).unwrap(); + // Leave Strokes: the stroke keeps Bar as it was; Bar copy has the new art, after Bar, current. + let short = rect(&mut s, 10.0, 260.0, 40.0, 4.0); + let bar = s.execute("brush.get", &json!({"name": "Bar"})).unwrap(); + let r = s.execute("brush.replaceArt", &json!({"name": "Bar", "ids": [short.0], "applyToStrokes": false})).unwrap(); + assert_eq!(r["name"], "Bar copy"); + assert_eq!(brush_of(&s, path).as_deref(), Some("Bar")); + assert_eq!(s.execute("brush.get", &json!({"name": "Bar"})).unwrap(), bar); + assert!((brushed_bounds(&s, path).height() - want).abs() < 1e-6); + let copy: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Bar copy"})).unwrap()["art"].clone()).unwrap(); + assert!((copy.geometric_bounds().unwrap().height() - 4.0).abs() < 1e-6); + let list = s.execute("brush.list", &json!({})).unwrap(); + assert_eq!(list["current"], "Bar copy"); + let names: Vec<&str> = list["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect(); + let at = names.iter().position(|n| *n == "Bar").unwrap(); + assert_eq!(names.get(at + 1), Some(&"Bar copy")); + // No stroke uses Bar copy: its art changes in place, with or without applying. + s.execute("brush.replaceArt", &json!({"name": "Bar copy", "ids": [tall.0], "applyToStrokes": false})).unwrap(); + assert!(s.execute("brush.get", &json!({"name": "Bar copy 2"})).is_err()); +} + +/// `brush.replaceArt` refuses a brush without art, an unknown brush, an empty selection, art with no +/// width and an `applyToStrokes` that is not a boolean, and changes nothing then. +#[test] +fn replace_art_refuses_what_it_cannot_take() { + let mut s = session(); + let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0); + let flat = s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap(); + let lib = s.execute("brush.list", &json!({})).unwrap(); + let steps = undo_steps(&s); + for (p, msg) in [ + (json!({"name": "3 pt. Round", "ids": [r.0]}), "`3 pt. Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Bristle Round", "ids": [r.0]}), "`Bristle Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Nope", "ids": [r.0]}), "no brush named `Nope`"), + (json!({"name": "Arrow", "ids": [flat]}), "the selected art has no width"), + (json!({"name": "Chain", "ids": [flat]}), "the selected art has no width"), + (json!({"name": "Arrow", "ids": [r.0], "applyToStrokes": "no"}), "`applyToStrokes` must be true or false"), + ] { + let e = s.execute("brush.replaceArt", &p).unwrap_err(); + assert!(e.to_string().contains(msg), "{p}: {e}"); + } + s.select(|_, sel| sel.clear()).unwrap(); + let e = s.execute("brush.replaceArt", &json!({"name": "Arrow"})).unwrap_err(); + assert!(e.to_string().contains("nothing selected"), "{e}"); + assert_eq!(undo_steps(&s), steps); + assert_eq!(s.execute("brush.list", &json!({})).unwrap(), lib); +} + +/// An instance of a symbol whose art has no width is art with no width for `brush.new` and +/// `brush.replaceArt`, although an instance's box without its document has width. +#[test] +fn a_symbol_whose_art_has_no_width_makes_no_brush_art() { + let mut s = session(); + let flat = NodeId(s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap()); + let art = node(&s, flat); + s.execute("edit.clear", &json!({"ids": [flat.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Flat".into(), art: std::sync::Arc::new(art) }); + let layer = d.layers[0].id; + let inst = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Flat".into(), xf: vectorcraft_geom::Affine::IDENTITY })) + .unwrap(); + assert!(node(&s, inst).geometric_bounds().is_some_and(|b| b.width() > 1.0), "the box an instance has without its document"); + for (cmd, p) in [("brush.new", json!({"type": "art", "ids": [inst.0]})), ("brush.replaceArt", json!({"name": "Arrow", "ids": [inst.0]}))] { + let e = s.execute(cmd, &p).unwrap_err(); + assert!(e.to_string().contains("the selected art has no width"), "{cmd}: {e}"); + } +} + +/// `brush.replaceArt` checks only the art it is given: a tile the pattern brush already has that +/// has no width does not stop another tile from being replaced. +#[test] +fn replace_art_checks_only_the_tiles_it_is_given() { + let mut s = session(); + let flat = NodeId(s.execute("shape.line", &json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap()); + let end = art_json(&mut s, flat); + s.execute("brush.options", &json!({"name": "Chain", "params": {"end": end}})).unwrap(); + let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0); + s.execute("brush.replaceArt", &json!({"name": "Chain", "ids": [r.0]})).unwrap(); + let side: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Chain"})).unwrap()["side"].clone()).unwrap(); + assert!(close(side.geometric_bounds().unwrap(), bounds(&s, r), 1e-6)); +} + +/// Tile `key` (`side`, `start`…) of pattern brush `name`. +fn tile_art(s: &mut Session, name: &str, key: &str) -> Node { + serde_json::from_value(s.execute("brush.get", &json!({"name": name})).unwrap()[key].clone()).unwrap() +} + +/// A pattern brush tile placed with `brush.placeArt` and given back with `brush.replaceArt` is the +/// art it was, not that art inside the tile's group, however many times it goes out and back. +#[test] +fn pattern_tiles_go_out_and_back_without_gaining_a_group() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let size = |n: &Node| { + let b = n.geometric_bounds().unwrap(); + (b.width(), b.height()) + }; + let first = tile_art(&mut s, &name, "side"); + assert!(matches!(first.kind, NodeKind::Path { .. }), "{:?}", first.kind); + for round in 1..=2 { + let id = s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap(); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [id]})).unwrap(); + let side = tile_art(&mut s, &name, "side"); + assert!(matches!(side.kind, NodeKind::Path { .. }), "round {round}: {:?}", side.kind); + let (a, b) = (size(&first), size(&side)); + assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "round {round}: {a:?} → {b:?}"); + } +} + +/// A tile group whose art was deleted, leaving only the rectangle that marks its frame, is art with +/// no width: the brush keeps its tile. +#[test] +fn a_tile_group_holding_only_its_frame_is_refused() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let before = s.execute("brush.get", &json!({"name": name})).unwrap(); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name})).unwrap()["id"].as_u64().unwrap()); + let side = node(&s, id).children().unwrap()[0].clone(); + assert_eq!(side.name.as_deref(), Some("Side Tile")); + let art = side.children().unwrap()[1].id; + s.execute("edit.clear", &json!({"ids": [art.0]})).unwrap(); + let steps = undo_steps(&s); + let e = s.execute("brush.replaceArt", &json!({"name": name, "ids": [side.id.0]})).unwrap_err(); + assert!(e.to_string().contains("the selected art has no width"), "{e}"); + assert_eq!(undo_steps(&s), steps); + assert_eq!(s.execute("brush.get", &json!({"name": name})).unwrap(), before); +} + +/// Two groups named for the same tile: the frontmost one is the tile's art. +#[test] +fn the_frontmost_group_named_for_a_tile_is_that_tile() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let back = rect(&mut s, 10.0, 10.0, 20.0, 10.0); + let front = rect(&mut s, 10.0, 40.0, 30.0, 10.0); + let (back, front) = (node(&s, back), node(&s, front)); + s.execute("edit.clear", &json!({"ids": [back.id.0, front.id.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + let tile = |d: &mut Document, n: Node| { + let mut g = Node::group(d.alloc_id(), vec![std::sync::Arc::new(n)]); + g.name = Some("Start Tile".into()); + std::sync::Arc::new(g) + }; + let tiles = vec![tile(d, back), tile(d, front)]; + let both = Node::group(d.alloc_id(), tiles); + let both_id = both.id; + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, both).unwrap(); + s.execute("brush.replaceArt", &json!({"name": name, "ids": [both_id.0]})).unwrap(); + let start = tile_art(&mut s, &name, "start"); + assert!((start.geometric_bounds().unwrap().width() - 30.0).abs() < 1e-6, "{:?}", start.geometric_bounds()); +} + +/// Type is art for `brush.replaceArt` as it is for `brush.new`: taken as it is. +#[test] +fn replace_art_takes_type_as_brush_new_does() { + let mut s = session(); + let t = s.execute("text.create", &json!({"x": 20, "y": 60, "text": "Hi", "size": 24})).unwrap()["id"].as_u64().unwrap(); + let want = art_json(&mut s, NodeId(t)); + s.execute("brush.replaceArt", &json!({"name": "Arrow", "ids": [t]})).unwrap(); + assert_eq!(s.execute("brush.get", &json!({"name": "Arrow"})).unwrap()["art"], want); + let made = s.execute("brush.new", &json!({"type": "art", "ids": [t]})).unwrap()["name"].as_str().unwrap().to_string(); + assert_eq!(s.execute("brush.get", &json!({"name": made})).unwrap()["art"], want); +} + #[test] fn brush_options_rename_duplicate_and_delete_update_strokes() { let mut s = session(); diff --git a/crates/engine/src/tests/caret_kerning.rs b/crates/engine/src/tests/caret_kerning.rs new file mode 100644 index 0000000000..dca3519cb1 --- /dev/null +++ b/crates/engine/src/tests/caret_kerning.rs @@ -0,0 +1,183 @@ +//! Kerning at the Type tool's caret at the start of a line (#5111): with no character on the line +//! before the caret, `type.step`, `type.resetTracking` and the Kerning field set the space before +//! the character after it (`kerningBefore`), as one undo step, and the character moves. + +use serde_json::json; +use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, t: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") }; + (**t).clone() +} + +fn style_at(s: &Session, id: NodeId, byte: usize) -> CharStyle { + vectorcraft_text::edit::style_at(&obj(s, id).runs, byte + 1) +} + +/// Where glyph `byte` is drawn along its line, in text space. +fn drawn_x(s: &Session, id: NodeId, byte: usize) -> f64 { + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id)); + l.glyphs.iter().find(|g| g.byte == byte).unwrap().xf.as_coeffs()[4] +} + +/// The Type tool editing `id` (made by [`text`]) with its caret at byte `caret`. +fn caret_in(s: &mut Session, id: NodeId, caret: usize) -> ViewInfo { + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 102.0, 95.0), v).unwrap(); + } + assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text"); + s.set_tool_option("select", &json!({"start": caret, "end": caret})); + v +} + +fn step_at(s: &mut Session, id: NodeId, caret: usize, by: i32) -> serde_json::Value { + s.execute("type.step", &json!({"id": id.0, "start": caret, "end": caret, "attribute": "kerning", "by": by})).unwrap() +} + +#[test] +fn a_step_at_the_start_of_the_text_moves_the_first_character() { + let mut s = session(); + let id = text(&mut s, "AV"); + let inc = s.prefs.tracking_increment; + let (a, v) = (drawn_x(&s, id, 0), drawn_x(&s, id, 1)); + assert_eq!(step_at(&mut s, id, 0, 1)["ids"], json!([id.0])); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning), (inc, None), "the space before A, not after it"); + // A step of `inc` thousandths of the 20 pt em moves both letters along the line. + let moved = inc / 1000.0 * 20.0; + assert!((drawn_x(&s, id, 0) - a - moved).abs() < 1e-6 && (drawn_x(&s, id, 1) - v - moved).abs() < 1e-6); + // A negative step goes back towards the line's start, past it. + step_at(&mut s, id, 0, -3); + assert_eq!(style_at(&s, id, 0).kerning_before, -2.0 * inc); + assert!((drawn_x(&s, id, 0) - a + 2.0 * moved).abs() < 1e-6); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, inc); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 0.0); + assert!((drawn_x(&s, id, 0) - a).abs() < 1e-6); +} + +#[test] +fn inside_a_line_a_step_still_kerns_the_character_before_the_caret() { + let mut s = session(); + let id = text(&mut s, "AV"); + step_at(&mut s, id, 1, 1); + let (a, v) = (style_at(&s, id, 0), style_at(&s, id, 1)); + assert_eq!((a.kerning_before, v.kerning_before, v.kerning), (0.0, 0.0, None)); + assert!(a.kerning.is_some(), "the pair after A"); +} + +#[test] +fn the_start_of_a_paragraph_or_after_a_forced_line_break_kerns_the_character_after_it() { + let mut s = session(); + let inc = s.prefs.tracking_increment; + let id = text(&mut s, "One\nTwo"); + let t = drawn_x(&s, id, 4); + step_at(&mut s, id, 4, 1); + assert_eq!((style_at(&s, id, 3).kerning, style_at(&s, id, 4).kerning_before), (None, inc), "not the line break's kerning"); + assert!(drawn_x(&s, id, 4) > t, "T moves"); + let forced = text(&mut s, "One\u{2028}Two"); + let after = "One\u{2028}".len(); + step_at(&mut s, forced, after, 1); + assert_eq!((style_at(&s, forced, 3).kerning, style_at(&s, forced, after).kerning_before), (None, inc)); +} + +#[test] +fn an_empty_line_has_nothing_to_kern() { + let mut s = session(); + let id = text(&mut s, "One\n\nTwo"); + let before = obj(&s, id).runs; + assert_eq!(step_at(&mut s, id, 4, 1)["ids"], json!([])); + assert_eq!(obj(&s, id).runs, before); + // Nor does the end of text after a line break. + let end = text(&mut s, "One\n"); + assert_eq!(step_at(&mut s, end, 4, 1)["ids"], json!([])); +} + +#[test] +fn reset_at_the_start_of_a_line_clears_the_space_before_and_a_full_reset_clears_it_too() { + let mut s = session(); + let id = text(&mut s, "AV"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": -40, "tracking": 100})).unwrap(); + step_at(&mut s, id, 0, 2); + assert_ne!(style_at(&s, id, 0).kerning_before, 0.0); + // At the caret before A only the space before A goes; A keeps its kerning after it. + caret_in(&mut s, id, 0); + s.execute("type.resetTracking", &json!({})).unwrap(); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, Some(-40.0), 100.0)); + step_at(&mut s, id, 0, 2); + s.execute("type.resetTracking", &json!({"ids": [id.0]})).unwrap(); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, None, 0.0), "all manual kerning"); +} + +#[test] +fn set_range_style_sets_the_space_before() { + let mut s = session(); + let id = text(&mut s, "AV"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 50})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 50.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 20000})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 10000.0, "kerning's limits"); + assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": "auto"})).is_err()); +} + +/// The Type tool: Alt+← at the caret before the first character kerns it, and Alt+→ back. +#[test] +fn alt_arrows_at_the_caret_before_the_first_character_kern_it() { + let mut s = session(); + let inc = s.prefs.tracking_increment; + let id = text(&mut s, "Hello"); + let v = caret_in(&mut s, id, 0); + let alt = Mods { alt: true, ..Mods::default() }; + s.tool_key(ToolKey::Left, alt, v).unwrap(); + assert_eq!((style_at(&s, id, 0).kerning_before, style_at(&s, id, 0).kerning), (-inc, None)); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, inc); +} + +/// The Eyedropper copies how type looks, not where a line starts: a sample from a first character +/// with a space before it leaves the target's first character where it was. +#[test] +fn the_eyedropper_keeps_the_targets_space_before() { + let mut s = session(); + let src = text(&mut s, "Source"); + s.execute("text.setRangeStyle", &json!({"id": src.0, "start": 0, "end": 1, "kerningBefore": 300, "tracking": 50})).unwrap(); + let dst = text(&mut s, "Target"); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let st = style_at(&s, dst, 0); + assert_eq!((st.tracking, st.kerning_before), (50.0, 0.0), "the tracking goes, the space before doesn't"); +} + +/// A character style captured from a line's first character leaves its space before out, and a +/// style can't be given one: it would space the first character of every paragraph set in it. +#[test] +fn a_character_style_leaves_the_space_before_out() { + let mut s = session(); + let id = text(&mut s, "Source"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": 300, "tracking": 50})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "id": id.0})).unwrap(); + let attrs = s.doc().unwrap().doc.char_styles[0].attrs.clone(); + assert_eq!(attrs.get("tracking"), Some(&json!(50.0))); + assert!(!attrs.contains_key("kerningBefore"), "{attrs:?}"); + let err = s.execute("charStyle.new", &json!({"name": "Spaced", "attrs": {"kerningBefore": 100}})).unwrap_err(); + assert!(err.to_string().contains("kerningBefore"), "{err}"); +} diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index 278d979ef5..c5ef8a1949 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -432,6 +432,30 @@ fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() { assert_eq!(overrides(&s, inst), 0); } +#[test] +fn redefining_the_symbol_drops_paints_its_new_objects_do_not_draw() { + let mut s = session(); + let inst = pair(&mut s, "dynamic"); + let p = json!({"id": inst.0, "path": [1], "fill": "#ff0000", "stroke": "#ff0000", "opacity": 50}); + s.execute("symbol.instance.setAppearance", &p).unwrap(); + // The second object is now an image, which draws no fill or stroke of its own. + let a = square(&mut s, 10.0); + place_image(&mut s); + let image = top(&s); + s.execute("symbol.update", &json!({"name": "Pair", "ids": [a.0, image.0]})).unwrap(); + let o = node(&s, inst).extras().symbol_overrides.clone(); + assert_eq!(o.len(), 1, "the opacity change stays: {o:?}"); + assert_eq!((&o[0].fill, &o[0].stroke, o[0].opacity), (&None, &None, Some(0.5))); + // A change of paint alone goes with its paint: the first object is now a blend. + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [0], "stroke": "#ff0000"})).unwrap(); + let (b, c) = (square(&mut s, 10.0), square(&mut s, 60.0)); + let blend = id(&s.execute("object.blend.make", &json!({"ids": [b.0, c.0], "steps": 1})).unwrap()); + let d = square(&mut s, 120.0); + s.execute("symbol.update", &json!({"name": "Pair", "ids": [blend.0, d.0]})).unwrap(); + let o = node(&s, inst).extras().symbol_overrides.clone(); + assert_eq!(o.iter().map(|o| o.path.clone()).collect::<Vec<_>>(), [vec![1]], "{o:?}"); +} + #[test] fn replace_symbol_drops_the_changes_made_for_the_old_symbol() { let mut s = session(); diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index ae1a72fa08..20cad1dc01 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -234,6 +234,26 @@ fn svg_export_writes_the_drawn_size() { assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}"); } +#[test] +fn release_type_on_a_path_keeps_the_drawn_size() { + let mut s = session(); + let id = on_line(&mut s, "Release"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let size = 12.0 * fit(&s, id); + assert!(size > 24.0, "the text grows on the long line: {size}"); + let r = s.execute("addon.type.releasePath", &json!({"ids": [id.0], "mode": "text"})).unwrap(); + let released = NodeId(r["texts"][0].as_u64().unwrap()); + let t = text(&s, released); + assert!(!t.dynamic_text, "point type does not keep Dynamic Text"); + assert!((t.runs[0].style.size - size).abs() < 1e-9, "{} vs the drawn {size}", t.runs[0].style.size); + assert_eq!(fit(&s, released), 1.0); + // Type on a path without Dynamic Text keeps its stored size. + let plain = on_line(&mut s, "Plain"); + let r = s.execute("addon.type.releasePath", &json!({"ids": [plain.0], "mode": "text"})).unwrap(); + let t = text(&s, NodeId(r["texts"][0].as_u64().unwrap())); + assert!(!t.dynamic_text && t.runs[0].style.size == 12.0); +} + /// 12 pt point type at (100, 100), selected, with its layout bounds. fn point(s: &mut Session, content: &str) -> (NodeId, vectorcraft_geom::Rect) { let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": content, "size": 12})).unwrap(); diff --git a/crates/engine/src/tests/effectedit.rs b/crates/engine/src/tests/effectedit.rs index 2d10c3f5d9..34e01bf31e 100644 --- a/crates/engine/src/tests/effectedit.rs +++ b/crates/engine/src/tests/effectedit.rs @@ -147,3 +147,37 @@ fn show_all_hidden_attributes() { s.execute("edit.undo", &json!({})).unwrap(); assert!(node(&s, id).appearance.has_hidden()); } + +/// A Warp applied now takes its style's Bézier net (`version` 1) unless a map is given, an edit +/// keeps the map the effect has (one saved before the version existed keeps the earlier map), and +/// a map this build doesn't draw is refused. +#[test] +fn warps_keep_the_map_their_version_names() { + let (mut s, id) = session_with_rect(); + let version = |s: &Session| node(s, id).appearance.effects[0].params.get("version").cloned(); + run(&mut s, "effect.apply", json!({"effect": "warp.flag", "params": {"bend": 40}})); + assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET))); + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 20}, "warpStyle": "arc"})); + assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET))); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(Value::Object(p)) = doc.node_mut(id).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) { + p.remove("version"); + } + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 10, "horizontal": 20}})); + assert_eq!(version(&s), None); + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"version": 1}})); + assert_eq!(version(&s), Some(json!(1))); + run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"version": 0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0)); + // A whole number given as a float is that version. + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 1.0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(1)); + run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 0.0}})); + assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0)); + for bad in [json!(2), json!(-1), json!(0.5), json!("1")] { + assert!(s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"version": bad}})).is_err(), "{bad}"); + assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"version": bad}})).is_err(), "{bad}"); + } + assert_eq!(node(&s, id).appearance.effects.len(), 2); +} diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index e4d030d387..4ffd7a361a 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -123,6 +123,51 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() { assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err()); } +/// A new warp envelope bends with its style's Bézier net (`version` [`live::WARP_NET`]) and keeps +/// it through Reset with Warp. One saved before the version existed keeps the earlier map when its +/// warp or its options change. +#[test] +fn new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map() { + // The surface point a quarter of the way along the top edge. + let quarter = |s: &Session, e| live::EnvelopeMap::of(&node(s, e)).unwrap().surface_mesh(4, 4, vectorcraft_color::Color::BLACK).points[1].p; + let at = |x, y| vectorcraft_geom::Point::new(x, y); + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40})); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["version"], json!(live::WARP_NET)); + // Flag moves the top edge 6 × bend × height × u(1 - u)(1 - 2u) down at u: 22.5 at u = 1/4. + assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e)); + s.execute("object.envelope.resetWithWarp", &json!({"bend": -40})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert!(quarter(&s, e).distance(at(150.0, 77.5)) < 1e-9, "{:?}", quarter(&s, e)); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Envelope { kind, .. }) = doc.node_mut(e).map(|n| &mut n.kind) + && let EnvelopeKind::Warp { version, .. } = &mut **kind + { + *version = 0; + } + s.execute("object.envelope.options", &json!({"fidelity": 75})).unwrap(); + s.execute("object.envelope.resetWithWarp", &json!({"bend": 40})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e)); + // The earlier map's flag: half the bend times sin(π x), x = -1/2 here, in half heights. + assert!(quarter(&s, e).distance(at(150.0, 90.0)) < 1e-9, "{:?}", quarter(&s, e)); + // `version` moves a warp to a map, and a new warp can take the earlier one. + s.execute("object.envelope.options", &json!({"version": 1.0})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e)); + assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e)); + s.execute("object.envelope.resetWithWarp", &json!({"version": 0})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e)); + let (_, old) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40, "version": 0})); + assert!(matches!(kind(&s, old), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, old)); + for bad in [json!(2), json!(-1), json!(0.5), json!("1"), json!(null)] { + for cmd in ["object.envelope.makeWithWarp", "object.envelope.resetWithWarp", "object.envelope.options"] { + let r = s.execute(cmd, &json!({"version": bad})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("`version` must be 0 or 1")), "{cmd} {bad}: {r:?}"); + } + } +} + #[test] fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { let mut s = session(); @@ -141,9 +186,9 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { let content = node(&s, a); assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") }; - // The mesh follows the arc: its outline reaches below the content's bottom edge at the sides. + // The mesh follows the arc: its top corners swing out past the content's sides and up. let b = m.outline().bounds().unwrap(); - assert!(b.y1 > 201.0, "{b:?}"); + assert!(b.x0 < 99.0 && b.x1 > 301.0 && b.y0 < 99.0, "{b:?}"); assert_eq!(s.doc().unwrap().selection.objects, ids); } diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 8a1e46c68a..435d918552 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -154,6 +154,35 @@ fn images_bend_as_a_raster_mesh_warp() { assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels"); } +#[test] +fn a_colorized_image_keeps_its_color_in_an_envelope() { + let mut s = session(); + let img = image::GrayImage::from_pixel(4, 4, image::Luma([0])); + let mut png = vec![]; + image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let im = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); + let im = + vectorcraft_doc::ImageObject { key: "gray".into(), width: 4, height: 4, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [im.0]})).unwrap(); + sel(&mut s, &[im]); + arch(&mut s, 80.0, json!({})); + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + // The black pixels draw in the fill color, in the bent shape. + let mid = img.pixel(120, 97); + assert!(mid[0] > 200 && mid[1] < 60 && mid[2] < 60, "the middle rose, red: {mid:?}"); + let inside = img.pixel(120, 120); + assert!(inside[0] > 200 && inside[1] < 60 && inside[2] < 60, "red inside: {inside:?}"); +} + /// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one. fn two_color_image(s: &mut Session) -> NodeId { let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); @@ -286,17 +315,18 @@ fn symbols_bend_with_the_envelope() { let e = arch(&mut s, 60.0, json!({})); let pts = anchors(&evaluated(&s, e)); assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle"); - // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). + // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). The + // arch lifts the middle of the 100..200 box by about 51, so (200, 120) is inside it. let mut r = vectorcraft_render::Renderer::new(); let region = Rect::new(0.0, 0.0, 400.0, 300.0); - assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60); + assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] < 60); s.edit("edit symbol", |d, _| { let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default()); d.symbols[0].art = Arc::new(art); Ok(()) }) .unwrap(); - assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200); + assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] > 200); } fn strokes(nodes: &[Node]) -> usize { diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index c37cbfe90d..4d86a84b3c 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -161,7 +161,8 @@ fn an_exported_pdf_opens_with_its_file_info() { assert_eq!(back["description"], "A poster\nfor the fair"); assert_eq!(back["keywords"], json!(["fair", "Poster", "print"])); assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]); - assert_eq!(back["title"], "poster.pdf", "the title stays the file name"); + assert_eq!(back["title"], "Poster", "File Info shows the title the PDF carries"); + assert_eq!(s.doc().unwrap().title(), "poster.pdf", "the document stays named after the file"); assert_eq!(back["copyrightStatus"], "copyrighted"); assert_eq!(back["copyrightNotice"], "(c) 2026 Ada"); assert_eq!(back["copyrightUrl"], "https://example.com/rights"); @@ -277,6 +278,192 @@ fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() { assert_eq!(m.copyright_url, ""); } +/// A one-page PDF whose trailer names an information dictionary with the entries `info`, and whose +/// catalog has a metadata stream holding an XMP `dc:title` of `xmp_title`. +fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec<u8> { + use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog}; + let first = first_extra(1); + let mut objects = vec![]; + let mut catalog = String::new(); + if let Some(t) = xmp_title { + let xmp = format!( + "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\ + <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\ + <dc:title><rdf:Alt><rdf:li xml:lang=\"de\">Zugangswege</rdf:li><rdf:li xml:lang=\"x-default\">{t}</rdf:li></rdf:Alt></dc:title>\ + </rdf:Description></rdf:RDF></x:xmpmeta>" + ); + objects.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len())); + catalog = format!("/Metadata {first} 0 R "); + } + let info_at = first + objects.len(); + if let Some(i) = info { + objects.push(format!("<< {i} >>")); + } + let refs: Vec<&str> = objects.iter().map(String::as_str).collect(); + let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &refs, &catalog, None); + if info.is_none() { + return bytes; + } + // The trailer follows the cross-reference table, so a longer trailer moves no object. + String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes() +} + +/// A document called `name` with content `bytes` opened (as `document.open` with no path: the tab +/// shows the document's name), `s` holding it. +fn open_named(s: &mut Session, name: &str, bytes: &[u8]) -> Value { + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap() +} + +/// The document's name (its tab) and its File Info title. +fn titles(s: &mut Session) -> [String; 2] { + let info = s.execute("file.info", &json!({})).unwrap(); + [s.doc().unwrap().title(), info["title"].as_str().unwrap().to_string()] +} + +/// What opening `bytes` as a file called `name` gives: the document's name and its File Info title. +fn opened(name: &str, bytes: &[u8]) -> [String; 2] { + let mut s = Session::new(); + let r = open_named(&mut s, name, bytes); + let t = titles(&mut s); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some(t[0].as_str()), Some(t[1].as_str())), "the open reply"); + assert_eq!(s.doc().unwrap().doc.title, t[0], "the tab is the document's title"); + t +} + +fn svg_titled(inner: &str) -> String { + format!( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\">{inner}<rect width=\"5\" height=\"5\"><title>Tooltip" + ) +} + +#[test] +fn import_puts_the_files_own_title_in_file_info_only() { + // SVG: the root's ``, its white space made single spaces (not a shape's tooltip), is the + // File Info title; the document keeps the file's name, with its extension. + assert_eq!(opened("box.svg", svg_titled("<title>\n Access\n paths ").as_bytes()), ["box.svg", "Access paths"]); + assert_eq!( + opened("/work/box.svg", svg_titled("Zugangswege Rechenzentrum – Prüfdiagramm").as_bytes()), + ["box.svg", "Zugangswege Rechenzentrum – Prüfdiagramm"] + ); + // PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone. + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, Some("XMP title"))), ["plan.pdf", "XMP title"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.pdf", "Info title"]); + assert_eq!(opened("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.ai", "Info title"]); + // Compressed SVG too. + let svgz = |inner: &str| vectorcraft_svg::compress(&svg_titled(inner)); + assert_eq!(opened("box.svgz", &svgz("Access paths")), ["box.svgz", "Access paths"]); + // A file that gives none (or a blank one) gives File Info the document's name. + assert_eq!(opened("box.svg", svg_titled("").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("box.svg", svg_titled(" ").as_bytes()), ["box.svg", "box.svg"]); + assert_eq!(opened("plan.pdf", &pdf_titled(None, None)), ["plan.pdf", "plan.pdf"]); + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), ["plan.pdf", "plan.pdf"]); + // The Untitled-N name New gives is no title (a document that never had one writes it). + assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), ["plan.pdf", "plan.pdf"]); + // A template opens as a new untitled document, whatever title its file gave. + let [name, info] = opened("plan.ait", &pdf_titled(Some("/Title (Info title)"), None)); + assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}"); +} + +#[test] +fn imported_svg_titles_are_filtered_and_bounded() { + for (raw, expected) in [ + ("Untitled-2".to_string(), None), + ("\u{7f}".to_string(), None), + ("Re\u{7f}lease Plan".to_string(), Some("Release Plan".to_string())), + ("λ".repeat(65_537), Some("λ".repeat(65_536))), + ] { + let svg = svg_titled(&format!("{raw}")); + for format in ["svg", "svgz"] { + let name = format!("box.{format}"); + let bytes = if format == "svgz" { vectorcraft_svg::compress(&svg) } else { svg.as_bytes().to_vec() }; + assert_eq!(opened(&name, &bytes), [name.clone(), expected.clone().unwrap_or(name)], "{format}"); + } + } +} + +#[test] +fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() { + let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("box.svg"); + std::fs::write(&path, svg_titled("Access paths")).unwrap(); + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap(); + assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths"))); + // The default names of what the document exports are made from the file's name, not its title. + assert_eq!(s.doc().unwrap().doc.title, "box.svg"); + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "png"), "box.png"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn file_info_edits_a_files_own_title_and_the_document_follows() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The dialog sends back the title it was given: nothing changes (no undo step). + let before = s.doc().unwrap().revision; + let shown = s.execute("file.info", &json!({})).unwrap(); + s.execute("file.info", &shown).unwrap(); + assert_eq!(s.doc().unwrap().revision, before, "the title it gave is no edit"); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); + // Another title is the document's own, as ever, and the file's is gone. + let r = s.execute("file.info", &json!({"title": "Plan B"})).unwrap(); + assert_eq!(r["title"], "Plan B"); + assert_eq!(titles(&mut s), ["Plan B", "Plan B"]); + assert_eq!(s.doc().unwrap().doc.metadata.title, None); + // Undo brings the file's title back, and the name. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(titles(&mut s), ["box.svg", "Access paths"]); +} + +#[test] +fn restored_file_info_title_keeps_authored_spaces() { + let mut s = session(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).metadata.title = Some("Release Plan".into()); + for format in ["vectorcraft", "svg", "pdf"] { + let bytes = b64(&s.execute("document.export", &json!({"format": format, "preserveEditing": true})).unwrap()); + let mut reopened = Session::new(); + let reply = open_named(&mut reopened, &format!("again.{format}"), &bytes); + assert_eq!(reply["restored"], format != "vectorcraft", "{format}"); + assert_eq!(reopened.doc().unwrap().doc.metadata.title.as_deref(), Some("Release Plan"), "{format}"); + assert_eq!(titles(&mut reopened)[1], "Release Plan", "{format}"); + } +} + +#[test] +fn the_files_own_title_is_kept_by_saves_and_written_by_exports() { + let mut s = Session::new(); + open_named(&mut s, "box.svg", svg_titled("Access paths").as_bytes()); + // The native file: its own title is the document's name, the File Info title survives. + let native = b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()); + let mut t = Session::new(); + open_named(&mut t, "other.vectorcraft", &native); + assert_eq!(titles(&mut t), ["box.svg", "Access paths"]); + // An SVG with the editing data it carries, reopened under another name. + let svg = s.execute("document.serialize", &json!({"format": "svg", "preserveEditing": true, "metadata": true})).unwrap()["text"] + .as_str() + .unwrap() + .to_string(); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + let mut u = Session::new(); + let r = open_named(&mut u, "again.svg", svg.as_bytes()); + assert_eq!(r["restored"], true); + assert_eq!(titles(&mut u), ["again.svg", "Access paths"]); + // PDF: the Info `Title` and the XMP `dc:title`, shown by the viewer. + for pdf in pdf_texts(&mut s) { + assert!(pdf.contains("/Title(Access paths)") && xmp_title(&pdf).contains("Access paths"), "{}", xmp_title(&pdf)); + assert!(pdf.contains("/ViewerPreferences<>"), "{pdf}"); + } + // PNG text. + let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap()); + assert!(crate::cmd::fileio::pngtext::text(&png).iter().any(|(k, v)| k == "Title" && v == "Access paths")); + // The default file names are made from the document's name. + assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "pdf"), "box.pdf"); +} + #[test] fn png_exports_carry_text_chunks() { let mut s = session(); diff --git a/crates/engine/src/tests/fontfiles.rs b/crates/engine/src/tests/fontfiles.rs index f31df35eb1..8bef8733b5 100644 --- a/crates/engine/src/tests/fontfiles.rs +++ b/crates/engine/src/tests/fontfiles.rs @@ -194,6 +194,43 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() { assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}"); } +/// A caller confined by read roots sees, in the replies to a search another caller started, only +/// the files found inside its read roots, and the folder searched and a failed search's path only +/// when they are inside them. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3"); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + for dir in [&home, &wide] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("Findme.otf"), &font).unwrap(); + } + let mut s = session_for(&root); + let roots = |read: &[&Path]| crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap(); + let (narrow, broad) = (roots(&[&home]), roots(&[&root])); + let ask = |s: &mut Session, r: &Arc, p: Value| { + crate::file_access::confine(Some(r), || s.execute("text.findFontFiles", &p).unwrap()) + }; + // A font no file has keeps the search going through every folder. + let fonts = json!([{"family": "Findme Sans 3"}, {"family": "No Such Sans"}]); + ask(&mut s, &broad, json!({"folder": root, "fonts": fonts})); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!((&all["fonts"][0]["files"], &all["folder"]), (&json!([home.join("Findme.otf"), wide.join("Findme.otf")]), &json!(root)), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + assert!(!r.to_string().contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + assert_eq!((&r["fonts"][0]["files"], &r["fonts"][0]["family"]), (&json!([home.join("Findme.otf")]), &json!("Findme Sans 3")), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": wide.join("gone"), "fonts": fonts})); + assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide"))); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}"); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn junk_search_params_never_fail_internally() { let root = folder("junk"); diff --git a/crates/engine/src/tests/gradpresets.rs b/crates/engine/src/tests/gradpresets.rs index 807094a1bd..4328361b8b 100644 --- a/crates/engine/src/tests/gradpresets.rs +++ b/crates/engine/src/tests/gradpresets.rs @@ -40,7 +40,16 @@ fn built_in_presets_are_the_gradient_libraries_by_theme_with_unique_names() { libraries.dedup(); assert_eq!( libraries, - ["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Organic Fades", "Duotone Gradients", "Radial Glows", "Metallic Gradients"] + [ + "Spectrum Gradients", + "Sky Gradients", + "Neutral Gradients", + "Organic Fades", + "Holographic", + "Duotone Gradients", + "Radial Glows", + "Metallic Gradients" + ] ); let mut seen = std::collections::HashSet::new(); assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset"); @@ -82,6 +91,42 @@ fn an_organic_fade_applies_with_its_method_midpoints_and_opacities() { assert_eq!(row["gradient"]["method"], "perceptual"); } +#[test] +fn a_holographic_foil_applies_in_place_as_one_undo_step() { + let (mut s, id) = session_with(json!({"gradient": {"angle": 30}})); + let before = paint(&s, id, false); + let undo = s.doc().unwrap().history.undo.len(); + let veil = s.gradient_preset("prism veil").unwrap(); + assert_eq!((veil.library, veil.gradient.method), (Some("Holographic"), GradientMethod::Perceptual)); + let r = s.execute("gradient.presets.apply", &json!({"name": "prism veil"})).unwrap(); + assert_eq!(r["name"].as_str(), Some("Prism Veil")); + let g = paint(&s, id, false); + assert_eq!((g.gradient.kind, g.gradient.method, &g.gradient.stops), (GradientKind::Linear, GradientMethod::Perceptual, &veil.gradient.stops)); + assert_eq!(g.gradient.stops.len(), 6); + assert_eq!(g.angle, before.angle, "the placement stays"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.gradient_preset_name(&g.gradient).as_deref(), Some("Prism Veil")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paint(&s, id, false), before); +} + +/// A preset saved as "Mint Halo" before Holographic existed stays listed under a free name. +#[test] +fn a_saved_preset_named_like_a_holographic_glow_gets_a_free_name() { + let (mut s, id) = session_with(json!({"gradient": {"kind": "radial"}})); + let halo = s.gradient_preset("Mint Halo").unwrap().gradient; + let mut own = paint(&s, id, false).gradient; + own.dither = true; + let mut prefs = s.prefs.clone(); + prefs.gradient_presets = vec![crate::cmd::GradientPreset { name: "MINT HALO".into(), gradient: own.clone() }]; + s.apply_prefs(prefs); + assert_eq!(s.prefs.gradient_presets.iter().map(|p| p.name.as_str()).collect::>(), ["MINT HALO 2"]); + s.execute("gradient.presets.apply", &json!({"name": "mint halo 2"})).unwrap(); + assert_eq!(paint(&s, id, false).gradient, own); + s.execute("gradient.presets.apply", &json!({"name": "Mint Halo"})).unwrap(); + assert_eq!(paint(&s, id, false).gradient.stops, halo.stops); +} + #[test] fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() { let (mut s, id) = session_with(json!({"color": "#336699"})); diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index 1e13c468c3..24a1635c30 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -94,10 +94,49 @@ fn run_fresh( params: &serde_json::Value, ) -> (std::result::Result, Option>) { let (mut s, ..) = doc_with_type(); - let r = s.execute(cmd, params).map_err(|e| e.to_string()); + let r = s.execute(cmd, params).map(|v| without_plugin_effects(cmd, v)).map_err(|e| e.to_string()); (r, s.doc().ok().map(|d| d.doc.clone())) } +/// `cmd`'s result with the plug-in effects (`plugin.`) taken out of `effect.list`'s catalog. +/// The plug-in tests install and remove plug-ins in the process-wide registry while these +/// comparisons run, so two runs of `effect.list` can list different plug-ins. +fn without_plugin_effects(cmd: &str, mut r: serde_json::Value) -> serde_json::Value { + if cmd == "effect.list" + && let Some(catalog) = r.get_mut("catalog").and_then(serde_json::Value::as_array_mut) + { + catalog.retain(|e| !e["id"].as_str().is_some_and(|id| id.starts_with(vectorcraft_plugins::PREFIX))); + } + r +} + +/// Whether two results of `cmd` are the same, apart from the plug-in effects in `effect.list`'s +/// catalog ([`without_plugin_effects`]). +fn same_without_plugin_effects(cmd: &str, x: &serde_json::Value, y: &serde_json::Value) -> bool { + if cmd == "effect.list" { without_plugin_effects(cmd, x.clone()) == without_plugin_effects(cmd, y.clone()) } else { x == y } +} + +/// Two `effect.list` results that differ only in a plug-in effect in the catalog compare the same; +/// a difference in a built-in effect, in `applied` or in `activeItem`, or in another command's +/// result, does not. +#[test] +fn plugin_effects_are_left_out_of_the_effect_list_comparison() { + let list = |catalog: &[&str], applied: serde_json::Value, active: serde_json::Value| { + let catalog: Vec<_> = catalog.iter().map(|id| json!({"id": id, "label": id})).collect(); + json!({"catalog": catalog, "applied": applied, "activeItem": active}) + }; + let base = list(&["stylize.dropShadow"], json!([]), json!(null)); + let with_plugin = list(&["stylize.dropShadow", "plugin.test.glow"], json!([]), json!(null)); + assert!(same_without_plugin_effects("effect.list", &base, &with_plugin)); + assert!(same_without_plugin_effects("effect.list", &with_plugin, &base)); + assert_eq!(without_plugin_effects("effect.list", with_plugin.clone()), base); + assert!(!same_without_plugin_effects("effect.list", &base, &list(&[], json!([]), json!(null))), "a built-in effect"); + assert!(!same_without_plugin_effects("effect.list", &base, &list(&["stylize.dropShadow"], json!([{"id": 3}]), json!(null))), "applied"); + assert!(!same_without_plugin_effects("effect.list", &base, &list(&["stylize.dropShadow"], json!([]), json!(0))), "activeItem"); + assert!(!same_without_plugin_effects("object.transform", &base, &with_plugin), "another command"); + assert_eq!(without_plugin_effects("object.transform", with_plugin.clone()), with_plugin); +} + /// Every command whose params description names both `id` and `ids` (`{ids?|id?}`, `{id?, ids?: /// … (instead of id)}`), with nothing selected: `id` does what `ids` of that one object does (the /// same result or the same error, and the same document), for objects of the document and for an @@ -265,7 +304,7 @@ fn ids_and_selection(cmd: &str, picks: &[usize]) -> std::result::Result x == y, + (Ok(x), Ok(y)) => same_without_plugin_effects(cmd, x, y), (Err(x), Err(y)) => x.to_string() == y.to_string(), _ => false, }; diff --git a/crates/engine/src/tests/linkfiles.rs b/crates/engine/src/tests/linkfiles.rs index 695ea41419..ef9127042a 100644 --- a/crates/engine/src/tests/linkfiles.rs +++ b/crates/engine/src/tests/linkfiles.rs @@ -271,7 +271,7 @@ fn stop_ends_only_the_search_it_names() { let (tx, rx) = mpsc::channel(); let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap(); let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }]; - s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search }); + s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), doc: None, links, sizes: Arc::default(), search }); let until = Instant::now() + WAIT; while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until { std::thread::sleep(Duration::from_millis(2)); @@ -284,6 +284,116 @@ fn stop_ends_only_the_search_it_names() { drop(tx); } +/// A caller confined by read roots sees, in the replies to a search another caller started and to +/// its own, only the paths inside its read roots: the files found outside them, a link's saved path +/// outside them and the folder searched are left out, and the rows, ids and counts stay. Without +/// roots, the replies are as before. +#[test] +fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() { + let root = folder("read-roots"); + let mut s = session_for(&root); + let photo = png(40, 30, RED); + write(&root.join("lrf-away/photo.png"), &photo); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::remove_file(root.join("lrf-away/photo.png")).unwrap(); + // Files of the name with other contents: the search reads every folder. + let other = png(40, 30, BLUE); + let (home, wide) = (root.join("home"), root.join("lrf-wide")); + write(&home.join("photo.png"), &other); + write(&wide.join("photo.png"), &other); + let roots = |read: &[&Path], write: &[&Path]| crate::file_access::AutomationRoots::new(read, write).unwrap().unwrap(); + let (narrow, write_only, broad) = (roots(&[&home], &[]), roots(&[], &[&root]), roots(&[&root], &[])); + let ask = |s: &mut Session, r: &Arc, p: Value| { + crate::file_access::confine(Some(r), || s.execute("links.findFiles", &p).unwrap()) + }; + let hidden = |r: &Value| { + let text = r.to_string(); + assert!(!text.contains("lrf-away") && !text.contains("lrf-wide") && r.get("folder").is_none(), "{r}"); + let row = &r["links"][0]; + assert_eq!((row["name"].as_str(), &row["ids"], row.get("path")), (Some("photo.png"), &json!([id]), None), "{r}"); + }; + // Started with the broad roots, over the whole folder. + let r = ask(&mut s, &broad, json!({"folder": root})); + assert_eq!(r["folder"], json!(root), "{r}"); + finished(&mut s); + let all = ask(&mut s, &broad, json!({})); + assert_eq!(all["links"][0]["files"].as_array().map(Vec::len), Some(2), "{all}"); + for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] { + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([{"path": home.join("photo.png"), "size": other.len(), "match": "name"}]), "{r}"); + assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}"); + } + let r = ask(&mut s, &write_only, json!({})); + hidden(&r); + assert_eq!(r["links"][0]["files"], json!([]), "{r}"); + // Without roots, every path shows. + let r = s.execute("links.findFiles", &json!({})).unwrap(); + assert_eq!((&r["folder"], &r["links"][0]["path"]), (&json!(root), &json!(root.join("lrf-away/photo.png"))), "{r}"); + assert_eq!(r["links"][0]["files"].as_array().map(Vec::len), Some(2), "{r}"); + // The narrow caller's own search shows the folder it gave. + let r = ask(&mut s, &narrow, json!({"folder": home})); + assert_eq!(r["folder"], json!(home), "{r}"); + finished(&mut s); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((&r["folder"], r["links"][0].get("path")), (&json!(home), None), "{r}"); + // A search that failed: the reason shows without the path to a caller outside its folder. + ask(&mut s, &broad, json!({"folder": root.join("lrf-wide/gone")})); + let r = finished(&mut s); + assert!(r["error"].as_str().is_some_and(|e| e.contains("lrf-wide")), "{r}"); + let r = ask(&mut s, &narrow, json!({})); + assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}"); + hidden(&r); + let _ = std::fs::remove_dir_all(&root); +} + +/// A link's saved path relative to the document is shown to a confined caller when it leads to a +/// file inside its read roots, and left out when `..` leads out of them. +#[test] +fn a_relative_link_path_is_judged_from_the_documents_folder() { + let root = folder("read-roots-relative"); + let mut s = session_for(&root); + let home = root.join("home"); + write(&root.join("lrf-away/photo.png"), &png(40, 30, RED)); + let id = placed(&mut s, &root.join("lrf-away/photo.png")); + std::fs::create_dir_all(&home).unwrap(); + s.execute("document.save", &json!({"path": home.join("poster.vectorcraft")})).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&home], &[]).unwrap().unwrap(); + for (saved, shown) in [("sub/photo.png", true), ("../lrf-away/photo.png", false)] { + s.edit("relative path", |d, _| { + if let NodeKind::Image(im) = &mut d.node_mut(NodeId(id)).unwrap().kind { + im.link.as_mut().unwrap().path = saved.into(); + } + Ok(()) + }) + .unwrap(); + let r = crate::file_access::confine(Some(&narrow), || s.execute("links.findFiles", &json!({"folder": home, "ids": [id]})).unwrap()); + assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}"); + finished(&mut s); + } + let _ = std::fs::remove_dir_all(&root); +} + +/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link` +/// null), as `links.info` leaves it out, and keeps the other fields. +#[test] +fn image_info_leaves_out_a_link_outside_the_read_roots() { + let root = folder("read-roots-info"); + let mut s = session_for(&root); + let pic = root.join("lrf-away/photo.png"); + write(&pic, &png(40, 30, RED)); + let id = placed(&mut s, &pic); + std::fs::create_dir_all(root.join("home")).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&root.join("home")], &[]).unwrap().unwrap(); + let ask = |s: &mut Session, cmd: &str| crate::file_access::confine(Some(&narrow), || s.execute(cmd, &json!({"id": id})).unwrap()); + let r = ask(&mut s, "image.info"); + assert_eq!((&r["link"], &r["linked"], &r["pixelWidth"], &r["id"]), (&Value::Null, &json!(true), &json!(40), &json!(id)), "{r}"); + assert!(!r.to_string().contains("lrf-away"), "{r}"); + let info = ask(&mut s, "links.info"); + assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}"); + assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic)); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn relinking_with_copy_puts_the_file_in_the_documents_folder() { let root = folder("copy"); diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 9fc790efa2..f2ab135817 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -424,6 +424,93 @@ fn blend_replace_spine_follows_path() { assert!(first.distance(Point::new(100.0, 300.0)) < 1.0, "{first:?}"); } +/// #6809: after Replace Spine, moving a key object moves the drawn key and its end of the spine, +/// not only the key's selection outline. +#[test] +fn moving_a_key_of_a_replaced_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let arc = id_of(&s.execute("path.create", &json!({"d": "M100 300 C100 245 145 200 200 200 C255 200 300 245 300 300"})).unwrap()); + sel(&mut s, &[g, arc]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + assert!(center(&before[0]).distance(Point::new(100.0, 300.0)) < 1e-6); + // Direct Selection picks the key inside the blend; the drag moves it. + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": 40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 340.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(300.0, 300.0)) < 1e-6, "the other key stays put: {last:?}"); + // Undo puts the key back on the spine as Replace Spine left it. + s.execute("edit.undo", &json!({})).unwrap(); + let back = steps_of(&s, g); + assert!(center(&back[0]).distance(Point::new(100.0, 300.0)) < 1e-6); + assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); +} + +/// #6809 on a closed spine, where both end keys start on its first point: moving the first key +/// moves its end of the spine without dragging the last key's end along. +#[test] +fn moving_the_first_key_of_a_closed_replaced_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap()); + sel(&mut s, &[g, square]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + assert!(center(&before[0]).distance(Point::new(100.0, 100.0)) < 1e-6); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let back = steps_of(&s, g); + assert!(center(&back[0]).distance(Point::new(100.0, 100.0)) < 1e-6); + assert!(center(&back[1]).distance(center(&before[1])) < 1e-6); +} + +/// #6809 on a closed spine pinned by a spine edit: the end keys get points of their own, so +/// moving the first key afterwards still moves only its end. +#[test] +fn moving_the_first_key_of_a_pinned_closed_spine_blend_moves_what_is_drawn() { + let mut s = session(); + let (a, _, g) = blend(&mut s, json!({"steps": 5})); + let square = id_of(&s.execute("path.create", &json!({"d": "M100 100 L300 100 L300 300 L100 300 Z"})).unwrap()); + sel(&mut s, &[g, square]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + // Dragging a corner away from the keys pins the spine to them. + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 2, "x": 320, "y": 320})).unwrap(); + let NodeKind::Blend { spec, .. } = node(&s, g).kind else { panic!() }; + assert_eq!(spec.key_anchors.len(), 2, "{:?}", spec.key_anchors); + let center = |n: &Node| n.geometric_bounds().unwrap().center(); + let before = steps_of(&s, g); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": -50, "dy": -40})).unwrap(); + let key = center(&node(&s, a)); + assert!(key.distance(Point::new(50.0, 60.0)) < 1e-6, "{key:?}"); + let after = steps_of(&s, g); + assert_eq!(after.len(), before.len()); + let drawn = center(&after[0]); + assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}"); + let last = center(&after[after.len() - 1]); + assert!(last.distance(Point::new(100.0, 100.0)) < 1e-6, "the other key stays put: {last:?}"); +} + #[test] fn blend_align_to_path_rotates_steps() { let mut s = session(); @@ -483,8 +570,8 @@ fn envelope_warp_make_and_content_stays_inside_bounds() { let pts = all_anchors(&out); assert!(pts.len() > 4, "curves are subdivided"); assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}"); - // It really is bent: the sides moved down. - assert!(pts.iter().any(|p| p.y > 201.0)); + // It really is bent: the top corners swung out past the sides. + assert!(pts.iter().any(|p| p.x < 99.0) && pts.iter().any(|p| p.x > 301.0)); } #[test] diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index b0345db4ff..6850715b0e 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -400,6 +400,90 @@ fn stroke_set_refuses_an_unknown_key() { } } +/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such, +/// naming its key, not as a missing id; an id left out is still a missing one. +#[test] +fn a_malformed_object_id_is_not_reported_as_missing() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = undo(&s); + let points = json!([[0, 0], [10, 10], [20, 0]]); + for bad in [json!("5"), json!(-1)] { + let id = || json!({ "id": bad }); + let with = |extra: serde_json::Value| { + let mut p = id(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + for (cmd, p, key) in [ + ("stroke.widthPoint.set", id(), "id"), + ("stroke.widthPoint.copy", id(), "id"), + ("stroke.widthPoint.remove", id(), "id"), + ("path.curvatureEdit", id(), "id"), + ("path.removeAnchor", id(), "id"), + ("path.convertAnchor", id(), "id"), + ("path.reshapeSegment", id(), "id"), + ("path.split", id(), "id"), + ("path.appendAnchor", id(), "id"), + ("path.close", id(), "id"), + ("path.setHandle", id(), "id"), + ("path.moveAnchor", id(), "id"), + ("path.setAnchors", id(), "id"), + ("path.reshape", id(), "id"), + ("path.insertAnchor", id(), "id"), + ("path.freehand", json!({"points": points, "extend": {"id": bad}}), "id"), + ("path.freehand", json!({"points": points, "redraw": {"id": bad}}), "id"), + ("document.node", id(), "id"), + ("layer.target", id(), "id"), + ("layer.setCurrent", id(), "id"), + ("layer.selectAll", id(), "id"), + ("layer.move", with(json!({"target": 1})), "id"), + ("layer.move", json!({"ids": [1], "target": bad}), "target"), + ("node.move", id(), "id"), + ("node.move", json!({"id": 1, "parent": bad}), "parent"), + ("text.reshapeArea", with(json!({"anchors": []})), "id"), + ("text.reshapeArea", json!({"items": [{"id": bad, "anchors": []}]}), "id"), + ("text.reshapePath", id(), "id"), + ("text.setPathHandle", id(), "id"), + ("text.editRange", id(), "id"), + ("text.startList", id(), "id"), + ("text.getRange", id(), "id"), + ("text.setGlyphAlternate", id(), "id"), + ("text.glyphAlternates", id(), "id"), + ("text.touchType", id(), "id"), + ("text.discardEmpty", id(), "id"), + ("text.createInPath", json!({"path": bad}), "path"), + ("object.envelope.setMeshPoint", id(), "id"), + ("object.mesh.setPointColor", with(json!({"index": 0})), "id"), + ("object.mesh.movePoint", with(json!({"index": 0, "x": 0, "y": 0})), "id"), + ("object.mesh.movePoints", json!({"items": [{"id": bad, "index": 0}]}), "id"), + ("object.mesh.addLine", with(json!({"x": 0, "y": 0})), "id"), + ("object.mesh.deletePoint", with(json!({"index": 0})), "id"), + ("object.slice.setRect", id(), "id"), + ("appearance.transfer", json!({"source": bad, "target": 1}), "source"), + ("appearance.transfer", json!({"source": 1, "target": bad}), "target"), + ("appearance.copyFrom", json!({"source": bad}), "source"), + ("livePaint.strokeEdge", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.fill", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.info", json!({"group": bad}), "group"), + ("livePaint.info", id(), "id"), + ("select.magicWand", id(), "id"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + // `layer.move` checks an `id` that stands in for `ids` before it runs. + let named = e.contains(&format!("`{key}` must be a whole number")) || (cmd == "layer.move" && e.contains("invalid object id")); + assert!(named, "{cmd} {p}: {e}"); + } + } + for cmd in ["path.close", "layer.target", "text.reshapePath", "object.mesh.movePoint", "select.magicWand"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("missing"), "{cmd}: {e}"); + } + assert_eq!(undo(&s), before); +} + /// A command that takes the keys of one that isn't checked yet isn't checked either. #[test] fn a_reference_to_an_unchecked_command_is_unchecked() { diff --git a/crates/engine/src/tests/pathtype.rs b/crates/engine/src/tests/pathtype.rs index 5ead0cbd22..8aa8c05684 100644 --- a/crates/engine/src/tests/pathtype.rs +++ b/crates/engine/src/tests/pathtype.rs @@ -184,6 +184,184 @@ fn a_flip_round_a_closed_path_keeps_the_type_in_place() { assert!(origins(&s, id)[0].distance(first) < 8.0); } +/// A circle of radius 100 round (200, 200), starting at 9 o'clock and running clockwise, with +/// type on it from its start. +fn on_circle(s: &mut Session, text: &str) -> NodeId { + path_type(s, "M100 200 A100 100 0 1 1 300 200 A100 100 0 1 1 100 200 Z", [90.0, 200.0], text) +} + +#[test] +fn center_on_top_and_bottom_of_a_circle() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + assert!(near(brackets(&s, id).0, 0.0), "{:?}", brackets(&s, id)); + let steps = undo_len(&s); + let r = s.execute("type.pathCenter.top", &json!({})).unwrap(); + // Once round, its middle at 12 o'clock, a quarter of the way round: the type sits on the top, centered. + let (start, end) = brackets(&s, id); + assert!(near(start, 0.75) && end.is_none(), "{start} {end:?}"); + assert_eq!((r["ids"].clone(), r["end"].clone()), (json!([id.0]), Value::Null)); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center); + let top = ink(&s, id); + assert!((top.center().x - 200.0).abs() < 0.5 && top.y1 < 101.0, "{top:?}"); + assert_eq!((undo_len(&s), last_step(&s)), (steps + 1, "Center on Top".to_string())); + // Again: nothing moves, and no undo step is recorded. + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(undo_len(&s), steps + 1); + // The bottom: 6 o'clock, outside the circle (the type is not flipped). + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.25) && end.is_none(), "{start} {end:?}"); + let bottom = ink(&s, id); + assert!((bottom.center().x - 200.0).abs() < 0.5 && bottom.y0 > 299.0, "{bottom:?}"); + assert_eq!((undo_len(&s), last_step(&s)), (steps + 2, "Center on Bottom".to_string())); + // Justify Center stays as it is. + s.execute("text.setStyle", &json!({"justify": "justifyCenter"})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::JustifyCenter); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(brackets(&s, id).0, 0.25)); + // Brackets already in place with another alignment: only the alignment changes, in one step. + s.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); + let (steps, before) = (undo_len(&s), brackets(&s, id)); + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + assert_eq!((undo_len(&s), brackets(&s, id)), (steps + 1, before)); + assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center); +} + +#[test] +fn center_on_top_twice_records_one_step_for_any_span() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + for (start, end) in [(0.0, 0.2), (0.13, 0.41), (0.37, 0.52), (0.61, 0.97), (0.89, 0.07), (0.3, 0.3001)] { + s.execute("type.pathOptions", &json!({"start": start, "end": end})).unwrap(); + for c in ["type.pathCenter.top", "type.pathCenter.bottom"] { + s.execute(c, &json!({})).unwrap(); + let (steps, placed) = (undo_len(&s), brackets(&s, id)); + // Again: the brackets stay exactly where they are, and no undo step is recorded. + s.execute(c, &json!({})).unwrap(); + assert_eq!((undo_len(&s), brackets(&s, id)), (steps, placed), "{c} {start} {end}"); + } + } +} + +#[test] +fn center_on_top_keeps_the_span_and_measures_the_path_as_drawn() { + let mut s = session(); + let id = on_circle(&mut s, "OOOO"); + // A span of a fifth of the path keeps its length. + s.execute("type.pathOptions", &json!({"end": 0.2})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.15) && end.is_some_and(|e| near(e, 0.35)), "{start} {end:?}"); + // Turned a quarter turn counter-clockwise, its 3 o'clock point, halfway round, is at the top. + s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap(); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, _) = brackets(&s, id); + assert!(near(start.rem_euclid(1.0), 0.0) || near(start, 1.0), "{start}"); + let bounds = text(&s, id).bounds().unwrap(); + let top = ink(&s, id); + assert!((top.center().x - bounds.center().x).abs() < 0.5 && top.y1 < bounds.y0 + 1.0, "{top:?} {bounds:?}"); + // A rectangle: the middle of its top edge. + let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO"); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + let (start, end) = brackets(&s, id); + assert!(near(start, 0.625) && end.is_none(), "{start} {end:?}"); + assert!((ink(&s, id).center().x - 200.0).abs() < 0.5); +} + +#[test] +fn center_on_top_moves_every_selected_type_on_a_path_in_one_step() { + let mut s = session(); + let a = on_circle(&mut s, "OOOO"); + let b = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO"); + let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0, p]})).unwrap(); + let steps = undo_len(&s); + let r = s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([a.0, b.0])); + assert!(near(brackets(&s, a).0, 0.75) && near(brackets(&s, b).0, 0.625)); + assert_eq!(undo_len(&s), steps + 1); + // Dynamic Text stays on, and the layout fits the text to the span where it now runs: a fifth of + // the circle, at its bottom. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("type.pathOptions", &json!({"start": 0, "end": 0.2})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + s.execute("type.pathCenter.bottom", &json!({})).unwrap(); + assert!(text(&s, a).dynamic_text && near(brackets(&s, a).0, 0.65), "{:?}", brackets(&s, a)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, a)); + assert!(lay.fit_scale > 1.5 && !lay.overflow, "{}", lay.fit_scale); + let bottom = ink(&s, a); + assert!((bottom.center().x - 200.0).abs() < 1.0 && bottom.y1 > 300.0, "{bottom:?}"); + // Vertical type on a path moves the same way. + s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap(); + s.execute("type.orientation.vertical", &json!({})).unwrap(); + s.execute("type.pathCenter.top", &json!({})).unwrap(); + assert!(near(brackets(&s, a).0, 0.75)); +} + +#[test] +fn center_on_top_refuses_open_compound_and_threaded_type() { + let mut s = session(); + let circle = on_circle(&mut s, "OOOO"); + let line = on_line(&mut s); + s.execute("select.set", &json!({"ids": [circle.0, line.0]})).unwrap(); + let steps = undo_len(&s); + let before = brackets(&s, circle); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("type on an open or compound path cannot be centered on its top or bottom") && e.ends_with(&line.0.to_string()), "{e}"); + // Nothing changes, the type on the circle included, and the same holds for an explicit list. + assert_eq!((undo_len(&s), brackets(&s, circle)), (steps, before)); + let e = s.execute("type.pathCenter.bottom", &json!({"ids": [circle.0, line.0]})).unwrap_err().to_string(); + assert!(e.contains("open or compound path") && e.ends_with(&line.0.to_string()), "{e}"); + s.execute("select.set", &json!({"ids": [circle.0]})).unwrap(); + for bad in [json!({"ids": "all"}), json!({"ids": [999_999]})] { + assert!(s.execute("type.pathCenter.top", &bad).is_err(), "{bad}"); + } + assert_eq!(undo_len(&s), steps); + // Two circles side by side in one path: a compound path. + let pair = path_type( + &mut s, + "M100 450 A40 40 0 1 1 180 450 A40 40 0 1 1 100 450 Z M300 450 A40 40 0 1 1 380 450 A40 40 0 1 1 300 450 Z", + [90.0, 450.0], + "OOOO", + ); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("open or compound path") && e.ends_with(&pair.0.to_string()), "{e}"); + // A closed path that is level all round has no height. + let flat = path_type(&mut s, "M100 400 L300 400 Z", [90.0, 400.0], "OOOO"); + let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string(); + assert!(e.contains("type on a path with no length or no height cannot be centered") && e.ends_with(&flat.0.to_string()), "{e}"); + // A frame of a thread is refused, alone or with other type on a closed path. + let threaded = on_circle(&mut s, "Threaded story"); + let frame = s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0]})).unwrap(); + let before = brackets(&s, threaded); + for ids in [json!([threaded.0]), json!([circle.0, threaded.0])] { + let steps = undo_len(&s); + let e = s.execute("type.pathCenter.top", &json!({ "ids": ids })).unwrap_err().to_string(); + assert!(e.contains("type on a path in a thread cannot be centered on its top or bottom") && e.ends_with(&threaded.0.to_string()), "{e}"); + assert_eq!((undo_len(&s), brackets(&s, threaded)), (steps, before)); + } + // Without type on one closed path selected, the commands are not available. + let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + for (ids, why) in [ + (json!([p]), "select type on a closed path"), + (json!([line.0]), "open or compound path"), + (json!([circle.0, line.0]), "open or compound path"), + (json!([threaded.0]), "in a thread"), + ] { + s.execute("select.set", &json!({ "ids": ids })).unwrap(); + for c in ["type.pathCenter.top", "type.pathCenter.bottom"] { + let e = s.execute(c, &json!({})).unwrap_err().to_string(); + assert!(e.contains(why), "{c} {ids}: {e}"); + } + } +} + #[test] fn align_to_path_and_spacing() { let mut s = session(); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 0ba51f20a3..8248f12a48 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1057,6 +1057,142 @@ fn a_copy_left_behind_is_not_offered_by_its_own_app() { let _ = std::fs::remove_dir_all(&dir); } +/// A copy left in an area the session had to leave is not another app's to offer, restore or +/// discard while the session runs, even after the app that locked that area let it go: an entry in +/// the session's new area says it keeps it. Once the copy is written again in the new area, the +/// entry goes; after a crash before that, the copy is offered. +#[cfg(unix)] +#[test] +fn a_copy_left_behind_is_not_offered_by_another_app_while_its_app_runs() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-leftother-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let left_behind = |area: &str| dir.join(area).join("Untitled-1-1.vectorcraft"); + let leave = |a: &mut Session, area: &str| { + std::fs::remove_file(dir.join(area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(a); + drop(other); + a.recovery.own_area().unwrap().to_string() + }; + let (mut a, area) = saved_in(&dir); + let mine = leave(&mut a, &area); + assert_ne!(mine, area); + assert!(dir.join(&mine).join(format!("keeps-{area}")).is_file()); + // Removed meanwhile: the next heartbeat writes it again. + std::fs::remove_file(dir.join(&mine).join(format!("keeps-{area}"))).unwrap(); + cmd::recovery::heartbeat(&mut a); + assert!(dir.join(&mine).join(format!("keeps-{area}")).is_file()); + let mut b = other_app(&dir); + assert!(offered(&mut b).is_empty()); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0); + assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty()); + assert!(b.execute("file.recovery.discard", &json!({"file": format!("{area}/Untitled-1-1")})).is_err()); + assert!(left_behind(&area).is_file()); + // Written again in the new area: the copy left behind and the entry go. + a.execute("file.recovery.save", &json!({})).unwrap(); + assert!(!left_behind(&area).exists()); + assert!(!dir.join(&mine).join(format!("keeps-{area}")).exists()); + assert!(offered(&mut b).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); + // A crash before the copy is written again: the copy left behind is offered. + let (mut a, area) = saved_in(&dir); + leave(&mut a, &area); + drop(a); + assert_eq!(offered(&mut other_app(&dir)).len(), 1); + assert!(left_behind(&area).is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A store in memory whose areas named `foreign-…` can't be locked by this account (as another +/// account's folder in a shared recovery folder). +#[derive(Default)] +struct ForeignAreas(MemoryStore); + +impl RecoveryStore for ForeignAreas { + fn list(&self) -> std::result::Result, String> { + self.0.list() + } + fn read(&self, name: &str) -> std::result::Result, String> { + self.0.read(name) + } + fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { + self.0.write(name, bytes) + } + fn remove(&self, name: &str) -> std::result::Result<(), String> { + self.0.remove(name) + } + fn location(&self) -> String { + self.0.location() + } + fn lock(&self, area: &str) -> std::result::Result { + if area.starts_with("foreign-") { + return Err(format!("{area}: belongs to another account")); + } + self.0.lock(area) + } + fn now(&self) -> Option { + self.0.now() + } +} + +/// An entry `/keeps-` in an area this account can't lock keeps nothing: a crashed +/// app's copies in the left area are still offered. +#[test] +fn a_keeps_entry_in_an_area_that_cannot_be_locked_keeps_nothing() { + let store = Arc::new(ForeignAreas::default()); + let app = || { + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + s + }; + let mut a = app(); + a.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + drop(a); + store.write(&format!("foreign-1/keeps-{area}"), b"").unwrap(); + let mut b = app(); + assert_eq!(offered(&mut b).len(), 1); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1); +} + +/// An entry `/keeps-` in a running app's area keeps a crashed app's copies in the +/// left area until that app is gone too. +#[test] +fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() { + let store = Arc::new(MemoryStore::default()); + let mut a = session_in(&store); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + drop(a); + let mut c = session_in(&store); + c.execute("file.recovery.save", &json!({})).unwrap(); + let keeper = c.recovery.own_area().unwrap().to_string(); + store.write(&format!("{keeper}/keeps-{area}"), b"").unwrap(); + let mut b = relaunch(&store); + assert!(offered(&mut b).is_empty()); + assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0); + drop(c); + assert_eq!(offered(&mut b).len(), 2); +} + +/// An entry `/keeps-` in a folder whose lock file can't be opened keeps nothing. +#[cfg(unix)] +#[test] +fn a_keeps_entry_in_a_folder_whose_lock_cannot_be_checked_keeps_nothing() { + let dir = std::env::temp_dir().join(format!("vc-recovery-keepsunlocked-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (a, area) = saved_in(&dir); + drop(a); + std::fs::create_dir_all(dir.join("keeper").join(".lock")).unwrap(); + std::fs::write(dir.join("keeper").join(format!("keeps-{area}")), b"").unwrap(); + assert_eq!(offered(&mut other_app(&dir)).len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + /// A complex document (Data Recovery off for those) gets no new copy after the session left its /// area: it keeps the copy it left there, and the heartbeat asks for it only once. #[cfg(unix)] diff --git a/crates/engine/src/tests/script_engines.rs b/crates/engine/src/tests/script_engines.rs index 5506cb98d1..3171657b32 100644 --- a/crates/engine/src/tests/script_engines.rs +++ b/crates/engine/src/tests/script_engines.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, engines_on_this_thread, releases_kept_for}; +use crate::script::{PANIC_IN_SLICE, TEST_ENGINE_HEAP, TEST_KEPT_MAP_BYTES, engines_on_this_thread, releases_kept_for}; use crate::*; fn run(s: &mut Session, source: &str) -> Result { @@ -119,11 +119,115 @@ fn only_the_script_itself_names_its_engine() { assert_eq!(report["line"], 2, "{report}"); assert_eq!(names(&mut s), ["e"]); let _ = std::fs::remove_dir_all(base); - // An error in a function an earlier run defined has no place in the run that calls it. + // An error in a function an earlier run defined is at that run's line; a script given as text + // is named by its run's name. run(&mut s, "#targetengine e\n\n\n\nfunction bad() { throw new Error('kept'); }").unwrap(); - assert!(run(&mut s, "#targetengine e\nbad();").is_err()); + let e = run(&mut s, "#targetengine e\nbad();").unwrap_err().to_string(); let report = s.script_report().unwrap().clone(); - assert!(report["line"].as_u64().is_none_or(|l| l == 0), "{report}"); + assert_eq!((report["name"].as_str(), report["file"].as_str(), report["line"].as_u64()), (Some("t.js"), Some("t.js"), Some(5)), "{report}"); + let column = report["column"].as_u64().unwrap(); + assert_eq!(error_lines(&report), [format!("t.js: t.js:5:{column}: Error: kept")]); + assert!(e.contains(&format!("line 5, column {column} of t.js")), "{e}"); +} + +/// The console error lines of a run's report. +fn error_lines(report: &Value) -> Vec { + let lines = report["console"]["lines"].as_array().unwrap(); + lines.iter().filter_map(Value::as_str).filter(|l| l.contains("Error: ")).map(str::to_string).collect() +} + +/// The file, line and column of the last run's error. +fn place(s: &Session) -> (Option, u64, u64) { + let r = s.script_report().unwrap(); + (r["file"].as_str().map(str::to_string), r["line"].as_u64().unwrap(), r["column"].as_u64().unwrap()) +} + +/// An error in code an earlier run of a named engine compiled is at the place of that code: in that +/// run's script, a file it included or a file `$.evalFile` gave, while the report and the console +/// line keep the calling run's name. +#[test] +fn an_error_in_code_an_earlier_run_kept_is_at_that_runs_file_and_line() { + let base: PathBuf = std::env::temp_dir().join(format!("vc-script-engines-places-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + std::fs::write(base.join("inc.jsxinc"), "\nfunction fromInclude() { throw new Error('inc'); }").unwrap(); + std::fs::write(base.join("ev.jsxinc"), "\n\n\nfunction fromEval() { throw new Error('ev'); }").unwrap(); + let def = base.join("def.jsx"); + let text = "#targetengine e\n#include \"inc.jsxinc\"\n$.evalFile('ev.jsxinc');\nfunction fromMain() {\n throw new Error('main');\n}\n\ + function spin() {\n while (true) {}\n}\nfunction badEval() {\n eval('var = 1');\n}"; + std::fs::write(&def, text).unwrap(); + let mut s = Session::new(); + s.execute("script.run", &json!({"path": def.to_string_lossy()})).unwrap(); + let call = |s: &mut Session, code: &str| { + let e = s.execute("script.run", &json!({"source": format!("#targetengine e\n\n{code}"), "name": "caller.js"})); + assert!(e.is_err(), "{code}"); + }; + let shown = def.to_string_lossy().to_string(); + let ends = |p: &(Option, u64, u64), file: &str, line: u64| p.0.as_deref().is_some_and(|f| f.ends_with(file)) && p.1 == line && p.2 > 0; + call(&mut s, "fromMain();"); + let p = place(&s); + assert!(ends(&p, "def.jsx", 5), "{p:?}"); + let report = s.script_report().unwrap().clone(); + assert_eq!(report["name"], "caller.js"); + assert_eq!(error_lines(&report), [format!("caller.js: {}:5:{}: Error: main", p.0.as_deref().unwrap(), p.2)]); + call(&mut s, "fromInclude();"); + assert!(ends(&place(&s), "inc.jsxinc", 2), "{:?}", place(&s)); + call(&mut s, "fromEval();"); + assert!(ends(&place(&s), "ev.jsxinc", 4), "{:?}", place(&s)); + call(&mut s, "badEval();"); + assert!(ends(&place(&s), "def.jsx", 11), "a syntax error eval raises is at its call: {:?}", place(&s)); + let e = s.execute("script.run", &json!({"source": "#targetengine e\nspin();", "name": "caller.js", "maxFuel": 100_000})); + assert!(e.is_err()); + assert_eq!(s.script_report().unwrap()["kind"], "limit"); + assert!(ends(&place(&s), "def.jsx", 8), "the fuel ran out in spin: {:?}", place(&s)); + // A caught error thrown again is where that throw is, in the calling run. + call(&mut s, "try { fromMain(); } catch (x) {\n throw x;\n}"); + assert_eq!(place(&s), (None, 4, 3)); + // A run that fails before it starts compiles nothing, and the kept places stay. + assert!(run(&mut s, "#targetengine e\n#include \"missing.jsxinc\"").is_err()); + call(&mut s, "fromMain();"); + assert!(ends(&place(&s), "def.jsx", 5), "{:?}", place(&s)); + assert!(place(&s).0.is_some_and(|f| f == shown)); + // A dropped engine takes its places with it: the new one has its own. + s.execute("script.engine.drop", &json!({"name": "e"})).unwrap(); + run(&mut s, "#targetengine e\nfunction fromMain() { throw new Error('new'); }").unwrap(); + call(&mut s, "fromMain();"); + assert_eq!(place(&s).0.as_deref(), Some("t.js")); + assert_eq!(place(&s).1, 2); + // The name stands for the earlier script as that run's report gave it, not as console lines + // show it. + s.execute("script.run", &json!({"source": "#targetengine e\nfunction named() { throw 1; }", "name": "a\u{1}b.js"})).unwrap(); + call(&mut s, "named();"); + assert_eq!(place(&s).0.as_deref(), Some("a\u{1}b.js")); + let _ = std::fs::remove_dir_all(base); +} + +/// A named engine keeps the places of its earlier runs' code within its budget, the oldest going +/// first, and the code still runs once its place is gone. +#[test] +fn an_engine_keeps_the_newest_places_within_its_budget() { + let mut s = Session::new(); + run(&mut s, "#targetengine e\nfunction a() { throw new Error('a'); }").unwrap(); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(300))); + run(&mut s, "#targetengine e\n\nfunction b() { throw new Error('b'); }").unwrap(); + for _ in 0..20 { + run(&mut s, "#targetengine e\nvar x = 1;").unwrap(); + } + run(&mut s, "#targetengine e\n\n\nfunction c() { throw new Error('c'); }").unwrap(); + // Each run's own map takes the room of the one before, so c is called first. + assert!(run(&mut s, "#targetengine e\nc();").is_err()); + let c_place = place(&s); + let a = run(&mut s, "#targetengine e\na();").map(drop); + let a_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(Some(1))); + run(&mut s, "#targetengine e\n\n\n\nfunction d() { throw new Error('d'); }").unwrap(); + assert!(run(&mut s, "#targetengine e\nd();").is_err()); + let d_place = place(&s); + TEST_KEPT_MAP_BYTES.with(|b| b.set(None)); + assert!(a.is_err()); + assert_eq!((a_place.0, a_place.1, a_place.2), (None, 0, 0), "a's place went to make room"); + assert_eq!((c_place.0.as_deref(), c_place.1), (Some("t.js"), 4)); + assert_eq!((d_place.0, d_place.1, d_place.2), (None, 0, 0), "a map larger than the budget is not kept"); } /// A `$.evalFile` an earlier run kept reads files the way the run that calls it does: relative to diff --git a/crates/engine/src/tests/select_effects.rs b/crates/engine/src/tests/select_effects.rs index b30d0ef70e..d2160fba34 100644 --- a/crates/engine/src/tests/select_effects.rs +++ b/crates/engine/src/tests/select_effects.rs @@ -234,3 +234,160 @@ fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() { // From above B, over B and the shadow: B only. assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]); } + +/// The selection after a drag with `tool` from `from` to `to`. +fn marquee(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64)) -> vectorcraft_doc::Selection { + press(s, tool, from, to, Mods::default()); + s.doc().unwrap().selection.clone() +} + +/// The point of anchor `(si, ai)` of path `id`. +fn anchor_point(s: &Session, id: NodeId, (si, ai): vectorcraft_doc::AnchorRef) -> Point { + let path = s.doc().unwrap().doc.node(id).and_then(|n| n.path_data()).unwrap(); + path.anchors().find(|(s, i, _)| (*s, *i) == (si, ai)).map(|(.., a)| a.p).unwrap() +} + +/// The anchors a marquee or a click picked on `id`, as points. +fn picked_points(s: &Session, id: NodeId) -> Vec { + let refs: Vec<_> = s.doc().unwrap().selection.partial(id).into_iter().flatten().copied().collect(); + refs.into_iter().map(|r| anchor_point(s, id, r)).collect() +} + +/// The anchors the tool marks as hovered. +fn anchor_marks(s: &mut Session) -> Vec { + s.overlays(ViewInfo::default()) + .into_iter() + .filter_map(|o| if let vectorcraft_tools::Overlay::Anchor { p, .. } = o { Some(p) } else { None }) + .collect() +} + +fn hover(s: &mut Session, x: f64, y: f64) -> Cursor { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Move, x, y), v).unwrap(); + s.cursor(Point::new(x, y), Mods::default(), v) +} + +#[test] +fn a_direct_selection_marquee_leaves_out_a_drop_shadow() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + let (a_before, b_before) = (bounds(&s, a), bounds(&s, b)); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + // Over the shadow alone: no object, no anchor, no segment. + let sel = marquee(&mut s, "directSelection", (85.0, 40.0), (95.0, 60.0)); + assert!(sel.objects.is_empty() && sel.anchors.is_empty() && sel.segments.is_empty(), "{sel:?}"); + // Over the shadow and B's upper left corner: that anchor of B, and nothing of A. + let sel = marquee(&mut s, "directSelection", (90.0, 5.0), (105.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); + // Across B's top edge under the shadow, with neither of its anchors inside: that segment. + run(&mut s, "select.none", json!({})); + assert_ne!(pixel(&s, 112, 25), BLUE); + let sel = marquee(&mut s, "directSelection", (110.0, 5.0), (115.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert!(sel.segments.contains_key(&b) && !sel.segments.contains_key(&a), "{sel:?}"); + assert_eq!((bounds(&s, a), bounds(&s, b)), (a_before, b_before)); +} + +#[test] +fn direct_selection_hover_leaves_out_a_drop_shadow_and_marks_the_anchor_under_it() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + // B's upper left corner lies under the shadow. + assert_ne!(pixel(&s, 101, 21), BLUE); + run(&mut s, "select.none", json!({})); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + for (x, y) in [(90.0, 50.0), (110.0, 50.0)] { + assert_eq!(hover(&mut s, x, y), Cursor::ArrowHollow, "({x}, {y})"); + assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})"); + } + assert_eq!(hover(&mut s, 101.0, 21.0), Cursor::DirectAnchor); + assert_eq!(anchor_marks(&mut s), [Point::new(100.0, 20.0)]); + // A press there picks that anchor of B. + assert_eq!(press(&mut s, "directSelection", (101.0, 21.0), (101.0, 21.0), Mods::default()), [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); +} + +#[test] +fn a_group_selection_click_on_a_drop_shadow_selects_the_object_under_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]); + assert!(press(&mut s, "groupSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty()); + assert_eq!(press(&mut s, "groupSelection", (50.0, 50.0), (50.0, 50.0), Mods::default()), [a]); +} + +#[test] +fn group_selection_clicks_on_a_drop_shadow_climb_the_groups_of_the_object_under_it() { + let mut s = session(); + // B and C in one group, A and D in another above it, and A's shadow over B. + let b = rect(&mut s, 100.0, 20.0, 60.0, 60.0, "#0000ff"); + let c = rect(&mut s, 170.0, 20.0, 20.0, 20.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [b.0, c.0]})); + let gb = id_of(&run(&mut s, "object.group", json!({}))); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + let d = rect(&mut s, 20.0, 120.0, 20.0, 20.0, "#ff0000"); + run(&mut s, "select.set", json!({"ids": [a.0, d.0]})); + let ga = id_of(&run(&mut s, "object.group", json!({}))); + shadow(&mut s, a, 40.0); + assert_ne!(pixel(&s, 110, 50), BLUE); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + let click = |s: &mut Session, x, y| { + let mut sel = press(s, "groupSelection", (x, y), (x, y), Mods::default()); + sel.sort_by_key(|n| n.0); + sel + }; + assert_eq!(click(&mut s, 110.0, 50.0), [b]); + let mut both = vec![b, gb]; + both.sort_by_key(|n| n.0); + assert_eq!(click(&mut s, 110.0, 50.0), both); + // With A selected, a click on its shadow over the canvas does not add A's group. + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert!(click(&mut s, 90.0, 50.0).is_empty()); + // On A itself the second click adds it. + assert_eq!(click(&mut s, 50.0, 50.0), [a]); + let mut both = vec![a, ga]; + both.sort_by_key(|n| n.0); + assert_eq!(click(&mut s, 50.0, 50.0), both); +} + +#[test] +fn a_group_selection_marquee_leaves_out_a_drop_shadow() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + let sel = marquee(&mut s, "groupSelection", (85.0, 40.0), (95.0, 60.0)); + assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}"); + // Over the shadow and B's upper left corner: that anchor of B. + let sel = marquee(&mut s, "groupSelection", (90.0, 5.0), (105.0, 30.0)); + assert_eq!(sel.objects, [b]); + assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]); + // With E, a marquee around B that crosses A's shadow selects B whole, and not A. + run(&mut s, "select.none", json!({})); + let v = ViewInfo::default(); + s.select_tool("groupSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 95.0, 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 130.0, 50.0), v).unwrap(); + s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 165.0, 90.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 165.0, 90.0), v).unwrap(); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!(sel.objects, [b]); + assert!(sel.partial(b).is_none() && sel.partial(a).is_none(), "{sel:?}"); +} + +#[test] +fn group_selection_hover_over_a_drop_shadow_marks_nothing() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + s.select_tool("groupSelection", ViewInfo::default()).unwrap(); + for (x, y) in [(90.0, 50.0), (110.0, 50.0), (101.0, 21.0)] { + assert_eq!(hover(&mut s, x, y), Cursor::GroupSelection, "({x}, {y})"); + assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})"); + } + assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]); +} diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 57d6c23d06..695a2962a5 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -671,6 +671,44 @@ fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() } } +#[test] +fn holographic_glows_add_whole_in_rgb_and_cmyk_as_one_undo_step() { + let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap(); + let Paint::Gradient(want) = &lib.swatch("Mint Halo").unwrap().paint else { panic!("Mint Halo is a gradient") }; + for mode in ["rgb", "cmyk"] { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode})); + let list = run(&mut s, "swatch.library.list", json!({})); + let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "holographic").unwrap().clone(); + assert_eq!((row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), (Some("Holographic"), Some("gradients"), Some(14))); + let got = run(&mut s, "swatch.library.get", json!({"library": "holographic"})); + assert!(got.to_string().contains("Mint Halo"), "{mode}: get lists Mint Halo"); + let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let node = vectorcraft_doc::NodeId(id.as_u64().unwrap()); + let before = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Mint Halo"]), json!("Mint Halo")), "{mode}"); + assert_eq!(undo_len(&s), before + 1, "{mode}: added and applied as one step"); + // The swatch comes as the library has it: kind, stops (RGB colors, opacities, midpoints) and method. + let Paint::Gradient(added) = &doc(&s).swatch("Mint Halo").unwrap().paint else { panic!("{mode}: a gradient swatch") }; + assert_eq!(added.gradient, want.gradient, "{mode}"); + assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode}: the stops stay RGB"); + assert_eq!(added.gradient.stops.last().map(|st| st.opacity), Some(0.0), "{mode}: clear at the edge"); + let Paint::Gradient(fill) = doc(&s).node(node).unwrap().appearance.fill_paint() else { panic!("{mode}: a gradient fill") }; + assert_eq!((fill.gradient.kind, fill.gradient.method), (want.gradient.kind, want.gradient.method), "{mode}"); + assert_eq!(fill.gradient.stops, want.gradient.stops, "{mode}"); + run(&mut s, "edit.undo", json!({})); + assert!(doc(&s).swatch("Mint Halo").is_none(), "{mode}: undo removes the add"); + assert!(!matches!(doc(&s).node(node).unwrap().appearance.fill_paint(), Paint::Gradient(_)), "{mode}: and the fill"); + // A document swatch already named Mint Halo stays; the glow comes in under a number. + run(&mut s, "swatch.new", json!({"name": "Mint Halo", "color": "#123456"})); + let r = run(&mut s, "swatch.library.add", json!({"library": "holographic", "names": ["Mint Halo"]})); + assert_eq!(r["added"], json!(["Mint Halo 2"]), "{mode}"); + assert!(doc(&s).swatch("Mint Halo").unwrap().paint.color().is_some(), "{mode}: the document's swatch is kept"); + assert!(matches!(&doc(&s).swatch("Mint Halo 2").unwrap().paint, Paint::Gradient(g) if g.gradient == want.gradient)); + } +} + #[test] fn pattern_libraries_bring_their_patterns_and_add_each_once() { let mut s = session(); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 9eea9cf456..abb39c0d96 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -475,6 +475,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() { ("text.setFormat", json!({"ids": [id.0], "hyphenate": "false"}), "hyphenate"), ("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"), ("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"), + ("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"), ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), ("type.systemLayout", json!({"on": "true"}), "on"), @@ -2523,6 +2524,68 @@ fn converting_tracked_type_between_point_and_area_keeps_it_in_place() { } } +/// Horizontal and vertical type keep every line where it is when converted from point to area +/// type and back, and from area to point type and back, however the lines are aligned. Vertical +/// lines run down their columns, and each next column sits to the left of the one before it. +#[test] +fn converting_vertical_type_between_point_and_area_keeps_it_in_place() { + for vertical in [false, true] { + for j in ["left", "center", "right"] { + for text in ["Wave", "Wave\nWaves"] { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 300, "y": 40, "text": text, "size": 12, "vertical": vertical})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), text.lines().count()); + if vertical && before.len() > 1 { + assert!(before[1].x < before[0].x, "{j}: the second column is to the left: {before:?}"); + } + for round in 0..2 { + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + let area = line_starts_doc(&t); + assert_eq!(area.len(), before.len(), "vertical {vertical}, {j}, {text:?}: no rewrap"); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, area: {before:?} → {area:?}"); + } + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let t = obj(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + let point = line_starts_doc(&t); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, {text:?}, round {round}, point: {before:?} → {point:?}"); + } + } + } + // Area type to point type and back. + let mut s = session(); + let r = s + .execute( + "text.create", + &json!({"x": 300, "y": 40, "text": "Wave\nWaves", "size": 12, "vertical": vertical, "area": {"width": 120, "height": 120}}), + ) + .unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setStyle", &json!({"ids": [id.0], "justify": j})).unwrap(); + let before = line_starts_doc(&obj(&s, id)); + assert_eq!(before.len(), 2); + s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap(); + let point = line_starts_doc(&obj(&s, id)); + for (a, b) in point.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, point: {before:?} → {point:?}"); + } + s.execute("type.convertToAreaType", &json!({"ids": [id.0]})).unwrap(); + let area = line_starts_doc(&obj(&s, id)); + assert_eq!(area.len(), 2); + for (a, b) in area.iter().zip(&before) { + assert!((*a - *b).hypot() < 0.01, "vertical {vertical}, {j}, from area, area again: {before:?} → {area:?}"); + } + } + } +} + #[test] fn convert_area_to_point_keeps_a_deeper_list_item_in_place() { let mut s = session(); diff --git a/crates/engine/src/tests/warp_images.rs b/crates/engine/src/tests/warp_images.rs new file mode 100644 index 0000000000..47175fc866 --- /dev/null +++ b/crates/engine/src/tests/warp_images.rs @@ -0,0 +1,293 @@ +//! Effect › Warp on placed images: the pixels bend with the frame, in the canvas and in exports. + +use serde_json::{Value, json}; +use vectorcraft_doc::live::{WarpNet, WarpStyle, uses_net, warp_version}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, Point, Rect}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +/// The box of the image [`quarters`] places. +const FRAME: Rect = Rect::new(100.0, 100.0, 180.0, 180.0); + +/// The color of the quarter of the image [`quarters`] places that holds (u, v) of its box: red top +/// left, green top right, blue bottom left, yellow bottom right. +fn quarter(u: f64, v: f64) -> [u8; 3] { + match (u < 0.5, v < 0.5) { + (true, true) => [255, 0, 0], + (false, true) => [0, 255, 0], + (true, false) => [0, 0, 255], + (false, false) => [255, 255, 0], + } +} + +/// A selected embedded 40×40 px image filling [`FRAME`], each quarter one color ([`quarter`]). +fn quarters(s: &mut Session) -> NodeId { + let img = image::RgbaImage::from_fn(40, 40, |x, y| { + let [r, g, b] = quarter(x as f64 / 40.0, y as f64 / 40.0); + image::Rgba([r, g, b, 255]) + }); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("quarters".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0); + let im = vectorcraft_doc::ImageObject { + key: "quarters".into(), + width: 40, + height: 40, + xf, + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +/// Where an Arc warp bending `bend` % takes the point at (u, v) of [`FRAME`]. +fn arced(bend: f64, u: f64, v: f64) -> Point { + let net = WarpNet::new(WarpStyle::Arc, bend / 100.0, 0.0, 0.0, FRAME.width(), FRAME.height(), false); + FRAME.origin() + net.at(u, v).to_vec2() +} + +/// Points of the image's box: near each quarter's middle and on both sides of the line between +/// the top and bottom quarters, where a warp that moves the pixels up or down changes the color. +const SAMPLES: [(f64, f64); 10] = + [(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8), (0.4, 0.4), (0.6, 0.4), (0.4, 0.6), (0.6, 0.6), (0.3, 0.62), (0.7, 0.62)]; + +/// The document drawn over (0, 0)–(400, 400) at one pixel a point, on white. +fn drawn(s: &Session) -> vectorcraft_render::Rendered { + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 400.0, 400.0), 1.0, true) +} + +/// The sample points whose color in `img` at `at(u, v)` is not the image's color at (u, v). +fn wrong(img: &vectorcraft_render::Rendered, at: &dyn Fn(f64, f64) -> Point) -> Vec<(f64, f64, [u8; 4])> { + SAMPLES + .iter() + .filter_map(|&(u, v)| { + let p = at(u, v); + let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32); + let want = quarter(u, v); + let near = (0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40); + (!near).then_some((u, v, px)) + }) + .collect() +} + +/// `quarters` with Effect › Warp › Arc at 50 %: the warp the effect holds, checked to be the +/// Bézier patch [`arced`] follows. +fn arc_image(s: &mut Session) -> NodeId { + let id = quarters(s); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let e = &n.appearance.effects[0]; + assert!(warp_version(&e.params["version"]).is_some_and(uses_net), "{}", e.params); + id +} + +fn decoded(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +#[test] +fn an_images_pixels_bend_with_effect_warp() { + let mut s = session(); + let id = arc_image(&mut s); + // The arc lifts the middle of the image: the line between the quarters rises there. + assert!(arced(50.0, 0.5, 0.6).y < FRAME.y0 + 0.5 * FRAME.height(), "{:?}", arced(50.0, 0.5, 0.6)); + let bent = drawn(&s); + assert_eq!(wrong(&bent, &|u, v| arced(50.0, u, v)), vec![], "each pixel is drawn where the warp takes it"); + // Drawn as pieces of the one image, each placed by the warp. + let d = &s.doc().unwrap().doc; + let art = vectorcraft_render::effects::reshape(Some(d), d.node(id).unwrap(), None).unwrap(); + let mut pieces = 0; + art.walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize); + assert!(pieces >= 100, "{pieces} pieces"); + // Removing the warp draws the image as it was, in one undo step. + let steps = s.doc().unwrap().history.undo.len(); + s.execute("effect.remove", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let plain = |u: f64, v: f64| Point::new(FRAME.x0 + u * FRAME.width(), FRAME.y0 + v * FRAME.height()); + assert_eq!(wrong(&drawn(&s), &plain), vec![]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![], "undo brings the bend back"); +} + +/// `out`, an export of [`arc_image`]'s document named `name`, opened again: it draws the image +/// bent. +fn opens_bent(name: &str, out: &Value) { + let mut back = session(); + back.execute("document.open", &json!({"name": name, "dataBase64": out["dataBase64"]})).unwrap(); + assert_eq!(wrong(&drawn(&back), &|u, v| arced(50.0, u, v)), vec![], "{name}"); +} + +#[test] +fn a_warped_image_keeps_its_bend_in_svg() { + let mut s = session(); + arc_image(&mut s); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let text = String::from_utf8(decoded(&svg)).unwrap(); + assert_eq!(text.matches("= 100, "{images} pieces"); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); + s.execute("edit.undo", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert!(matches!(n.kind, NodeKind::Image(_)) && n.appearance.effects.len() == 1); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); +} + +/// A selected embedded grayscale 40×40 px image filling [`FRAME`], black above white, colorized +/// red by its fill color: red above, nothing below. +fn red_on_clear(s: &mut Session) -> NodeId { + let img = image::GrayImage::from_fn(40, 40, |_, y| image::Luma([if y < 20 { 0 } else { 255 }])); + let mut png = vec![]; + image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let id = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 40, + height: 40, + xf, + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap(); + id +} + +#[test] +fn a_colorized_image_keeps_its_color_when_warped() { + let mut s = session(); + red_on_clear(&mut s); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + let img = drawn(&s); + let (red, white) = ([255u8, 0, 0], [255u8, 255, 255]); + for (u, v, want) in [(0.5, 0.35, red), (0.2, 0.2, red), (0.5, 0.65, white), (0.8, 0.8, white)] { + let p = arced(50.0, u, v); + let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32); + assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}"); + } +} + +#[test] +fn a_warp_on_a_group_bends_the_image_in_it() { + let mut s = session(); + let im = quarters(&mut s); + // A group of the image and a line below it as wide as the image: the group's box is the image's. + let line = s.execute("shape.line", &json!({"x1": FRAME.x0, "y1": FRAME.y1, "x2": FRAME.x1, "y2": FRAME.y1})).unwrap(); + let line = NodeId(line["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [im.0, line.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap(); + assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index b3779d8108..b41eb112f4 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -809,6 +809,12 @@ fn structured_junk() { ("addon.type.releasePath", json!({"mode": "text", "keepOriginal": false, "id": 2})), ("addon.type.releasePath", json!({"mode": ["both"], "keepOriginal": "no", "ids": "all", "id": -1})), ("addon.type.releasePath", json!({"mode": "nope", "keepOriginal": 1e308, "ids": [1.5, null, "x"]})), + // Cutting Tools add-on: Stencil Bridges. + ("addon.cutting.bridges", json!({"width": 0.01, "count": 16, "direction": "shortest", "keepOriginal": true})), + ("addon.cutting.bridges", json!({"width": 1000, "count": 1, "direction": "horizontal", "keepOriginal": false})), + ("addon.cutting.bridges", json!({"width": "3 mm", "direction": "vertical"})), + ("addon.cutting.bridges", json!({"width": f64::MAX, "count": 1e308, "direction": ["vertical"], "keepOriginal": "no"})), + ("addon.cutting.bridges", json!({"width": -1, "count": -3, "direction": "diagonal", "keepOriginal": 1e308})), ("addon.cleanup.breakText", json!({"by": "glyphs", "ids": [0, 1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})), ("addon.cleanup.breakText", json!({"by": ["lines"]})), ("addon.cleanup.breakText", json!({"by": "paragraphs"})), diff --git a/crates/engine/tests/font_folder.rs b/crates/engine/tests/font_folder.rs index b58e79fc17..11d152fb4d 100644 --- a/crates/engine/tests/font_folder.rs +++ b/crates/engine/tests/font_folder.rs @@ -71,6 +71,13 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() { assert!(st.revision > revision && !st.is_dirty()); assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0); + // A caller whose read roots hold the file but not the Fonts folder: the file is kept, and the + // reply names the copy by its file name only, without the folder. + let downloads = vectorcraft_engine::file_access::AutomationRoots::new(&[&root.join("Downloads")], &[]).unwrap(); + let r = vectorcraft_engine::file_access::confine(downloads.as_ref(), || s.execute("text.addFontFiles", &json!({"files": [file]}))).unwrap(); + assert_eq!((&r["folder"], &r["kept"]), (&Value::Null, &json!([{"from": file, "to": "Findme 2.otf"}])), "{r}"); + assert!(!r.to_string().contains("App Fonts"), "{r}"); + // The same file again is kept, not copied a third time. let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap(); assert_eq!((r["copied"].clone(), r["kept"].clone()), (json!([]), json!([{"from": file, "to": fonts.join("Findme 2.otf")}])), "{r}"); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 67bb04c33b..ac98db8e9c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2682,7 +2682,10 @@ fn arb_ps_token() -> impl Strategy { // Local VM restored: dictionary changes undone, saves restored twice. "/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store", // Executable strings and keys other than names (PLRM 3rd ed., `cvx`, `load`). - "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit" + "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit", + // CID-keyed fonts found by name over predefined CMaps, horizontal and vertical. + "/Ryumin-Light-90ms-RKSJ-H", "/Ryumin-Light-EUC-V", "/Ryumin-Light-H", "/GBT-EUC-H", "/KSCms-UHC-V", "/UniJIS-UTF16-V", + "[/Ryumin-Light]", "composefont", "<93fa967b8540fd0d41>", "" ]) .prop_map(str::to_string), ] diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index f2ba516b46..1ab878fc62 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -3785,6 +3785,34 @@ fn an_unstroked_run_with_a_stroke_style_loses_nothing() { } } +/// The text document has no Korean Line Break Anywhere: a paragraph with it on and Hangul in it +/// is reported as left out, and comes back off; with it off, or with no Hangul, nothing is +/// reported. +#[test] +fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() { + use vectorcraft_doc::TextObject; + for (text, on, reported) in [("가나다 라마", true, true), ("가나다 라마", false, false), ("Plain", true, false)] { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK)); + t.para.korean_line_break_anywhere = on; + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers[0].id; + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings); + let mut all = vec![]; + paths(&read_back(&doc).layers, &mut all); + let paras: Vec = all + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some(t.para_at(0).korean_line_break_anywhere), + _ => None, + }) + .collect(); + assert_eq!(paras, [false], "{text} {on}"); + } +} + #[test] fn type_is_written_again_as_the_same_bytes() { let doc = typed(); @@ -4366,6 +4394,19 @@ fn dynamic_text_is_written_at_its_drawn_sizes() { assert!(!texts[0].dynamic_text); let k = texts[0].xf.as_coeffs()[0]; assert_eq!(r3(texts[0].runs[0].style.size * k), r3(drawn)); + // Type written as outlines (here on an open path whose ends meet) loses the setting too, and + // says so. + let mut doc = Document::new(600.0, 300.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Grow", style(12.0, Color::BLACK)); + let ends_meet = [(50.0, 200.0).into(), (550.0, 200.0).into(), (300.0, 50.0).into(), (50.0, 200.0).into()]; + t.kind = TextKind::OnPath { path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&ends_meet, false)]), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + let node = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, node).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::TYPE) && out.warnings.iter().any(|w| w == writer::DYNAMIC_TEXT), "{:?}", out.warnings); } /// See-through type with the knockout shape flag comes back with the flag and writes no warning. @@ -6627,6 +6668,7 @@ fn every_effect_a_graphic_style_holds_is_read_back() { for id in &ids { cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"}))); cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); + cases.push((id, json!({"bend": 45.0, "horizontal": 15.0, "vertical": -5.0, "orientation": "horizontal", "version": 1}))); } for (id, params) in cases { let effect = fx(id, params.clone()); @@ -6643,6 +6685,10 @@ fn every_effect_a_graphic_style_holds_is_read_back() { let got = &back.graphic_styles[0].appearance.effects; assert_eq!(got.len(), 1, "{id} {params}"); assert_eq!(got[0].id, effect.id); + // A warp read from a file bends as its style's Bézier net. + if id.starts_with("warp.") { + assert_eq!(got[0].params["version"], json!(vectorcraft_doc::live::WARP_NET), "{id} {params}"); + } let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params)); for (key, value) in w.as_object().unwrap() { // Random isn't written, and a style with it isn't read; its seed is the default. @@ -6663,6 +6709,19 @@ fn every_effect_a_graphic_style_holds_is_read_back() { } } } + // A warp saved before the map version existed is written with its settings too, and reads back on + // the nets' map. + let old = vectorcraft_doc::Effect { id: "warp.flag".into(), params: json!({"bend": 40.0, "horizontal": 10.0}), visible: true }; + let mut doc = Document::new(200.0, 100.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Old", Appearance { effects: vec![old], ..Appearance::basic(black_fill(), Paint::None, 0.0) }); + g.id = 3; + *doc.graphic_styles = vec![g]; + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings); + let back = read_back(&doc); + let [warp] = back.graphic_styles[0].appearance.effects.as_slice() else { panic!("{:?}", back.graphic_styles) }; + assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["horizontal"]), ("warp.flag", &json!(40.0), &json!(10.0))); + assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET)); // A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians. let mut doc = Document::new(200.0, 100.0); let effects = vec![ diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index afa6412561..dea4ad0c24 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1257,6 +1257,10 @@ impl<'a> Writer<'a> { return; } self.warn(TYPE); + // The outlines keep the sizes Dynamic Text draws the type at, but not the setting. + if matches!(&n.kind, NodeKind::Text(t) if t.dynamic_text) { + self.warn(DYNAMIC_TEXT); + } let doc = self.doc; let resolved = match &n.kind { NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index c4ae9fda3b..c28c538bcf 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -889,9 +889,16 @@ pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) -> } /// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as -/// `#rrggbb`. +/// `#rrggbb`. A Warp's map `version` is left out: the file holds the warp's settings, and the reader +/// gives every warp it reads the newest map, so a warp saved before the version existed is written +/// with its settings as before. fn normalized_params(e: &Effect) -> Value { let mut p = vectorcraft_effects::merged_params(&e.id, &e.params); + if e.id.starts_with("warp.") + && let Value::Object(m) = &mut p + { + m.remove("version"); + } if let Some(m) = word(&p, "mode") { p["mode"] = Value::String(m); } diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 5ebd73de3a..1d791afadf 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -385,6 +385,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { if p.justify == Justify::Auto && p.direction.is_none() && rtl_letter { return true; } + let hangul = + text.chars().any(|c| matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}' | '\u{FFA0}'..='\u{FFDC}')); let held = ParaStyle { justify: p.justify, left_indent: p.left_indent, @@ -398,6 +400,9 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // Width, Standard burasagari) isn't reported: it changes no other script's lines. mojikumi: if p.mojikumi == Mojikumi::LineEndHalf { p.mojikumi } else { ParaStyle::default().mojikumi }, burasagari: if p.burasagari == Burasagari::Standard { p.burasagari } else { ParaStyle::default().burasagari }, + // The text document has no Korean Line Break Anywhere: it reads back off, which + // changes only the lines of a paragraph with Hangul in it. + korean_line_break_anywhere: p.korean_line_break_anywhere && !hangul, ..ParaStyle::default() }; *p != held diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 0a5799ddf9..dc335bf1a1 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -42,6 +42,7 @@ mod interp; mod lex; mod native; mod obj; +mod predefined_cmap; mod preview; mod shading; mod text; diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index d117dc83c6..edb9bba3e3 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -349,6 +349,7 @@ fn warp(o: &Obj) -> Option { h: percent("DeformHoriz")?, v: percent("DeformVert")?, horizontal: !vertical, + version: vectorcraft_doc::live::WARP_NET, }) } diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index a1b24662ec..6cdbb96747 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -14,12 +14,12 @@ use std::sync::Arc; -use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen}; use vectorcraft_color::Paint; use vectorcraft_doc::{Burasagari, CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun}; use vectorcraft_geom::AnchorKind; +use super::super::super::predefined_cmap::{self, CMapCodes, composite_font}; use super::super::obj::V; use super::art::path_data; use super::{FLIP, GState, HashSet, Kind, Node, NodeId, Reader}; @@ -284,259 +284,15 @@ impl TypeState { /// How strings in a font with predefined CMap `cmap` decode; `None` when the CMap can't be read. fn cmap_codes(&mut self, cmap: &str) -> Option> { - if let Some(form) = unicode_cmap(cmap) { - return Some(Arc::new(CMapCodes::Unicode(form))); - } if let Some((_, codes)) = self.cmaps.iter().find(|(n, _)| n == cmap) { return codes.clone(); } - let codes = cid_codes(cmap).map(Arc::new); + let codes = predefined_cmap::codes(cmap).map(Arc::new); self.cmaps.push((cmap.to_string(), codes.clone())); codes } } -/// How a composite font's strings decode. -#[derive(Debug)] -enum CMapCodes { - /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; - /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's - /// code space ([`code_space`]). - Cids { codes: Box, unicode: Box, high_bit: bool, space: Option }, - /// A predefined Unicode CMap: the codes are the characters. - Unicode(Unicode), -} - -/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the -/// code's length in bytes. -type CodeSpace = &'static [(&'static [u8], &'static [u8])]; - -/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. -fn control(b: u8) -> Option { - match b { - b'\r' => Some('\n'), - b'\t' => Some('\t'), - _ => None, - } -} - -impl CMapCodes { - /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a - /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a - /// UTF-16 or UTF-32 string). - fn decode(&self, bytes: &[u8]) -> String { - let (codes, unicode, high_bit, space) = match self { - Self::Unicode(form) => { - let unit = match form { - Unicode::Utf16 => 2, - Unicode::Utf8 => 1, - Unicode::Utf32 => 4, - }; - let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); - let mut text = unicode_text(whole, *form).replace('\r', "\n"); - text.extend(rest.last().copied().and_then(control)); - return text; - } - Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), - }; - let euc: Vec; - let bytes = if high_bit { - euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); - euc.as_slice() - } else { - bytes - }; - let mut out = String::new(); - let mut at = 0; - while let Some(&first) = bytes.get(at) { - // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every - // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space - // has no one-byte codes. - if let Some(control) = control(first) { - out.push(control); - at += 1; - continue; - } - let rest = bytes.get(at..).unwrap_or_default(); - let code = |len: u8| rest.get(..usize::from(len)).map(|b| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b))); - let found = match space { - // The code is as long as the code space range it falls in; a byte that starts no - // code in the code space is skipped. - Some(space) => { - let Some(len) = code_len(space, rest) else { - at += 1; - continue; - }; - code(len).map(|c| (len, codes.lookup_cid_code(c, len))) - } - None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), - }; - let Some((len, cid)) = found else { - at += 1; - continue; - }; - at += usize::from(len); - // A code the CMap leaves out is dropped. - let Some(cid) = cid else { continue }; - match unicode.lookup_bf_string(cid) { - // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in - // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. - Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => out.push(char::from(first)), - Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => out.push(c), - Some(BfString::String(s)) => out.extend(s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), - _ => {} - } - } - out - } -} - -/// How a predefined Unicode CMap's codes encode their characters. -#[derive(Clone, Copy, PartialEq, Debug)] -enum Unicode { - /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. - Utf16, - Utf8, - /// Four bytes, the high byte first. - Utf32, -} - -/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, -/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` -/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ). -fn unicode_cmap(name: &str) -> Option { - let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; - let rest = rest.strip_suffix("-HW").unwrap_or(rest); - let (collection, form) = rest.rsplit_once('-')?; - let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; - if !collections.contains(&collection) { - return None; - } - match form { - "UCS2" | "UTF16" => Some(Unicode::Utf16), - "UTF8" => Some(Unicode::Utf8), - "UTF32" => Some(Unicode::Utf32), - _ => None, - } -} - -/// The characters string `s` encodes in Unicode form `form` (invalid codes left out). -fn unicode_text(s: &[u8], form: Unicode) -> String { - match form { - Unicode::Utf16 => { - let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); - char::decode_utf16(units).filter_map(Result::ok).collect() - } - Unicode::Utf8 => s.utf8_chunks().map(|chunk| chunk.valid()).collect(), - Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), - } -} - -/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose -/// codes are CIDs of no known character collection. -fn predefined(name: &str) -> Option> { - match CMapName::from_bytes(name.as_bytes()) { - CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, - name => Some(name), - } -} - -/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` -/// (): the nearest carried CMap with the same -/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC -/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space -/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. -fn stand_in(stem: &str) -> Option> { - Some(match stem { - "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), - "78-EUC" => Some(("EUC-H", false)), - "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), - "GB" => Some(("GB-EUC-H", true)), - "CNS1" => Some(("CNS-EUC-H", true)), - "KSC" => Some(("KSC-EUC-H", true)), - "B5" => Some(("ETen-B5-H", false)), - "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), - "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, - _ => return None, - }) -} - -/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A -/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two -/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with -/// its [`stand_in`]. -fn cid_codes(cmap: &str) -> Option { - let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); - let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); - let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); - let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { - let (name, high_bit) = stand_in(stem?).flatten()?; - Some((read(name)?, high_bit)) - })?; - let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; - let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; - // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. - let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; - Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) -} - -/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it -/// (); a CMap's vertical form has its -/// horizontal form's. `None` for a CMap not listed here. -fn code_space(cmap: &str) -> Option { - const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; - const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; - const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; - const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; - const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; - const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; - const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; - const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; - const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; - const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; - const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; - const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; - const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; - const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; - const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; - const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; - let stem = match cmap { - "H" | "V" => "", - _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, - }; - let stem = stem.strip_suffix("-HW").unwrap_or(stem); - Some(match stem { - "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, - _ if stem.ends_with("RKSJ") => RKSJ, - "EUC" | "78-EUC" => EUC_JP, - "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, - "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, - "GBpc-EUC" | "GBTpc-EUC" => GB_PC, - "GBK-EUC" | "GBKp-EUC" => GBK, - "GBK2K" => GBK2K, - "B5pc" => B5_PC, - "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, - "HKscs-B5" | "ETHK-B5" => HKSCS, - "HKdlb-B5" => HKDLB, - "HKgccs-B5" => HKGCCS, - "CNS-EUC" => CNS_EUC, - "KSCpc-EUC" => KSC_PC, - "KSCms-UHC" => UHC, - _ => return None, - }) -} - -/// The length of the code string `bytes` starts with: the length of the code space range whose -/// lowest and highest codes hold each of its bytes between them. `None` when no range does. -fn code_len(space: CodeSpace, bytes: &[u8]) -> Option { - space - .iter() - .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) - .map(|(lo, _)| lo.len()) - .min() - .and_then(|len| u8::try_from(len).ok()) -} - /// Encoding pairs (§3.3.1): codes, each followed by the glyph names from it on, into `table`. fn encoding_pairs(table: &mut [Option], vals: &[V]) { let mut code: Option = None; @@ -556,36 +312,6 @@ fn encoding_pairs(table: &mut [Option], vals: &[V]) { } } -/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a -/// predefined Unicode CMap ([`unicode_cmap`]), one of the predefined CMaps VectorCraft carries -/// ([`predefined`]) or one of the others ([`stand_in`]), in its horizontal (`-H`) or vertical -/// (`-V`) form. A name ending in an Identity CMap is not read as one, nor is one that ends in an -/// unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise end in `H`. -fn composite_font(name: &str) -> Option<(&str, &str)> { - if !(name.ends_with("-H") || name.ends_with("-V")) { - return None; - } - // The longest CMap name first: `83pv-RKSJ-H` before `H`. - // No predefined CMap name is longer than 32 bytes. - for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { - let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; - if matches!(cmap, "Identity-H" | "Identity-V") { - return None; - } - let encoding_part = - base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); - if matches!(cmap, "H" | "V") && encoding_part { - return None; - } - let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); - let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); - if unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() { - return Some((base, cmap)); - } - } - None -} - /// An operator's `N` operands, the last `N` values before it, when each is a finite number. fn nums(vals: &[V]) -> Option<[f64; N]> { let operands = vals.get(vals.len().checked_sub(N)?..)?; diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index f7df4f35d6..911d8340cc 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1259,7 +1259,7 @@ mod tests { fn envelopes_in_symbol_definitions_spend_the_imports_checks() { use crate::import::ai::tests::{drawn_content, envelope_content, envelope_of, straight_mesh, warp_keys}; use vectorcraft_doc::live::EnvelopeKind; - let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET }; let envelope = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&want)); let mut reader = Reader::new(b""); diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index f15744f30c..ef661c1446 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1159,6 +1159,7 @@ fn effect(f: &Filter, under: Under) -> Option { "horizontal": pct("DeformHoriz"), "vertical": pct("DeformVert"), "orientation": if flag("Rotate") { "vertical" } else { "horizontal" }, + "version": vectorcraft_doc::live::WARP_NET, }), ) } diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index d39a87b5c0..47086ca171 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -495,6 +495,8 @@ fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { // The warp is the whole object's; the characters paint below every item. let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); + // A file's warp bends as its style's Bézier net. + assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET)); assert_eq!(st.contents_index, None); } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 46386d2200..8fa0a58346 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use kurbo::{Affine, Point, Rect}; use vectorcraft_color::gradient::GradientKind; use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::live::WARP_NET; use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit}; use super::*; @@ -7403,15 +7404,16 @@ fn a_warp_envelope_opens_live() { for (style, bend, h, v, rotate, id) in [(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")] { - let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 }; + let want = + EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0, version: WARP_NET }; let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want)); let r = read_ok(&layer(&body)); let a = art(&r.doc); assert_eq!(a.len(), 1, "{a:#?}"); let (content, kind, fidelity, editing, options) = live_envelope(&a[0]); - let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") }; - assert_eq!((s.as_str(), *horizontal), (id, rotate == 0)); + let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal, version } = kind else { panic!("{kind:?}") }; + assert_eq!((s.as_str(), *horizontal, *version), (id, rotate == 0, WARP_NET)); assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}"); assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW)); assert_eq!(content.len(), 1, "{content:#?}"); @@ -7443,7 +7445,7 @@ fn warp_envelopes_read_every_style() { "twist", ]; for (n, id) in ids.iter().enumerate() { - let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0); let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want)))); let a = art(&r.doc); @@ -7974,7 +7976,7 @@ fn an_envelope_that_doesnt_read_keeps_its_drawn_art() { #[test] fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { use vectorcraft_doc::live::EnvelopeKind; - let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other)); let r = read_ok(&layer(&body)); let a = art(&r.doc); @@ -7989,7 +7991,7 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { fn a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { use vectorcraft_doc::live::EnvelopeKind; const WHITE: &str = "0 0 0 0 1 1 1 Xa\n"; - let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true }; + let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET }; let content = envelope_content().replace(RED, WHITE); let drawn = drawn_content(&other).replace(RED, WHITE); let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &content, 1, &drawn); diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs index e2ad169f79..a54c11ae2b 100644 --- a/crates/eps/src/import/cid.rs +++ b/crates/eps/src/import/cid.rs @@ -18,6 +18,7 @@ use vectorcraft_geom::BezPath; use super::interp::{Interp, matrix_obj}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; +use super::predefined_cmap::Unicode; /// Most codespace ranges, character and notdef mappings a CMap may hold. const MAX_RANGES: usize = 1 << 16; @@ -210,16 +211,6 @@ fn number(data: &[u8], at: usize, n: usize) -> Option { (n <= 8).then(|| bytes.iter().fold(0u64, |v, b| v << 8 | u64::from(*b))) } -/// How a predefined Unicode CMap's codes encode their characters. -#[derive(Clone, Copy, PartialEq, Debug)] -pub(super) enum Unicode { - /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. - Utf16, - Utf8, - /// Four bytes, the high byte first. - Utf32, -} - /// The encoding of predefined Unicode CMap `name` and whether it is vertical: one of Adobe's /// public CMap resources whose name says its codes are Unicode: `Uni` and a character /// collection, then `-UCS2`, `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), @@ -246,18 +237,6 @@ pub(super) fn unicode_cmap(name: &str) -> Option<(Unicode, bool)> { Some((form, vertical)) } -/// The characters show string `s` encodes in Unicode form `form` (invalid codes left out). -pub(super) fn unicode_text(s: &[u8], form: Unicode) -> String { - match form { - Unicode::Utf16 => { - let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); - char::decode_utf16(units).filter_map(Result::ok).collect() - } - Unicode::Utf8 => String::from_utf8_lossy(s).chars().filter(|c| *c != char::REPLACEMENT_CHARACTER).collect(), - Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), - } -} - /// A predefined Identity CMap: two-byte codes, each the CID of its own value; vertical for /// `Identity-V`. pub(super) fn identity(name: &str, vertical: bool) -> Obj { @@ -417,8 +396,13 @@ impl Interp<'_> { return Ok(d.clone()); } let text = name.text().map(|t| t.to_string()).unwrap_or_default(); - match unicode_cmap(&text) { - Some((_, vertical)) => { + // Another predefined CMap the file doesn't define, without its mappings: its codes are + // read by its name when type is set (see `text`). + let predefined = unicode_cmap(&text) + .map(|(_, vertical)| vertical) + .or_else(|| super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text))); + match predefined { + Some(vertical) => { let mut d = Dict::new(); d.insert(Key::name("CMapName"), Obj::name(&text)); d.insert(Key::name("CMapType"), Obj::Int(1)); diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 992a8bbcea..58708bb97e 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -92,6 +92,9 @@ pub(crate) struct Interp<'a> { pub cid_subrs: super::cid::CidSubrs, /// The font number `usefont` gave the CMap mappings that follow. pub cmap_font: i64, + /// The predefined CMaps type was set in so far, by name, with how their strings decode + /// (`None` when one can't be read; see `text`). + pub predefined_cmaps: Vec<(Rc, Option>)>, pub g: GState, pub saved: Vec, pub out: Out, @@ -232,6 +235,7 @@ impl<'a> Interp<'a> { cff: vec![], cid_subrs: vec![], cmap_font: 0, + predefined_cmaps: vec![], type42_counted: HashSet::new(), g, saved: vec![], diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs new file mode 100644 index 0000000000..1a9b3b95be --- /dev/null +++ b/crates/eps/src/import/predefined_cmap.rs @@ -0,0 +1,358 @@ +//! Strings in fonts over Adobe's predefined CMaps () +//! read as characters: the codes of a predefined Unicode CMap are the characters; those of the +//! others map to CIDs of a character collection, whose CID → Unicode CMap gives the characters. +//! Used by legacy `.ai` type in composite fonts and by type in CID-keyed fonts found by name. + +use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; + +/// How a composite font's strings decode. +#[derive(Debug)] +pub(super) enum CMapCodes { + /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; + /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's + /// code space ([`code_space`]). + Cids { codes: Box, unicode: Box, high_bit: bool, space: Option }, + /// A predefined Unicode CMap: the codes are the characters. + Unicode(Unicode), +} + +/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the +/// code's length in bytes. +type CodeSpace = &'static [(&'static [u8], &'static [u8])]; + +/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. +fn control(b: u8) -> Option { + match b { + b'\r' => Some('\n'), + b'\t' => Some('\t'), + _ => None, + } +} + +impl CMapCodes { + /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a + /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a + /// UTF-16 or UTF-32 string). + pub(super) fn decode(&self, bytes: &[u8]) -> String { + let mut text = String::new(); + self.each_code(bytes, |_, chars| text.extend(chars)); + text + } + + /// The codes of string `bytes`, each with its value and the characters it stands for (as + /// [`Self::decode`] reads them; none for a code the CMaps don't map, or for a byte that starts + /// no code, which counts as a code of its own). + pub(super) fn codes(&self, bytes: &[u8]) -> Decoded { + let mut out = Decoded::default(); + self.each_code(bytes, |code, chars| out.push(code, chars)); + out + } + + /// Calls `emit` with each code of string `bytes` in turn ([`Self::codes`]): its value and the + /// characters it stands for. + fn each_code(&self, bytes: &[u8], mut emit: impl FnMut(u32, &mut dyn Iterator)) { + let value = |b: &[u8]| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b)); + let (codes, unicode, high_bit, space) = match self { + Self::Unicode(form) => { + let unit = match form { + Unicode::Utf16 => 2, + Unicode::Utf8 => 1, + Unicode::Utf32 => 4, + }; + let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); + unicode_codes(whole, *form, |code, c| emit(value(code), &mut c.map(|c| if c == '\r' { '\n' } else { c }).into_iter())); + if let Some(&last) = rest.last() { + emit(value(rest), &mut control(last).into_iter()); + } + return; + } + Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), + }; + // A code's value is that of the string's bytes, also where they're read in their EUC form. + let original = bytes; + let value_at = |at: usize, len: usize| original.get(at..at + len).map_or(0, value); + let euc: Vec; + let bytes = if high_bit { + euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); + euc.as_slice() + } else { + bytes + }; + let mut at = 0; + while let Some(&first) = bytes.get(at) { + // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every + // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space + // has no one-byte codes. + if let Some(control) = control(first) { + emit(value_at(at, 1), &mut Some(control).into_iter()); + at += 1; + continue; + } + let rest = bytes.get(at..).unwrap_or_default(); + let code = |len: u8| rest.get(..usize::from(len)).map(value); + let found = match space { + // The code is as long as the code space range it falls in; a byte that starts no + // code in the code space is skipped. + Some(space) => code_len(space, rest).and_then(|len| code(len).map(|c| (len, codes.lookup_cid_code(c, len)))), + None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), + }; + let Some((len, cid)) = found else { + emit(value_at(at, 1), &mut None.into_iter()); + at += 1; + continue; + }; + let value = value_at(at, usize::from(len)); + at += usize::from(len); + // A code the CMap leaves out stands for no character. + match cid.and_then(|cid| unicode.lookup_bf_string(cid)) { + // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in + // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. + Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => { + emit(value, &mut Some(char::from(first)).into_iter()) + } + Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => emit(value, &mut Some(c).into_iter()), + Some(BfString::String(s)) => emit(value, &mut s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), + _ => emit(value, &mut None.into_iter()), + } + } + } +} + +/// A string's characters, and its codes: each code's value and how many of the characters it +/// stands for, in order. +#[derive(Debug, Default)] +pub(super) struct Decoded { + pub text: String, + pub codes: Vec<(u32, usize)>, +} + +impl Decoded { + /// Code `code`, standing for characters `chars`. + pub(super) fn push(&mut self, code: u32, chars: impl IntoIterator) { + let before = self.text.len(); + self.text.extend(chars); + let n = self.text.get(before..).map_or(0, |added| added.chars().count()); + self.codes.push((code, n)); + } +} + +/// How strings in a font over predefined CMap `cmap` decode; `None` when the CMap can't be read. +pub(super) fn codes(cmap: &str) -> Option { + match unicode_cmap(cmap) { + Some(form) => Some(CMapCodes::Unicode(form)), + None => cid_codes(cmap), + } +} + +/// How a predefined Unicode CMap's codes encode their characters. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(super) enum Unicode { + /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. + Utf16, + Utf8, + /// Four bytes, the high byte first. + Utf32, +} + +/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, +/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` +/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ). +fn unicode_cmap(name: &str) -> Option { + let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; + let rest = rest.strip_suffix("-HW").unwrap_or(rest); + let (collection, form) = rest.rsplit_once('-')?; + let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; + if !collections.contains(&collection) { + return None; + } + match form { + "UCS2" | "UTF16" => Some(Unicode::Utf16), + "UTF8" => Some(Unicode::Utf8), + "UTF32" => Some(Unicode::Utf32), + _ => None, + } +} + +/// Calls `out` with each code of string `s` in Unicode form `form` in turn, and the character it +/// encodes (none for an invalid code: a lone surrogate, a byte that starts no UTF-8 character, a +/// value past U+10FFFF). +fn unicode_codes(s: &[u8], form: Unicode, mut out: impl FnMut(&[u8], Option)) { + match form { + Unicode::Utf16 => { + let unit = |i: usize| s.get(i..i + 2).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(u16::from_be_bytes); + let mut at = 0; + while let Some(u) = unit(at) { + let next = unit(at + 2); + let pair = next.filter(|n| (0xd800..0xdc00).contains(&u) && (0xdc00..0xe000).contains(n)); + let len = if pair.is_some() { 4 } else { 2 }; + let c = char::decode_utf16([u].into_iter().chain(pair)).next().and_then(Result::ok); + out(s.get(at..at + len).unwrap_or_default(), c); + at += len; + } + } + Unicode::Utf8 => { + let mut at = 0; + for chunk in s.utf8_chunks() { + for c in chunk.valid().chars() { + out(s.get(at..at + c.len_utf8()).unwrap_or_default(), Some(c)); + at += c.len_utf8(); + } + for _ in chunk.invalid() { + out(s.get(at..at + 1).unwrap_or_default(), None); + at += 1; + } + } + } + Unicode::Utf32 => { + for b in s.as_chunks::<4>().0 { + out(b.as_slice(), char::from_u32(u32::from_be_bytes(*b))); + } + } + } +} + +/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose +/// codes are CIDs of no known character collection. +fn predefined(name: &str) -> Option> { + match CMapName::from_bytes(name.as_bytes()) { + CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, + name => Some(name), + } +} + +/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` +/// (): the nearest carried CMap with the same +/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC +/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space +/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. +fn stand_in(stem: &str) -> Option> { + Some(match stem { + "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), + "78-EUC" => Some(("EUC-H", false)), + "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), + "GB" => Some(("GB-EUC-H", true)), + "CNS1" => Some(("CNS-EUC-H", true)), + "KSC" => Some(("KSC-EUC-H", true)), + "B5" => Some(("ETen-B5-H", false)), + "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), + "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, + _ => return None, + }) +} + +/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A +/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two +/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with +/// its [`stand_in`]. +fn cid_codes(cmap: &str) -> Option { + let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); + let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); + let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); + let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { + let (name, high_bit) = stand_in(stem?).flatten()?; + Some((read(name)?, high_bit)) + })?; + let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; + let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; + // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. + let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; + Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) +} + +/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it +/// (); a CMap's vertical form has its +/// horizontal form's. `None` for a CMap not listed here. +fn code_space(cmap: &str) -> Option { + const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; + const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; + const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; + const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; + const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; + const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; + const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; + const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; + const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; + const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; + const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; + const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; + const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; + const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; + let stem = match cmap { + "H" | "V" => "", + _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, + }; + let stem = stem.strip_suffix("-HW").unwrap_or(stem); + Some(match stem { + "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, + _ if stem.ends_with("RKSJ") => RKSJ, + "EUC" | "78-EUC" => EUC_JP, + "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, + "GBpc-EUC" | "GBTpc-EUC" => GB_PC, + "GBK-EUC" | "GBKp-EUC" => GBK, + "GBK2K" => GBK2K, + "B5pc" => B5_PC, + "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, + "HKscs-B5" | "ETHK-B5" => HKSCS, + "HKdlb-B5" => HKDLB, + "HKgccs-B5" => HKGCCS, + "CNS-EUC" => CNS_EUC, + "KSCpc-EUC" => KSC_PC, + "KSCms-UHC" => UHC, + _ => return None, + }) +} + +/// The length of the code string `bytes` starts with: the length of the code space range whose +/// lowest and highest codes hold each of its bytes between them. `None` when no range does. +fn code_len(space: CodeSpace, bytes: &[u8]) -> Option { + space + .iter() + .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) + .map(|(lo, _)| lo.len()) + .min() + .and_then(|len| u8::try_from(len).ok()) +} + +/// Whether `cmap` names a predefined CMap: a predefined Unicode CMap ([`unicode_cmap`]), one of the +/// predefined CMaps VectorCraft carries ([`predefined`]) or one of the others ([`stand_in`]), in +/// its horizontal (`-H`) or vertical (`-V`) form. The Identity CMaps are not among them. +pub(super) fn known(cmap: &str) -> bool { + let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); + let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); + unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() +} + +/// Whether predefined CMap `cmap` is a vertical CMap (`-V`, or the row-and-cell `V`). +pub(super) fn vertical(cmap: &str) -> bool { + cmap == "V" || cmap.ends_with("-V") +} + +/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a +/// predefined CMap ([`known`]). A name ending in an Identity CMap is not read as one, nor is one +/// that ends in an unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise +/// end in `H`. +pub(super) fn composite_font(name: &str) -> Option<(&str, &str)> { + if !(name.ends_with("-H") || name.ends_with("-V")) { + return None; + } + // The longest CMap name first: `83pv-RKSJ-H` before `H`. + // No predefined CMap name is longer than 32 bytes. + for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { + let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; + if matches!(cmap, "Identity-H" | "Identity-V") { + return None; + } + let encoding_part = + base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); + if matches!(cmap, "H" | "V") && encoding_part { + return None; + } + if known(cmap) { + return Some((base, cmap)); + } + } + None +} diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 2b01b2b4f2..9e452c4bf4 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -403,6 +403,171 @@ fn named_cidfonts_over_unicode_cmaps_set_their_characters_as_type() { check("/Ryumin-Light-UniJIS-UCS2-H findfont 20 scalefont setfont <00410042> stringwidth pop dup 5 gt exch 40 lt and"); } +/// The text objects of an import. +fn texts(r: &crate::Imported) -> Vec { + all(&r.document) + .iter() + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((**t).clone()), + _ => None, + }) + .collect() +} + +/// A CID-keyed font over one of the other predefined CMaps (Shift-JIS, EUC, GBK, Big Five, UHC, +/// JIS row and cell: ), the file defining +/// neither, its CIDFont only named: the codes are read by that CMap and its character +/// collection's CIDs to Unicode, and set as type in the font of the CIDFont's name, whether +/// found as `CIDFont-CMap` or composed. Codes the CMap doesn't map and control codes are left out. +#[test] +fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() { + let cases: &[(&str, &str, &str)] = &[ + // 日 is 0x93FA and 本 0x967B in Shift-JIS. + ("83pv-RKSJ-H", "93fa967b41", "日本A"), + ("90ms-RKSJ-H", "93fa", "日"), + ("EUC-H", "c6fc", "日"), + // JIS row and cell: 日 is 0x467C. + ("H", "467c", "日"), + ("GBK-EUC-H", "d6d0", "中"), + ("B5pc-H", "a4a4", "中"), + ("ETen-B5-H", "a4a4", "中"), + ("KSCms-UHC-H", "c7d1", "한"), + // A CMap read with the nearest carried one: GB 2312 row and cell in EUC form, 中 0x5650. + ("GB-H", "5650", "中"), + // 0x8540 maps to no CID, 0xFD is in no code space range, 0x0D is a control code. + ("90ms-RKSJ-H", "854093fa41fd0d42", "日AB"), + ]; + for (cmap, hex, want) in cases { + let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show")); + assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{cmap}: {:?}", r.warnings); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), [*want], "{cmap}"); + let style = &t[0].runs[0].style; + assert_eq!((style.font_family.as_str(), style.font_style.as_str(), style.size), ("Ryumin", "Light", 20.0), "{cmap}"); + assert!(!t[0].vertical, "{cmap}"); + } + let r = super::tests::read("/X /GBK-EUC-H [/NotInFile] composefont 20 scalefont setfont 10 20 moveto show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "中A"); + // The point moves past the type, along the line. + check("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont <93fa967b> stringwidth abs 0.01 lt exch dup 5 gt exch 60 lt and and"); +} + +/// Type in a CID-keyed font over a predefined CMap, its CIDFont only named, shown with `ashow`, +/// `widthshow` (matching a code by its value) and `xshow`, is spaced as the operator says: the +/// point moves that far, and the characters' tracking makes up the difference, as for type in a +/// font found by name. Over a vertical CMap the extra space runs down the column. +#[test] +fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { + // Measured without painting. + let measured = |body: &str| check(&format!("gsave nulldevice {body} grestore")); + let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont"; + // `ashow` adds 5 to each of the two codes. + measured(&format!( + "{set} 10 20 moveto <93fa967b> show currentpoint pop 10 20 moveto 5 0 <93fa967b> ashow currentpoint pop sub -10 sub abs 0.01 lt" + )); + let r = super::tests::read(&format!("{set} 10 20 moveto 5 0 <93fa967b> ashow")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日本"]); + assert!(t[0].runs.iter().all(|run| (run.style.tracking - 250.0).abs() < 0.5), "{:?}", t[0].runs); + // `widthshow` adds 7 to the space, code 0x20. + measured(&format!( + "{set} 10 20 moveto <93fa20967b> show currentpoint pop 10 20 moveto 7 0 32 <93fa20967b> widthshow currentpoint pop sub -7 sub abs 0.01 lt" + )); + // A row-and-cell code read in its EUC form keeps the value of the string's bytes: 中 0x5650. + let gb = "/STSong-Light-GB-H findfont 20 scalefont setfont"; + measured(&format!( + "{gb} 10 20 moveto <56505650> show currentpoint pop 10 20 moveto 7 0 16#5650 <56505650> widthshow currentpoint pop sub -14 sub abs 0.01 lt" + )); + // `xshow` moves 30 a code. + measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt")); + // Down the column: `ashow`'s 5 down each code. + let set = "/Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont"; + measured(&format!( + "{set} 10 200 moveto <93fa967b> show currentpoint exch pop 10 200 moveto 0 -5 <93fa967b> ashow currentpoint exch pop sub 10 sub abs 0.01 lt" + )); + let r = super::tests::read(&format!("{set} 10 200 moveto 0 -5 <93fa967b> ashow")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::(), "日本"); + assert!(t.iter().all(|t| t.vertical), "{t:?}"); +} + +/// A CID-keyed font over a vertical predefined CMap (`-V`), the file defining neither, its CIDFont +/// only named: the type is a vertical column, and the point moves down it, not along a line. +#[test] +fn named_cidfonts_over_vertical_cmaps_set_vertical_type() { + for (cmap, hex) in [("90ms-RKSJ-V", "93fa967b"), ("UniJIS-UCS2-V", "65e5672c")] { + let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日本"], "{cmap}: {:?}", r.warnings); + assert!(t[0].vertical, "{cmap}"); + check(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont <{hex}> stringwidth dup -60 gt exch -5 lt and exch abs 0.01 lt and")); + } +} + +/// Down a column each code of a CID-keyed font over a vertical predefined CMap moves the point one +/// em, the default vertical displacement (PDF Reference 1.7, §5.6.3, `DW2`), also two digits that +/// vertical type sets side by side in one em: `show` and `stringwidth` move the point as far as +/// showing the codes one at a time. +#[test] +fn codes_down_a_column_each_move_the_point_one_em() { + // Measured without painting. + let set = "gsave nulldevice /Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont"; + check(&format!("{set} (12) stringwidth grestore -40 sub abs 0.01 lt exch abs 0.01 lt and")); + check(&format!("{set} 10 100 moveto (12) show currentpoint grestore 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); + check(&format!( + "{set} 10 100 moveto (1) show (2) show currentpoint 10 100 moveto (12) show currentpoint grestore \ + 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); +} + +/// Type in a CID-keyed font over a predefined CMap is set the same way by `show` and by the show +/// operators that space it: characters the font would join in a ligature (`fi` in the bundled +/// Source Serif 4) are set without the ligature, and the point moves as far. +#[test] +fn named_cid_type_is_set_alike_by_every_show_operator() { + let set = "/SourceSerif4-Regular-UniJIS-UCS2-H findfont 50 scalefont setfont 10 20 moveto"; + let features = |show: &str| { + let r = super::tests::read(&format!("{set} <00660069> {show}")); + let t = texts(&r); + assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["fi"], "{show}"); + t[0].runs.iter().map(|r| r.style.features.clone()).collect::>() + }; + let plain = features("show"); + assert_eq!(plain, features("0 0 3 -1 roll ashow")); + assert!(plain.iter().all(|f| f.iter().any(|f| f == "-liga")), "{plain:?}"); + check(&format!( + "gsave nulldevice {set} <00660069> stringwidth 10 20 moveto 0 0 <00660069> ashow currentpoint grestore \ + 20 sub 3 -1 roll sub abs 0.01 lt 3 1 roll 10 sub sub abs 0.01 lt and" + )); +} + +/// A font name ending in the row-and-cell CMap `H` after a name without a hyphen stays a font of +/// the whole name, its strings read byte by byte. Type in a predefined CMap whose mappings aren't +/// available is left out with a warning, and a long string in a CID-keyed font over a predefined +/// CMap shown in a loop runs out of the budget. +#[test] +fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { + let r = super::tests::read("/Futura-H findfont 20 scalefont setfont 10 20 moveto (AB) show"); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["AB"]); + let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill"); + assert!(texts(&r).is_empty(), "{:?}", all(&r.document)); + assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings); + super::tests::runs_out_of_budget("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont { 100000 string stringwidth pop pop } loop"); +} + +/// A string in the file, in a CID-keyed font over a predefined CMap, too long to decode within the +/// memory budget stops the program with an error before it is decoded. +#[test] +fn huge_strings_over_predefined_cmaps_count_against_the_memory_budget() { + // Codes the CMap doesn't map, so nothing is laid out when the string is decoded. + let huge = "\u{1}".repeat(17 << 20); + let r = import(&super::tests::eps(&format!( + "0 0 10 10 rectfill /Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont 10 20 moveto ({huge}) stringwidth" + ))) + .unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); +} + /// A Type 2 CIDFont the file carries (its glyph space one unit an em), over a Unicode CMap the /// file doesn't define, sets its characters as type at the font's size, not a thousand times it. #[test] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index dcce2d1f91..1c042d6657 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -5419,7 +5419,14 @@ fn type_envelope_text(family: &str, paint: Option<&str>) -> String { fn arc_envelope(text: &Node, bend: f64) -> Node { let mut text = text.clone(); text.visible = true; - let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::live::EnvelopeKind::Warp { + style: "arc".into(), + bend, + h: 0.0, + v: 0.0, + horizontal: true, + version: vectorcraft_doc::live::WARP_NET, + }; Node::new( vectorcraft_doc::NodeId(1_000_000), NodeKind::Envelope { diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 5868aa9228..ee9b0fc759 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -15,6 +15,7 @@ use super::cid::Base; use super::graphics::MAX_APART; use super::interp::{Interp, matrix_obj, matrix_of}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err}; +use super::predefined_cmap::Decoded; use super::type1::Embedded; /// The font a program uses before it sets one. @@ -170,8 +171,8 @@ impl Interp<'_> { ResourceForAll => self.resource_for_all()?, Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => { let (s, spacing) = self.show_operands(op)?; - if let Some((text, font)) = self.unicode_type(&s)? { - self.show_unicode(&text, &font)?; + if let Some((codes, font, vertical)) = self.named_cid_type(&s)? { + self.show_named_cid(&codes, &font, vertical, &spacing)?; } else if let Some(glyphs) = self.composite_glyphs(&s)? { self.show_composite(&glyphs, &spacing)?; } else { @@ -282,9 +283,9 @@ impl Interp<'_> { /// How far `s` moves the current point (user space). fn width_of(&mut self, s: &[u8]) -> Res { - if let Some((text, font)) = self.unicode_type(s)? { + if let Some((codes, font, vertical)) = self.named_cid_type(s)? { let saved = self.g.font.replace(font); - let r = self.set_text(&text, Point::ZERO).map(|t| t.2); + let r = self.named_cid_width(&codes, vertical); self.g.font = saved; return r; } @@ -395,18 +396,29 @@ impl Interp<'_> { } /// The font named `k`: one the program defined; else, for a name `CIDFont-CMap` whose CMap - /// is a predefined Unicode CMap, the CID-keyed font of those two (the usual name of a - /// CID-keyed font, as `composefont` makes them: PLRM 3rd ed., chapter 8); else a font of - /// that name. + /// is a predefined CMap, the CID-keyed font of those two (the usual name of a CID-keyed font, + /// as `composefont` makes them: PLRM 3rd ed., chapter 8; the CMaps are Adobe's public CMap + /// resources, ); else a font of that name. + /// A name an installed face has is that face's, and a name split at a bare `H` or `V` (the + /// row-and-cell CMaps) needs a CIDFont name with a hyphen in it (`Ryumin-Light-H`), so + /// `Futura-H` stays a font of that name. fn find_font(&mut self, k: &Obj) -> Res { let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?; if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) { return Ok(d.clone()); } let text = k.text().map(|t| t.to_string()).unwrap_or_default(); - let split = - text.match_indices('-').map(|(i, _)| i).find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()); - if let Some((cid_font, cmap)) = split.and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) { + let split = text + .match_indices('-') + .map(|(i, _)| i) + .find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some()) + .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) + .or_else(|| { + let (cid_font, cmap) = super::predefined_cmap::composite_font(&text)?; + let installed = vectorcraft_text::FontDb::global().find_postscript(&text).is_some(); + (!installed && !(matches!(cmap, "H" | "V") && !cid_font.contains('-'))).then_some((cid_font, cmap)) + }); + if let Some((cid_font, cmap)) = split { let cmap = self.find_cmap(&Obj::name(cmap))?; let dep = self.find_cid_font(&Obj::name(cid_font))?; return Ok(self.composed(key, cmap, vec![Obj::Dict(dep)])); @@ -476,6 +488,15 @@ impl Interp<'_> { /// [`Self::set_type`] for the characters `text`. fn set_text(&mut self, text: &str, at: Point) -> Res<(TextObject, TextLayout, Vec2)> { + self.set_text_in(text, at, false) + } + + /// [`Self::set_text`], as vertical type when `vertical`: a column anchored at `at`, the top + /// center of its first em box, that moves the current point down the column by the layout's + /// advances. In vertical writing the current point is a glyph's origin 1, by default half its + /// width across and 880 units of 1000 above its origin 0 (PDF Reference 1.7, §5.6.3 "Glyph + /// Metrics in CIDFonts", `DW2`). + fn set_text_in(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout, Vec2)> { // Laying out type costs about as much as 300 operations, and 64 more a character // (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends // within the budget like any other loop instead of running for minutes. @@ -493,11 +514,29 @@ impl Interp<'_> { let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; let mut t = TextObject::point(Point::ZERO, text, style); + t.vertical = vertical; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); t.xf = m * Affine::scale_non_uniform(1.0 / size, -1.0 / size); t.cached_bounds = Some(layout.bounds); let adv: f64 = layout.glyphs.iter().map(|g| g.advance).sum(); - Ok((t, layout, f1 * Point::new(adv / size, 0.0) - f1 * Point::ZERO)) + let moved = if vertical { Point::new(0.0, -adv / size) } else { Point::new(adv / size, 0.0) }; + Ok((t, layout, f1 * moved - f1 * Point::ZERO)) + } + + /// [`Self::set_text_in`] for characters the file places one by one: where the layout joins + /// characters in a ligature, the type is set without ligatures, so each character has its own + /// advance. + fn set_placed_text(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout)> { + let (mut t, mut layout, _) = self.set_text_in(text, at, vertical)?; + let glyph_starts: std::collections::HashSet = layout.glyphs.iter().map(|g| g.byte).collect(); + if text.char_indices().any(|(b, _)| !glyph_starts.contains(&b)) { + for run in &mut t.runs { + run.style.features.push("-liga".into()); + } + layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + t.cached_bounds = Some(layout.bounds); + } + Ok((t, layout)) } /// `show`: point type at the current point, which moves past it. @@ -527,27 +566,30 @@ impl Interp<'_> { return self.show_type42_outlines(data, glyphs, s, spacing, at, f1); } // Each code's character, if it has one that shows (control codes don't). - let chars: Vec> = match &plain { + let chars = match &plain { Some((_, glyphs)) => glyphs.iter().map(|g| g.ch).collect::>(), None => self.named_chars(s).into_iter().map(Some).collect(), + }; + let mut codes = Decoded::default(); + for (c, &code) in chars.into_iter().zip(s) { + codes.push(u32::from(code), c.filter(|c| !c.is_control())); } - .into_iter() - .map(|c| c.filter(|c| !c.is_control())) - .collect(); - let text: String = chars.iter().flatten().collect(); - let (mut t, mut layout, _) = self.set_text(&text, at)?; + let widths: Option> = plain.map(|(_, glyphs)| glyphs.iter().map(|g| g.width).collect()); + self.show_codes(&codes, widths.as_deref(), spacing, false, s) + } + + /// The codes `codes` (each standing for some of its characters, or none) shown in the current + /// font as [`Self::show_plain`] shows a string's, spaced as `spacing` says, each code `widths` + /// wide when given (in glyph space, one unit an em), else as wide as its characters' advances; + /// as vertical type when `vertical`, the codes moving the point down the column. `kern` is the + /// string `kshow` gives its procedure the codes of. + fn show_codes(&mut self, codes: &Decoded, widths: Option<&[f64]>, spacing: &Spacing, vertical: bool, kern: &[u8]) -> Res { + let at = self.user_point()?; + let (_, f1) = self.font()?; + let text = &codes.text; + let (mut t, layout) = self.set_placed_text(text, at, vertical)?; let size = t.runs.first().map_or(1.0, |r| r.style.size); let starts: Vec = text.char_indices().map(|(b, _)| b).collect(); - // The file places each character's glyph: where the layout joins characters in a - // ligature, the type is set without ligatures, so each character has its own advance. - let glyph_starts: std::collections::HashSet = layout.glyphs.iter().map(|g| g.byte).collect(); - if starts.iter().any(|b| !glyph_starts.contains(b)) { - for run in &mut t.runs { - run.style.features.push("-liga".into()); - } - layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - t.cached_bounds = Some(layout.bounds); - } // Each character's advance in the layout (text space). let mut natural = vec![0.0; starts.len()]; for g in &layout.glyphs { @@ -555,29 +597,30 @@ impl Interp<'_> { *n += g.advance; } } - // Each code's move (user space) and that move in text space, along the baseline and across. - let em = |x: f64| f1 * Point::new(x, 0.0) - f1 * Point::ZERO; + // Each code's move (user space) and that move in text space, along the baseline (down the + // column) and across. + let em = |x: f64| f1 * if vertical { Point::new(0.0, -x) } else { Point::new(x, 0.0) } - f1 * Point::ZERO; let inv = f1.inverse(); - let mut moves = Vec::with_capacity(s.len()); + let mut moves = Vec::with_capacity(codes.codes.len()); let mut k = 0; // What `kshow`'s procedure draws goes after the type. let start = self.out.drawn.len(); let mut pen = at; - for (i, (&code, c)) in s.iter().zip(&chars).enumerate() { - let own = match &plain { - Some((_, glyphs)) => glyphs.get(i).map_or(0.0, |g| g.width), - None if c.is_some() => natural.get(k).copied().unwrap_or(0.0) / size, - None => 0.0, + for (i, &(code, n)) in codes.codes.iter().enumerate() { + let own = match widths { + Some(widths) => widths.get(i).copied().unwrap_or(0.0), + None => natural.get(k..k + n).map_or(0.0, |w| w.iter().sum::()) / size, }; - k += usize::from(c.is_some()); + k += n; let mut d = spacing.advance(i, i64::from(code), em(own)); - d += self.kern(spacing, s, i, pen + d)?; + d += self.kern(spacing, kern, i, pen + d)?; pen += d; let e = inv * Point::new(d.x, d.y) - inv * Point::ZERO; - if ![d.x, d.y, e.x * size, e.y * size].iter().all(|v| v.is_finite()) { + let (along, across) = if vertical { (-e.y * size, e.x * size) } else { (e.x * size, e.y * size) }; + if ![d.x, d.y, along, across].iter().all(|v| v.is_finite()) { return ps_err("undefinedresult", "a show"); } - moves.push((d, e.x * size, e.y * size)); + moves.push((d, along, across)); } let total: Vec2 = moves.iter().map(|m| m.0).sum(); if !(total.x.is_finite() && total.y.is_finite()) { @@ -589,12 +632,11 @@ impl Interp<'_> { if across { // Moves across the baseline (`yshow`, `xyshow`): each character where the file puts it. let mut pen = at; - for ((d, ..), c) in moves.iter().zip(&chars) { - if let Some(c) = c - && painted - && !c.is_whitespace() - { - let (t, ..) = self.set_text(c.encode_utf8(&mut [0; 4]), pen)?; + let mut chars = text.chars(); + for ((d, ..), &(_, n)) in moves.iter().zip(&codes.codes) { + let c: String = chars.by_ref().take(n).collect(); + if painted && !c.trim().is_empty() { + let (t, ..) = self.set_text_in(&c, pen, vertical)?; let clips = self.g.clips.clone(); self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); } @@ -605,11 +647,17 @@ impl Interp<'_> { // a character, before the next, are that character's. let mut lead = Vec2::ZERO; let mut target: Vec = Vec::with_capacity(natural.len()); - for ((d, along, _), c) in moves.iter().zip(&chars) { - match (c, target.last_mut()) { - (Some(_), _) => target.push(*along), - (None, Some(last)) => *last += along, - (None, None) => lead += *d, + for ((d, along, _), &(_, n)) in moves.iter().zip(&codes.codes) { + if n > 0 { + // A code of several characters: those before its last keep their advances. + let before = natural.get(target.len()..target.len() + n - 1).unwrap_or_default().to_vec(); + let rest = *along - before.iter().sum::(); + target.extend(before); + target.push(rest); + } else if let Some(last) = target.last_mut() { + *last += along; + } else { + lead += *d; } } let tracking: Vec = target @@ -931,23 +979,47 @@ impl Interp<'_> { .collect()) } - /// The characters of `s` and the font to set them in when the current font is a CID-keyed - /// font over a predefined Unicode CMap the file doesn't define (`UniJIS-UCS2-H`), whose - /// mappings to CIDs aren't known: its codes say the characters, set as type in the font of - /// its CIDFont's name. - fn unicode_type(&mut self, s: &[u8]) -> Res> { - let Some((text, font, name)) = self.unicode_parts(s) else { return Ok(None) }; + /// The codes of `s` with the characters each stands for, the font to set them in and whether + /// they run down a column, when the current font is a CID-keyed font over a predefined CMap + /// the file doesn't define (`UniJIS-UCS2-H`, `90ms-RKSJ-H`), whose mappings to CIDs the file + /// doesn't give: a Unicode CMap's codes are the characters, another CMap's are read by the CMap + /// of that name and its character collection's CID → Unicode CMap (a code it doesn't map + /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical + /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't + /// read is left out, with a warning. + fn named_cid_type(&mut self, s: &[u8]) -> Res> { + let Some((cmap, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; + // Each code is looked up in the CMap's mappings. + self.spend(s.len() as u64)?; + // The decoded codes and characters are held while the string is shown: checked against + // the memory budget first, so a huge string in the file stops the program with an error. + let held = s.len().saturating_mul(DECODED_BYTES); + self.alloc(held)?; + self.unalloc(held); + let decoded = match self.predefined_codes(&cmap) { + Some(codes) => codes.codes(s), + None => { + self.out.warn(UNREAD_CMAP); + Decoded::default() + } + }; + let mut codes = Decoded::default(); + let mut chars = decoded.text.chars(); + for &(code, n) in &decoded.codes { + codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control())); + } // The composite font is copied under the CIDFont's name for every string: charged by // its size, so a huge font dictionary shown in a loop runs out of the budget. let entries = font.borrow().len(); self.spend(entries as u64)?; let mut named = font.borrow().clone(); named.insert(Key::name("FontName"), name); - Ok(Some((text, DictRef::new(named)))) + Ok(Some((codes, DictRef::new(named), vertical))) } - /// The text, the composite font and the CIDFont's name of [`Self::unicode_type`]. - fn unicode_parts(&self, s: &[u8]) -> Option<(String, DictRef, Obj)> { + /// The CMap's name, the composite font, the CIDFont's name and the writing mode of + /// [`Self::named_cid_type`]. + fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> { let font = self.g.font.clone()?; let f = font.borrow(); if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(9.0) { @@ -959,29 +1031,63 @@ impl Interp<'_> { if cmap.contains_key(&Key::name("CodeSpaceRange")) { return None; } - let (form, _) = super::cid::unicode_cmap(&cmap.get(&Key::name("CMapName")).and_then(Obj::text)?)?; + let cmap_name = cmap.get(&Key::name("CMapName")).and_then(Obj::text)?.to_string(); + if super::cid::unicode_cmap(&cmap_name).is_none() && !super::predefined_cmap::known(&cmap_name) { + return None; + } + let wmode = f.get(&Key::name("WMode")).or_else(|| cmap.get(&Key::name("WMode"))).and_then(Obj::as_num); let Some(Obj::Dict(dep)) = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)) else { return None }; let name = { let d = dep.borrow(); d.get(&Key::name("CIDFontName")).or_else(|| d.get(&Key::name("FontName"))).cloned()? }; - Some((super::cid::unicode_text(s, form), font.clone(), name)) + Some((cmap_name, font.clone(), name, wmode == Some(1.0))) } - /// `text` set as point type at the current point in font `font` (see - /// [`Self::unicode_type`]), which moves past it. - fn show_unicode(&mut self, text: &str, font: &DictRef) -> Res { - let at = self.user_point()?; - let saved = self.g.font.replace(font.clone()); - let r = self.set_text(text, at); - self.g.font = saved; - let (t, _, adv) = r?; - if !self.g.paint.is_none() && !self.g.null && !text.trim().is_empty() { - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips); + /// How strings in predefined CMap `cmap` decode (a CMap other than a Unicode CMap read once a + /// file); `None` when VectorCraft can't read it. + fn predefined_codes(&mut self, cmap: &str) -> Option> { + if let Some((form, _)) = super::cid::unicode_cmap(cmap) { + return Some(Rc::new(super::predefined_cmap::CMapCodes::Unicode(form))); } - self.g.cur = Some(self.xf() * (at + adv)); - Ok(()) + if let Some((_, codes)) = self.predefined_cmaps.iter().find(|(n, _)| **n == *cmap) { + return codes.clone(); + } + let codes = super::predefined_cmap::codes(cmap).map(Rc::new); + // Only the names of the other predefined CMaps reach here (`named_cid_parts`), so the + // list stays short. + self.predefined_cmaps.push((Rc::from(cmap), codes.clone())); + codes + } + + /// The codes `codes` (with their characters) set as point type at the current point in font + /// `font` (see [`Self::named_cid_type`]), running down a column when `vertical`, which moves + /// past them, spaced as `spacing` says, as [`Self::show_codes`] shows them. `widthshow` and + /// `awidthshow` match a code by its value (font number 0 times 256 plus the code: PLRM 3rd ed., + /// `widthshow` in chapter 8); `kshow`'s procedure isn't run between them (a composite font's + /// codes aren't single bytes). Down a column each code moves the point one em, the default + /// vertical displacement of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7, + /// §5.6.3, `DW2`). + fn show_named_cid(&mut self, codes: &Decoded, font: &DictRef, vertical: bool, spacing: &Spacing) -> Res { + let widths = vertical.then(|| vec![1.0; codes.codes.len()]); + let saved = self.g.font.replace(font.clone()); + let r = self.show_codes(codes, widths.as_deref(), spacing, vertical, &[]); + self.g.font = saved; + r + } + + /// How far the codes `codes` move the current point (user space) when [`Self::show_named_cid`] + /// shows them with no extra spacing. + fn named_cid_width(&mut self, codes: &Decoded, vertical: bool) -> Res { + let (_, f1) = self.font()?; + let moved = if vertical { + Point::new(0.0, -(codes.codes.len() as f64)) + } else { + let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?; + let size = t.runs.first().map_or(1.0, |r| r.style.size); + Point::new(layout.glyphs.iter().map(|g| g.advance).sum::() / size, 0.0) + }; + Ok(f1 * moved - f1 * Point::ZERO) } /// The characters of a composite font shown at the current point, spaced as `spacing` says. @@ -1282,6 +1388,13 @@ const MAX_TYPE1: usize = 256; /// The warning for CID-keyed type in a CIDFont the file doesn't carry. const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry was left out"; +/// Most bytes [`Interp::named_cid_type`] holds for each byte of a string: two lists of codes (16 +/// bytes a code) and their characters (up to 4 bytes a character, a few characters a code). +const DECODED_BYTES: usize = 64; + +/// The warning for type in a predefined CMap whose mappings VectorCraft doesn't have. +pub(super) const UNREAD_CMAP: &str = "Type in a CMap that couldn't be read was left out"; + /// Most Type 42 fonts whose TrueType data is kept between glyphs. const MAX_TYPE42: usize = 16; diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index e256e305c2..2a09cf003c 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -33,7 +33,10 @@ //! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx //! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each //! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`, -//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Only the +//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Standard +//! Vertical Roman Alignment is written as `1 Tv` and `0 Tv`, and Tate-chu-yoko in horizontal +//! type as a Kumi block recorded for later use, `1 TY` … `0 TY` (§15.3.4); neither operator is +//! written for type that has neither mark. Only the //! revisable form is written: readers lay the type out again (§15.1). Fonts are named by their //! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to //! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding @@ -97,6 +100,7 @@ pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines"; pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)"; pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, manual kerning, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out"; +pub const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at (legacy .ai files don't have it)"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; diff --git a/crates/eps/src/legacy_ai/prolog.rs b/crates/eps/src/legacy_ai/prolog.rs index b0d606bba9..5ee77f89cf 100644 --- a/crates/eps/src/legacy_ai/prolog.rs +++ b/crates/eps/src/legacy_ai/prolog.rs @@ -106,7 +106,7 @@ VCLegacy begin /TE {cleartomark} bd /TZ {cleartomark} bd /To {pop} bd /Tp {7 {pop} repeat} bd /TP {} bd /TO {} bd /Tr {pop} bd /Tf {pop pop pop pop} bd /Ts {pop} bd /Tz {pop pop} bd /Tt {pop} bd /TA {pop} bd -/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd +/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd /Tv {pop} bd /TY {pop} bd % The page trailer (3): annotatepage marks nothing. /annotatepage {} bd end diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 43b28c934a..d189c94ed9 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1193,6 +1193,59 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// Standard Vertical Roman Alignment is written as `Tv` and Tate-chu-yoko in horizontal type as a +/// Kumi block recorded for later use, `TY` (§15.3.4), each where it changes: the marks read back +/// on the same characters, a block is closed before its text object ends, and type with neither +/// mark has neither operator. +#[test] +fn vertical_roman_and_tate_chu_yoko_are_written_and_read_back() { + use vectorcraft_doc::{TextObject, TextRun}; + let plain = style(12.0, Paint::solid(Color::BLACK)); + let roman = vectorcraft_doc::CharStyle { standard_vertical_roman: true, ..plain.clone() }; + let tcy = vectorcraft_doc::CharStyle { tate_chu_yoko: true, ..plain.clone() }; + let both = vectorcraft_doc::CharStyle { tate_chu_yoko: true, standard_vertical_roman: true, ..plain.clone() }; + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut marked = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "A", plain.clone()); + for (text, st) in [("12", &tcy), ("b", &roman), ("c", &both), ("34", &tcy)] { + marked.runs.push(TextRun::new(text, st.clone())); + } + let next = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "D", plain.clone()); + for t in [marked, next] { + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == TYPE || w == TYPE_ATTRIBUTES), "{:?}", out.warnings); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + assert_eq!((text.matches("1 TY\n").count(), text.matches("0 TY\n").count()), (2, 2), "{text}"); + assert_eq!((text.matches("1 Tv\n").count(), text.matches("0 Tv\n").count()), (1, 1), "{text}"); + // The last block ends before its text object does. + assert!(text.contains("(34) Tx\n0 TY\nTO\n"), "{text}"); + let read = texts(&crate::import(&out.bytes).unwrap().document); + let flags = |t: &TextObject| -> Vec<(String, bool, bool)> { + let mut out: Vec<(String, bool, bool)> = vec![]; + for r in &t.runs { + let key = (r.style.tate_chu_yoko, r.style.standard_vertical_roman); + match out.last_mut() { + Some(last) if (last.1, last.2) == key => last.0.push_str(&r.text), + _ => out.push((r.text.clone(), key.0, key.1)), + } + } + out + }; + let s = |x: &str| x.to_string(); + assert_eq!( + flags(&read[0].1), + [(s("A"), false, false), (s("12"), true, false), (s("b"), false, true), (s("c"), true, true), (s("34"), true, false)] + ); + assert_eq!(flags(&read[1].1), [(s("D"), false, false)]); + + // Type with neither mark has neither operator. + let text = String::from_utf8(write(&typed()).bytes).unwrap(); + assert!(!text.contains(" Tv\n") && !text.contains(" TY\n"), "{text}"); +} + /// A legacy `.ai` text path holds the path and where the type starts on it, and no Align to Path /// or end bracket: type on a path with either is written as text, with the warning that they are /// left out. An end bracket at the end of an open path, or once around a closed one, is kept. A @@ -1292,6 +1345,74 @@ fn a_font_height_reference_is_written_as_the_size_drawn() { assert!((size - 20.0).abs() < 1e-6, "{size}"); } +/// Type on a path with Dynamic Text is written at the sizes it is drawn at, with a warning that +/// the setting itself is not written; without Dynamic Text there is no warning. +#[test] +fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() { + let mut doc = typed(); + assert!(!write(&doc).warnings.iter().any(|w| w == DYNAMIC_TEXT)); + let id = texts(&doc).into_iter().find(|(_, t)| matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. })).unwrap().0.id; + let mut drawn = 0.0; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.size = 4.0; + t.dynamic_text = true; + drawn = 4.0 * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale; + } + assert!(drawn > 4.0, "{drawn}"); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); + let imported = crate::import(&out.bytes).unwrap(); + let (_, back) = texts(&imported.document).into_iter().find(|(_, t)| t.runs.iter().any(|r| r.text.contains("path"))).unwrap(); + assert!(!back.dynamic_text); + assert!((back.runs[0].style.size - drawn).abs() < 1e-3, "{} vs {drawn}", back.runs[0].style.size); + // Type written as outlines loses the setting too, and says so. + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].text.push('\u{2192}'); + } + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == TYPE) && out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); + let out = write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }); + assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); +} + +/// Dynamic Text fits type whose size measures the cap height at the size the Character panel +/// shows: 1,000 pt measuring a 0.7 em cap height on a long path grows to the 1,296 pt limit and is +/// written at the em size drawn there, as text and as outlines. +#[test] +fn dynamic_text_with_a_font_height_reference_is_written_at_its_drawn_size() { + use vectorcraft_doc::{TextKind, TextObject}; + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(height_font(true).unwrap()); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "HE", style(1000.0, red())); + let st = &mut t.runs[0].style; + st.font_family = HEIGHT_FAMILY.into(); + st.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight; + let mut line = PathData::new(vec![]); + line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(0.0, 2000.0).into(), (100_000.0, 2000.0).into()], false)); + t.kind = TextKind::OnPath { path: line, start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + let lay = vectorcraft_text::layout(db, &t); + assert!((lay.fit_scale - 1.296).abs() < 1e-9, "{}", lay.fit_scale); + let em = 1296.0 / 0.7; + let mut glyphs = kurbo::BezPath::new(); + for g in &lay.glyphs { + glyphs.extend(g.outline.iter()); + } + let want = glyphs.bounding_box(); + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let back = crate::import(&write(&doc).bytes).unwrap().document; + let size = texts(&back)[0].1.runs[0].style.size; + assert!((size - em).abs() < 1e-3, "{size} vs {em}"); + let back = crate::import(&write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }).bytes).unwrap().document; + let b = children(&children(&back.layers[0])[0])[0].visual_bounds().unwrap(); + assert!((b.height() - want.height()).abs() < 0.05 && (b.width() - want.width()).abs() < 0.05, "{b:?}, laid out at {want:?}"); +} + /// Characters with Auto leading are written with their paragraph's Auto Leading share of the /// size: 18 pt type in paragraphs at 150% is written with 27 pt leading. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 8f8fc4ccf2..032317ab7a 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -3,8 +3,8 @@ use super::encoding::{ unit, writable, }; use super::{ - Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, - GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, + Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, DYNAMIC_TEXT, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, + Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, @@ -39,6 +39,10 @@ pub(super) struct Writer<'a> { in_hidden: bool, /// The fonts type is set in: PostScript name, and whether it's TrueType. pub(super) fonts: Vec<(String, bool)>, + /// The Standard Vertical Roman Alignment the last `Tv` set (it lasts into later text objects). + roman: bool, + /// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type). + kumi: bool, } impl<'a> Writer<'a> { @@ -59,6 +63,8 @@ impl<'a> Writer<'a> { hidden_art: vec![], in_hidden: false, fonts: vec![], + roman: false, + kumi: false, } } @@ -733,15 +739,15 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type: a text object when the format can hold it as text, else its outlines (type with - /// rotated characters among them, which keeps their rotation). A run whose - /// font size measures a height other than the em box is written at the em size it is drawn - /// at ([`vectorcraft_text::FontDb::font_heights_resolved`]). + /// rotated characters among them, which keeps their rotation). fn text(&mut self, t: &TextObject) { if !invertible(t.xf) { return; } - let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); - let t = &*resolved; + // Written as text or as outlines, the type keeps its drawn sizes but not the setting. + if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) { + self.warn(DYNAMIC_TEXT); + } if !self.outline_text { if writable(self.doc, t) { return self.text_object(t); @@ -759,14 +765,18 @@ impl<'a> Writer<'a> { /// A text object (§15): `type To`, the text path (`a b c d tx ty startPt Tp`, an area's frame /// or the path type runs along, `TP`), then each run's style and characters, `TO`. The matrix /// takes text space (y up, the first baseline's start for point type) to art space; the frame - /// and path are in art space, as the specification's examples have them (§15.5). + /// and path are in art space, as the specification's examples have them (§15.5). Type that + /// Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in, fitted at + /// the sizes the Character panel shows; then a run whose font size measures a height other + /// than the em box is written at the em size it is drawn at + /// ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn text_object(&mut self, t: &TextObject) { if lost_attributes(t) { self.warn(TYPE_ATTRIBUTES); } - // Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in. let fitted = vectorcraft_text::drawn(t); - let t = &*fitted; + let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(&fitted); + let t = &*resolved; let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let (kind, path, start) = match &t.kind { TextKind::Point => (0, None, 0.0), @@ -815,6 +825,10 @@ impl<'a> Writer<'a> { self.line(" Tx"); } } + if self.kumi { + self.line("0 TY"); + self.kumi = false; + } self.line("TO"); } @@ -834,7 +848,8 @@ impl<'a> Writer<'a> { } /// A run's character attributes and paint style: `Tr`, its fill and stroke, `Tf`, `Ts`, `Tz`, - /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`. + /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`; then `Tv` and `TY` + /// where Standard Vertical Roman Alignment or Tate-chu-yoko changes. fn char_style(&mut self, st: &CharStyle, p: &ParaStyle) { let fill = !st.fill.is_none(); let stroke = st.has_stroke(); @@ -872,6 +887,14 @@ impl<'a> Writer<'a> { let leading = st.leading.unwrap_or_else(|| p.justification.auto_leading_for(st.size)); ps::push_nums(&mut self.body, &[finite(leading, size * 1.2), finite(p.space_before, 0.0)]); self.line("Tl"); + if st.standard_vertical_roman != self.roman { + self.roman = st.standard_vertical_roman; + self.line(if self.roman { "1 Tv" } else { "0 Tv" }); + } + if st.tate_chu_yoko != self.kumi { + self.kumi = st.tate_chu_yoko; + self.line(if self.kumi { "1 TY" } else { "0 TY" }); + } } /// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill diff --git a/crates/geom/src/arc.rs b/crates/geom/src/arc.rs index c4fe640ae2..644dc44f6a 100644 --- a/crates/geom/src/arc.rs +++ b/crates/geom/src/arc.rs @@ -2,7 +2,7 @@ //! distance along it of the point nearest another (type on a path lays its glyphs out and the //! selection tools drag its brackets by distance along the path). -use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2}; +use kurbo::{Affine, ParamCurve, ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest, PathSeg, Point, Vec2}; use crate::PathData; @@ -76,6 +76,78 @@ impl ArcPath { pub fn fraction_at(&self, p: Point) -> Option { (self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0)) } + + /// The distance along the path of the middle of its topmost points (the least y) where `xf` + /// draws it: the topmost point when there is one, the middle of a level stretch (a + /// rectangle's top edge), and when several points or stretches are topmost, the middle of the + /// shortest part of the path that holds them all. None for a path with no length, or a closed + /// path whose every point is topmost. + pub fn topmost(&self, xf: Affine) -> Option { + let drawn: Vec<(PathSeg, PathSeg, f64, f64)> = self.segs.iter().map(|&(s, c, l)| (s, xf * s, c, l)).collect(); + // The candidates for the least y in each segment: its ends and where it turns. + let turns = |d: &PathSeg| [0.0, 1.0].into_iter().chain(d.extrema()).collect::>(); + let mut bounds: Option = None; + let mut top = f64::INFINITY; + for (_, d, _, _) in &drawn { + let b = d.bounding_box(); + bounds = Some(bounds.map_or(b, |a| a.union(b))); + top = turns(d).into_iter().map(|t| d.eval(t).y).fold(top, f64::min); + } + let b = bounds?; + if !(top.is_finite() && b.width().is_finite() && b.height().is_finite()) { + return None; + } + // Points this close to the top count as topmost: a part of the drawn size, and no less than + // the rounding error of coordinates this far from the origin. + let tol = (1e-9 * (b.width() + b.height())).max(64.0 * f64::EPSILON * b.y0.abs().max(b.y1.abs())); + // Stretches this close along the path meet. + let near = 1e-9 * self.len; + // The topmost points as stretches of distance along the path, in order. + let mut runs: Vec<(f64, f64)> = vec![]; + for (s, d, c, l) in &drawn { + let db = d.bounding_box(); + if db.y1 - db.y0 <= tol && db.y0 <= top + tol { + runs.push((*c, c + l)); + continue; + } + for t in turns(d) { + if d.eval(t).y <= top + tol { + let at = c + s.subsegment(0.0..t).arclen(ACCURACY).min(*l); + runs.push((at, at)); + } + } + } + runs.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Stretches that meet or overlap are one. + let mut merged: Vec<(f64, f64)> = vec![]; + for (a, z) in runs { + match merged.last_mut() { + Some(last) if a <= last.1 + near => last.1 = last.1.max(z), + _ => merged.push((a, z)), + } + } + let (first, last) = (*merged.first()?, *merged.last()?); + if !self.closed { + return Some((first.0 + last.1) * 0.5); + } + let len = self.len; + // Round a closed path, the shortest part holding every stretch leaves out the longest + // gap between two of them (the gap from the last round to the first included). + let wrap = first.0 + len - last.1; + let (mut gap, mut from, mut to) = (wrap, first.0, last.1); + for w in merged.windows(2) { + if let [a, b] = w + && b.0 - a.1 > gap + { + (gap, from, to) = (b.0 - a.1, b.0, a.1); + } + } + if gap <= near { + return None; + } + let run = (to - from).rem_euclid(len); + Some((from + run * 0.5).rem_euclid(len)) + } } #[cfg(test)] @@ -99,4 +171,64 @@ mod tests { assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6); assert!(ArcPath::new(&PathData::default()).is_empty()); } + + fn polyline(points: &[Point], closed: bool) -> PathData { + PathData::single(crate::SubPath::polyline(points, closed)) + } + + /// Where `topmost` puts the middle, drawn by `xf`. + fn top_point(a: &ArcPath, xf: Affine) -> Point { + xf * a.at(a.topmost(xf).unwrap()).0 + } + + #[test] + fn topmost_points_as_drawn() { + // A circle starts at 9 o'clock and runs clockwise (y down): its top is a quarter of the way round, its + // bottom three quarters. + let circle = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert!((circle.topmost(Affine::IDENTITY).unwrap() - circle.len() * 0.25).abs() < 1e-6); + assert!((circle.topmost(Affine::FLIP_Y).unwrap() - circle.len() * 0.75).abs() < 1e-6); + // As the transform draws it: turned a quarter turn the other way, 3 o'clock is at the top. + let turned = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(50.0, 50.0)); + assert!((circle.topmost(turned).unwrap() - circle.len() * 0.5).abs() < 1e-6); + // Turned part way, the top lies inside a segment. + let tilted = Affine::rotate_about(0.4, Point::new(50.0, 50.0)); + assert!(top_point(&circle, tilted).distance(Point::new(50.0, 0.0)) < 0.05, "{:?}", top_point(&circle, tilted)); + // An ellipse squashed and skewed by its transform. + let skewed = Affine::skew(0.5, 0.0) * Affine::scale_non_uniform(2.0, 0.5); + let p = top_point(&circle, skewed); + let lowest = (0..3600).map(|i| (skewed * circle.at(circle.len() * i as f64 / 3600.0).0).y).fold(f64::INFINITY, f64::min); + assert!((p.y - lowest).abs() < 1e-3, "{p:?} {lowest}"); + // A level top: the middle of the top edge, for a rectangle and a rounded rectangle. + for path in [shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), shapes::rounded_rectangle(Rect::new(10.0, 20.0, 110.0, 70.0), 8.0)] { + let a = ArcPath::new(&path); + assert!(top_point(&a, Affine::IDENTITY).distance(Point::new(60.0, 20.0)) < 1e-6, "{:?}", top_point(&a, Affine::IDENTITY)); + assert!(top_point(&a, Affine::FLIP_Y).distance(Point::new(60.0, -70.0)) < 1e-6); + } + // Far from the origin the top is still the top edge's middle. + let square = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + let far = Affine::translate((1e12, 1e12)); + assert!(top_point(&square, far).distance(Point::new(1e12 + 50.0, 1e12)) < 1e-3, "{:?}", top_point(&square, far)); + // A level top across the path's start: the start in the middle of the top edge. + let across = ArcPath::new(&polyline( + &[Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0), Point::new(0.0, 50.0), Point::new(0.0, 0.0)], + true, + )); + assert!(top_point(&across, Affine::IDENTITY).distance(Point::new(50.0, 0.0)) < 1e-6); + // Two peaks: the middle of the shortest part that holds both, the dip between them. + let peaks = ArcPath::new(&polyline( + &[Point::new(0.0, 10.0), Point::new(2.0, 0.0), Point::new(4.0, 5.0), Point::new(6.0, 0.0), Point::new(8.0, 10.0)], + true, + )); + assert!(top_point(&peaks, Affine::IDENTITY).distance(Point::new(4.0, 5.0)) < 1e-6, "{:?}", top_point(&peaks, Affine::IDENTITY)); + // An open path: the middle of its topmost points too. + let open = ArcPath::new(&polyline(&[Point::new(0.0, 10.0), Point::new(5.0, 0.0), Point::new(10.0, 10.0)], false)); + assert!(top_point(&open, Affine::IDENTITY).distance(Point::new(5.0, 0.0)) < 1e-6); + // No top: a closed path that is level all round, and a path with no length. + let flat = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 10.0, 100.0, 10.0))); + assert!(!flat.is_empty() && flat.topmost(Affine::IDENTITY).is_none()); + assert!(ArcPath::new(&PathData::default()).topmost(Affine::IDENTITY).is_none()); + let point = ArcPath::new(&polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], true)); + assert!(point.topmost(Affine::IDENTITY).is_none()); + } } diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 9c14bedca1..fbb5e4fc90 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -395,7 +395,7 @@ impl Interp { Err(e) => return Err(self.host_error(e)?), }; let writes = strict_writes().unwrap_or_else(|| self.writes_mode()); - self.evaluated = self.evaluated.saturating_add(1); + self.evaluated = crate::realm::next_tag(self.evaluated); let tag = std::mem::replace(&mut self.source_tag, self.evaluated); // UTF-8 takes at most 3 bytes for each UTF-16 code unit, so the fuel for a third of the // bytes is taken before the source is encoded, and the rest after. A source within the diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 124fe395f3..de252c6c64 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -638,6 +638,37 @@ fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unkn assert_eq!(r.error_source(), 0, "a syntax error is in the script"); } +#[test] +fn error_tags_stay_the_same_from_run_to_run_and_are_never_shared() { + let mut r = realm(10_000_000); + let source: HostFunction = Rc::new(|_| Ok(Data::String("function inEval() { throw new Error('in eval'); }".into()))); + let load = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + r.define_host_object("host", vec![("load".into(), load)]).unwrap(); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "no script yet"); + r.run("function late() { throw new Error('late'); }\nhost.load(); host.load();").unwrap(); + let first = r.script_tag(); + assert_ne!(first, UNKNOWN_SOURCE); + assert!(r.run("\nlate()").is_err()); + let second = r.script_tag(); + assert!(second > first + 2, "the evaluator's two sources took the tags after the first script"); + assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, first), "late keeps its script's tag"); + assert!(r.run("inEval()").is_err()); + assert_eq!(r.error_tag(), first + 2, "the second source the evaluator gave in the first run"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!((r.error_source(), r.error_tag()), (0, r.script_tag())); + // Once the tags are used up, a source gets no tag rather than one an earlier source has. + r.set_last_tag(UNKNOWN_SOURCE - 3); + r.run("function nearEnd() { throw 1; }\nhost.load(); host.load(); host.load();").unwrap(); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE - 2); + assert!(r.run("nearEnd()").is_err()); + assert_eq!(r.script_tag(), UNKNOWN_SOURCE, "a script after the last tag"); + assert_eq!(r.error_tag(), UNKNOWN_SOURCE - 2); + assert!(r.run("inEval()").is_err()); + assert_eq!(r.error_tag(), UNKNOWN_SOURCE, "the third source came after the last tag"); + assert!(r.run("\nthrow 1").is_err()); + assert_eq!((r.error_source(), r.error_tag()), (UNKNOWN_SOURCE, UNKNOWN_SOURCE)); +} + #[test] fn an_evaluators_source_takes_fuel_before_it_is_parsed() { // The evaluator gives 131,072 spaces and a number: far more code units than the fuel left. diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 7df9953a67..cad290f1af 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -41,9 +41,11 @@ //! With [`Options::e4x`], the parser also reads E4X (ECMA-357, second edition, December 2005): //! XML and XMLList literals, the `.@`, `..`, `::` and `.( )` operators, `for each … in` and //! `default xml namespace`; the realm then provides `XML`, `XMLList`, `Namespace`, `QName`, -//! `isXMLName` and `setDefaultXMLNamespace`. Without the option, the language and the built-ins are +//! `isXMLName` and `setDefaultXMLNamespace`, and XML and XMLList values have an `xpath` method that +//! evaluates an XPath 1.0 expression. Without the option, the language and the built-ins are //! those of ECMAScript 5.1 above. XML trees live in the heap, and parsing, copying, comparing and -//! serializing them use no native recursion and take fuel for each node. +//! serializing them, and evaluating XPath on them, use no native recursion and take fuel for each +//! node. #![forbid(unsafe_code)] pub mod ast; @@ -70,6 +72,7 @@ mod token; mod value; mod xml; mod xml_builtins; +mod xpath; pub use ast::{ AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index b1651d1291..077f3b19b6 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -63,6 +63,12 @@ pub enum LimitKind { /// evaluator's: code that `eval` or `Function` compiled, or an error with no place. pub const UNKNOWN_SOURCE: u32 = u32::MAX; +/// The source tag after `tag`. Once the tags are used up, every later source gets +/// [`UNKNOWN_SOURCE`], so no two sources share a tag. +pub(crate) fn next_tag(tag: u32) -> u32 { + if tag >= UNKNOWN_SOURCE - 1 { UNKNOWN_SOURCE } else { tag + 1 } +} + /// The fuel [`Realm::advance`] lets a slice spend, past the slice it was asked for, in a nested run /// it cannot pause. pub const MAX_UNBROKEN_FUEL: u64 = 200_000_000; @@ -151,7 +157,9 @@ pub struct Realm { unbroken_fuel: u64, /// The source tag of the script the last [`Realm::run`] or [`Realm::start`] compiled. Each /// script gets a new one, and the sources an evaluator gives during its run get the ones after - /// it, so code that an earlier script defined keeps a tag of its own. + /// it, so code that an earlier script defined keeps a tag of its own. [`UNKNOWN_SOURCE`] + /// before the first script, for a script the realm did not compile, and once the tags are used + /// up. script_tag: u32, } @@ -170,7 +178,7 @@ impl Realm { /// `options`; [`Options::max_source_units`] caps the source parsed in one run, all of it /// counted together. pub fn with_options(options: Options, limits: Limits) -> Self { - Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: 0 } + Self { interp: Interp::new(options, limits), unbroken_fuel: MAX_UNBROKEN_FUEL, script_tag: UNKNOWN_SOURCE } } /// Sets the fuel [`Realm::advance`] lets a nested run spend past its slice (by default @@ -226,6 +234,7 @@ impl Realm { /// Compiles `source` and readies the realm for a run. fn compile(&mut self, source: &str) -> Result, RunError> { + self.script_tag = UNKNOWN_SOURCE; let interp = &mut self.interp; if let Some(message) = &interp.install_error { return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); @@ -244,7 +253,7 @@ impl Realm { interp.finally_sites.clear(); interp.error_mark = None; let strict = interp.options.strict; - interp.evaluated = interp.evaluated.saturating_add(1).min(UNKNOWN_SOURCE - 1); + interp.evaluated = next_tag(interp.evaluated); self.script_tag = interp.evaluated; interp.source_tag = self.script_tag; let code = interp.encode_source(source).and_then(|units| interp.compile_source(&units, strict, CodeKind::Global)); @@ -397,6 +406,28 @@ impl Realm { } } + /// The source tag of the code the last error's `line` and `column` are in, which stays the same + /// from run to run: each script given to [`Realm::run`] or [`Realm::start`] and each source a + /// [`crate::HostItem::Evaluator`] gives gets the next tag, so a function an earlier script + /// defined has its script's tag. [`UNKNOWN_SOURCE`] for code that `eval` or `Function` + /// compiled, an error with no place, and sources given once the tags are used up. + pub fn error_tag(&self) -> u32 { + self.interp.error_tag + } + + /// The source tag ([`Realm::error_tag`]) of the script the last [`Realm::run`] or + /// [`Realm::start`] compiled; the `n`th source an evaluator gave during that run has this tag + /// plus `n`. [`UNKNOWN_SOURCE`] when that call compiled nothing. + pub fn script_tag(&self) -> u32 { + self.script_tag + } + + /// Tests: makes `tag` the last source tag given out. + #[cfg(test)] + pub(crate) fn set_last_tag(&mut self, tag: u32) { + self.interp.evaluated = tag; + } + /// The mark of the host error ([`crate::HostError::Marked`]) the last run ended with, when no /// `catch` clause took that error's exception on its way out; `None` otherwise. pub fn error_mark(&self) -> Option { diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index e1baed6c23..eed08f96bd 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -98,7 +98,7 @@ fn check_string_room(interp: &mut Interp, len: usize) -> JsResult<()> { /// Pushes `item` onto `list`, first holding the bytes of the list's new capacity when it must /// grow. -fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, item: T) -> JsResult<()> { +pub(crate) fn push_held(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec, item: T) -> JsResult<()> { if list.len() == list.capacity() { let more = list.capacity().max(4); held.add(interp, more.saturating_mul(size_of::()))?; @@ -180,7 +180,7 @@ fn clamp(n: f64, len: usize) -> usize { /// The first index at `from` or after where `needle` occurs in `hay`. Every position tried takes /// fuel. -fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult> { +pub(crate) fn find(interp: &mut Interp, hay: &[u16], needle: &[u16], from: usize) -> JsResult> { if needle.len() > hay.len() { return Ok(None); } diff --git a/crates/js/src/xml_builtins.rs b/crates/js/src/xml_builtins.rs index c74b9fac2e..9cd797f48a 100644 --- a/crates/js/src/xml_builtins.rs +++ b/crates/js/src/xml_builtins.rs @@ -14,6 +14,7 @@ use crate::value::{Str, Value}; use crate::xml::{ Access, Content, NameRef, RESERVED_NS_BINDING, STAR, Xv, canonical_index, is_ncname, is_script_xml_name, ns_binding_allowed, xml_name_writable, }; +use crate::xpath::xpath; const SETTINGS: [(&str, Value); 5] = [ ("ignoreComments", Value::Bool(false)), @@ -45,7 +46,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { method(interp, xml, "settings", settings, 0)?; method(interp, xml, "setSettings", set_settings, 1)?; method(interp, xml, "defaultSettings", default_settings, 0)?; - let xml_methods: [(&'static str, NativeFn, u32); 39] = [ + let xml_methods: [(&'static str, NativeFn, u32); 40] = [ ("addNamespace", add_namespace, 1), ("appendChild", append_child, 1), ("attribute", attribute, 1), @@ -85,6 +86,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { ("toString", to_string, 0), ("toXMLString", to_xml_string, 0), ("valueOf", value_of, 0), + ("xpath", xpath, 1), ]; for (name, f, n) in xml_methods { method(interp, xml_proto, name, f, n)?; @@ -92,7 +94,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { let list = native(interp, "XMLList", xml_list_ctor, 1, NativeKind::Plain, true)?; constructor(interp, "XMLList", list, list_proto)?; - let list_methods: [(&'static str, NativeFn, u32); 21] = [ + let list_methods: [(&'static str, NativeFn, u32); 22] = [ ("attribute", attribute, 1), ("attributes", attributes, 0), ("child", child, 1), @@ -114,6 +116,7 @@ pub(crate) fn install(interp: &mut Interp) -> JsResult<()> { ("toString", to_string, 0), ("toXMLString", to_xml_string, 0), ("valueOf", value_of, 0), + ("xpath", xpath, 1), ]; for (name, f, n) in list_methods { method(interp, list_proto, name, f, n)?; @@ -665,7 +668,7 @@ fn has_simple_content(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> /// The namespaces in scope at `x`, nearest declarations first, one for each prefix. Takes one unit /// of fuel for each node it climbs through and for each declaration it reads. -fn scope_of(interp: &mut Interp, x: Option) -> JsResult> { +pub(crate) fn scope_of(interp: &mut Interp, x: Option) -> JsResult> { let mut out: Vec = Vec::new(); let mut seen: HashSet> = HashSet::new(); let mut cur = x; diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 99a0100fe3..f9f66c8c26 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -372,7 +372,7 @@ fn the_xml_methods_behave_as_specified() { assert_eq!(string("(1).toString()"), "1"); assert_eq!(string("().toString()"), "\n \n"); truth("var x = ; x.valueOf() === x"); - truth("!Object.prototype.hasOwnProperty.call(XML.prototype, 'xpath') && !Object.prototype.hasOwnProperty.call(XMLList.prototype, 'xpath')"); + truth("typeof XML.prototype.xpath === 'function' && typeof XMLList.prototype.xpath === 'function' && XML.prototype.xpath.length === 1"); truth("var x = ; x.b.attributes !== undefined"); } @@ -1279,6 +1279,471 @@ fn an_interrupt_stops_serialization() { assert_eq!(r.run("stop(); x.toXMLString()").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); } +// ---- xpath ---- + +/// A script function that joins the local names of a list's items. +const NAMES: &str = "function names(l) { var s = ''; for each (var n in l) s += n.localName(); return s; }"; + +#[test] +fn xpath_selects_child_attribute_and_text_nodes() { + for check in [ + "xml.xpath('book').length() === 4", + "xml.xpath('book[2]/title') == 'Oz'", + "xml.xpath('book[last()]/title') == 'Opera'", + "xml.xpath('book[position() < 3]').length() === 2", + "xml.xpath('book[year > 1890]/title').length() === 3", + "xml.xpath('book[count(author) > 1]/title') == 'Alice'", + "xml.xpath('book[@category = \"CHILDREN\"]/title').length() === 2", + "xml.xpath('//author').length() === 6", + "xml.xpath('book/@category').length() === 4 && xml.xpath('book/@category')[0].nodeKind() === 'attribute'", + "xml.xpath('book[1]/title/text()')[0].nodeKind() === 'text' && xml.xpath('book[1]/title/text()') == 'Cookbook'", + "xml.xpath('book[1]/title/@lang') == 'en'", + "xml.xpath('nothing').length() === 0", + "xml.xpath('child::book[3]/child::author[2]') == 'B'", + "xml.xpath('*').length() === 4 && xml.xpath('book/node()').length() === 14", + ] { + truth(&format!("{BOOKS} {check}")); + } +} + +#[test] +fn xpath_returns_the_nodes_of_the_tree() { + truth(&format!("{BOOKS} xml.book[1].title[0].xpath('..')[0] === xml.book[1]")); + truth(&format!("{BOOKS} xml.xpath('book[2]')[0] === xml.book[1] && xml.xpath('.')[0] === xml")); + truth(&format!("{BOOKS} xml.xpath('book[1]/year')[0].setChildren('2000'); xml.book[0].year == '2000'")); + truth(&format!("{BOOKS} xml.xpath('book/@category')[1].setName('kind'); xml.book[1].@kind == 'CHILDREN'")); + truth("var x = t; x.a.text()[0].xpath('..')[0] === x.a[0]"); +} + +#[test] +fn xpath_absolute_paths_start_above_the_topmost_element() { + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/bookstore/book').length() === 4")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/*')[0] === xml")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('//title').length() === 4")); + truth(&format!("{BOOKS} xml.book[0].title[0].xpath('/title').length() === 0")); + truth("var d = ; d.e[0].xpath('/d')[0] === d"); + for src in ["().xpath('/')", "().xpath('..')", "().xpath('ancestor-or-self::node()')"] { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_walks_every_axis() { + let tree = "var x = ;"; + for (expr, want) in [ + ("x.a[0].xpath('following-sibling::*')", "df"), + ("x.f[0].xpath('preceding-sibling::*')", "ad"), + ("x.f[0].xpath('preceding-sibling::*[1]')", "d"), + ("x.d.e[0].xpath('ancestor::*')", "rd"), + ("x.d.e[0].xpath('ancestor::*[1]')", "d"), + ("x.d.e[0].xpath('ancestor-or-self::*')", "rde"), + ("x.a.b[0].xpath('following::*')", "cdef"), + ("x.d.e[0].xpath('preceding::*')", "abc"), + ("x.d.e[0].xpath('preceding::*[1]')", "c"), + ("x.xpath('descendant::*')", "abcdef"), + ("x.xpath('descendant-or-self::*')", "rabcdef"), + ("x.a[0].xpath('self::a')", "a"), + ("x.a[0].xpath('self::b')", ""), + ("x.a.b[0].xpath('parent::*')", "a"), + ("x.xpath('//*[2]')", "cd"), + ("x.xpath('(//*)[2]')", "a"), + ("x.xpath('d/e/../preceding-sibling::*/c')", "c"), + ] { + assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}"); + } + let attr = "var x = ;"; + assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('following::*'))")), "bc"); + assert_eq!(string(&format!("{NAMES} {attr} names(x.a.@p[0].xpath('ancestor::*'))")), "ra"); + truth(&format!("{attr} x.a.@p[0].xpath('following-sibling::node()').length() === 0 && x.a.@p[0].xpath('preceding::*').length() === 0")); +} + +#[test] +fn xpath_results_are_in_document_order_without_repeats() { + let tree = "var x = ;"; + for (expr, want) in [ + ("x.xpath('c | a | //b')", "abbc"), + ("x.xpath('a | a')", "a"), + ("x.xpath('//b/..')", "ra"), + ("x.xpath('//*/ancestor::*')", "ra"), + ("x.c[0].xpath('preceding::*[position() < 3]')", "bb"), + ] { + assert_eq!(string(&format!("{NAMES} {tree} names({expr})")), want, "{expr}"); + } + truth(&format!("{tree} x.xpath('c | @p')[0].nodeKind() === 'attribute' && x.xpath('c | @p').length() === 2")); +} + +#[test] +fn xpath_names_match_by_namespace_uri() { + let tree = "var x = ;"; + for check in [ + "x.xpath('p:a').length() === 2", + "x.xpath('a').length() === 1 && x.xpath('a')[0].name().uri === ''", + "x.xpath('p:*').length() === 2", + "x.xpath('*').length() === 4", + "x.xpath('*[local-name() = \"a\"]').length() === 4", + "x.xpath('*[namespace-uri() = \"urn:s\"]').length() === 1", + "x.xpath('*[name() = \"p:a\"]').length() === 1", + ] { + truth(&format!("{tree} {check}")); + } + truth("var x = ; x.xpath('a').length() === 0 && x.xpath('*').length() === 1"); + truth("var x = ; default xml namespace = 'urn:d'; x.xpath('a').length() === 1"); + truth("var x = ; x.xpath('a/@xml:lang') == 'en'"); + let rebound = "var x = ;"; + truth(&format!("{rebound} x.xpath('//p:a').length() === 1 && x.xpath('//p:a')[0].parent() === x")); + truth(&format!("{rebound} x.b[0].xpath('p:a').length() === 1 && x.b[0].xpath('/r/p:a').length() === 0")); + assert_eq!(thrown_name("().xpath('z:a')"), "TypeError"); +} + +#[test] +fn xpath_namespace_nodes_can_be_tested_but_not_returned() { + let tree = "var x = ;"; + truth(&format!("{tree} x.xpath('self::*[namespace::xml]').length() === 1")); + truth(&format!("{tree} x.xpath('a[count(namespace::*) = 3]').length() === 1")); + truth(&format!("{tree} x.xpath('a[namespace::q = \"urn:q\"]').length() === 1 && x.xpath('self::*[namespace::q]').length() === 0")); + truth(&format!("{tree} x.xpath('a[namespace::*[name() = \"p\"]/.. = .]').length() === 1")); + assert_eq!(thrown_name(&format!("{tree} x.xpath('namespace::*')")), "TypeError"); +} + +#[test] +fn xpath_variables_come_from_the_object_properties() { + truth(&format!("{BOOKS} xml.xpath('book[title = $t]/author', {{ t: 'Oz' }}) == 'Baum'")); + truth(&format!("{BOOKS} xml.xpath('book[$n]/title', {{ n: 3 }}) == 'Alice'")); + truth(&format!("{BOOKS} xml.xpath('book[$yes]', {{ yes: true }}).length() === 4 && xml.xpath('book[$no]', {{ no: false }}).length() === 0")); + truth(&format!("{BOOKS} xml.xpath('$b/title', {{ b: xml.book[1] }}) == 'Oz'")); + truth(&format!("{BOOKS} xml.xpath('$l/year', {{ l: xml.book.(@category == 'CHILDREN') }}).length() === 2")); + truth(&format!("{BOOKS} xml.xpath('book[year = $y]/title', {{ y: {{ toString: function () {{ return '1922'; }} }} }}) == 'Opera'")); + truth(&format!( + "{BOOKS} var o = {{}}; Object.defineProperty(o, 'v', {{ get: function () {{ return 'Alice'; }} }}); xml.xpath('book[title = $v]', o).length() === 1" + )); + for src in + ["().xpath('$v')", "().xpath('$v', {})", "().xpath('$v', { v: undefined })", "().xpath('a', 5)", "().xpath('a', null)"] + { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_keeps_the_items_of_a_list_variable_that_a_later_getter_empties() { + let setup = "var l = <>;"; + let action = "var o = { get v() { return l; }, get w() { delete l[0]; l = null; squeeze(100000000); return 1; } }; + var r = ().xpath('$v/b | $v[$w]', o); r.length() === 2 && r[0].localName() === 'a' && r[1].localName() === 'b'"; + assert_eq!(after_setup(setup, action), Ok(JsValue::Bool(true))); +} + +#[test] +fn xpath_functions_and_operators_follow_xpath_1() { + let tree = "var x = 12x y12.5;"; + let checks = [ + "last() = 1 and position() = 1", + "count(a) = 2", + "count(id('a')) = 0", + "local-name() = 'r' and name(a) = 'a' and namespace-uri() = ''", + "local-name(nothing) = '' and name(@xml:lang) = 'xml:lang'", + "string(a) = '1' and string() = '12x y12.5' and string(nothing) = ''", + "concat('a', 'b', 'c') = 'abc'", + "starts-with('abc', 'ab') and not(starts-with('abc', 'b'))", + "contains('abc', 'bc') and contains('abc', '') and not(contains('abc', 'd'))", + "substring-before('1999/04/01', '/') = '1999' and substring-before('abc', 'd') = ''", + "substring-after('1999/04/01', '/') = '04/01' and substring-after('abc', '') = 'abc'", + "substring('12345', 2, 3) = '234' and substring('12345', 2) = '2345'", + "substring('12345', 1.5, 2.6) = '234' and substring('12345', 0, 3) = '12'", + "substring('12345', 0 div 0, 3) = '' and substring('12345', 1, 0 div 0) = ''", + "substring('12345', -42, 1 div 0) = '12345' and substring('12345', -1 div 0, 1 div 0) = ''", + "string-length('abc') = 3 and string-length(b) = 4 and string-length() = 10", + "normalize-space(b) = 'x y' and normalize-space(' x \t y ') = 'x y' and normalize-space(' ') = ''", + "translate('bar', 'abc', 'ABC') = 'BAr' and translate('--aaa--', 'abc-', 'ABC') = 'AAA'", + "boolean(a) and not(boolean(nothing)) and boolean('x') and not(boolean('')) and not(boolean(0)) and not(boolean(0 div 0))", + "true() and not(false())", + "lang('en') and lang('EN-us') and not(lang('fr')) and a[lang('en')]", + "number(n) = 12.5 and number(' -3 ') = -3 and string(number('1e3')) = 'NaN' and string(number('+1')) = 'NaN'", + "sum(a) = 3 and sum(nothing) = 0", + "floor(2.5) = 2 and ceiling(2.1) = 3 and floor(-2.5) = -3", + "round(2.5) = 3 and round(-2.5) = -2 and round(2.4) = 2 and string(round(-0.4)) = '0'", + "string(1 div 0) = 'Infinity' and string(-1 div 0) = '-Infinity' and string(0 div 0) = 'NaN'", + "string(1.5) = '1.5' and string(2) = '2' and string(-0.25) = '-0.25' and string(1 div 3) = '0.3333333333333333'", + "string(1000000 * 1000000 * 1000000 * 1000) = '1000000000000000000000'", + "7 mod 3 = 1 and -7 mod 3 = -1 and 5 div 2 = 2.5 and 2 - 3 = -1 and --1 = 1 and -(1 + 1) = -2", + "1 + 2 * 3 = 7 and (1 + 2) * 3 = 9 and 3 > 2 > 1 = false()", + "a = 2 and a = '1' and a != 1 and a > 1 and not(a > 2) and a < 2 and 1 < a and 2 >= a", + "a = a and a != a and not(a[1] != a[1]) and not(nothing = nothing) and not(nothing != a) and a < a", + "true() = a and false() = nothing and '1' = 1 and true() = 'x' and 1 = true() and not(0 = true())", + "a > false() and not(a > true()) and nothing < true() and not(nothing >= true())", + "1 < 2 and 2 > 1 or false()", + "false() and 1 div 0 or true()", + "'a' < 'b' = false() and '1' < '2'", + "*[2] = 2 and *[last()] = 12.5", + "(a | b)[2] = 2", + ]; + for check in checks { + let expr = format!("self::*[{check}]"); + truth(&format!("{tree} x.xpath({expr:?}).length() === 1")); + } +} + +#[test] +fn xpath_throws_a_type_error_for_a_value_that_is_not_a_node_set() { + for expr in ["count(book)", "string(book)", "1", "'a'", "true()", "book = 1", "-book"] { + let src = format!("{BOOKS} try {{ xml.xpath({expr:?}); 'no error' }} catch (e) {{ e.name + ': ' + e.message }}"); + assert_eq!(string(&src), "TypeError: the XPath expression does not evaluate to a node-set", "{expr}"); + } +} + +#[test] +fn xpath_errors_name_their_cause() { + for expr in [ + "", + " ", + "/[1]", + "book[", + "book]", + "(", + ")", + "1 +", + "book/", + "@", + "foo::bar", + ".[1]", + "..[1]", + "'abc", + "$", + "book book", + "!", + "a//", + ",", + "count(", + "count(1,", + "a::", + "::a", + "a:", + "1..2", + "book[1]]", + "count(book))", + "//", + "/ /a", + "(1)(2)", + "processing-instruction(1)", + "node(1)", + "*:a", + "a:b:c", + ] { + assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "SyntaxError", "{expr:?}"); + } + for expr in + ["foo()", "count()", "count(1)", "p:count(book)", "sum('1')", "1 | book", "'a'/b", "'a'[1]", "concat('a')", "name(1)", "substring('a')"] + { + assert_eq!(thrown_name(&format!("{BOOKS} xml.xpath({expr:?})")), "TypeError", "{expr:?}"); + } + for src in ["().xpath()", "().xpath(5)", "().xpath(new String('a'))", "XML.prototype.xpath.call({}, 'a')"] { + assert_eq!(thrown_name(src), "TypeError", "{src}"); + } +} + +#[test] +fn xpath_on_an_xml_list_runs_on_each_element_in_turn() { + truth( + "var l = <>123; l.xpath('b').length() === 3 && l.xpath('b').toXMLString() === '1\\n2\\n3'", + ); + truth("var l = <>123; l.xpath('b[1]').length() === 2"); + truth("var x = ; var l = x.*; var p = l.xpath('..'); p.length() === 2 && p[0] === x && p[1] === x"); + truth("var x = t; var l = x.a.text() + x.a; l.xpath('.').length() === 1 && l.xpath('.')[0] === x.a[0]"); + truth("var x = ; (x.@p + x.comments()).xpath('.').length() === 0"); + truth("new XMLList().xpath('a').length() === 0 && new XMLList().xpath('z:a').length() === 0"); + truth("var l = <>; l.xpath('p:b').length() === 2"); + assert_eq!(thrown_name("var l = <>; l.xpath('p:b')"), "TypeError"); +} + +#[test] +fn xpath_expressions_of_any_depth_use_no_native_stack() { + let deep = format!("{}1{}", "(".repeat(100_000), ")".repeat(100_000)); + assert_eq!(thrown_name(&format!("().xpath('{deep}')")), "SyntaxError"); + let predicates = format!("{}{}", "a[".repeat(100_000), "]".repeat(100_000)); + assert_eq!(thrown_name(&format!("().xpath('{predicates}')")), "SyntaxError"); + let sum = format!("1{}", " + 1".repeat(100_000)); + truth(&format!("().xpath('self::*[{sum} = 100001]').length() === 1")); + let negated = format!("{}1", "-".repeat(100_001)); + truth(&format!("().xpath('self::*[{negated} = -1]').length() === 1")); + let union = format!("a{}", " | a".repeat(50_000)); + truth(&format!("().xpath('{union}').length() === 1")); + let mut shallow = Realm::with_options(Options { max_depth: 8, ..e4x() }, Limits::default()); + let seven = format!("{}1{}", "(".repeat(6), ")".repeat(6)); + assert_eq!(shallow.run(&format!("().xpath('self::*[{seven}]').length()")), Ok(JsValue::Number(1.0))); + let eight = format!("{}1{}", "(".repeat(8), ")".repeat(8)); + assert_eq!(shallow.run(&format!("try {{ ().xpath('{eight}') }} catch (e) {{ e.message }}")), Ok(JsValue::String("nesting too deep".into()))); +} + +#[test] +fn xpath_stops_at_the_fuel_and_heap_limits_leaving_the_tree() { + let setup = "var x = ; for (var i = 0; i < 6; i++) x.appendChild({i});"; + let check = "x.a.length() === 6 && x.a[5].b == '5' && x.*.length() === 6"; + assert!(fuel_stops(setup, "x.xpath('//b[. > 2]/preceding::*')", check) > 0); + assert!(heap_stops(setup, "x.xpath((SPARE, '//b[contains(concat(., ., .), \"3\")] | //a'))", check) > 0); + let mut r = realm(); + r.run("var x = ; for (var i = 0; i < 500; i++) x.appendChild();").unwrap(); + let interrupt = r.interrupt(); + r.define_function("stop", move |_| { + interrupt.set(); + Ok(JsValue::Undefined) + }); + assert_eq!(r.run("stop(); x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Interrupted))); +} + +#[test] +fn xpath_walks_of_deep_trees_and_long_lists_count_against_the_heap_limit() { + let deep = "var s = ''; for (var i = 0; i < 5000; i++) s += ''; s += ''; for (var i = 0; i < 5000; i++) s += ''; var x = new XML(s);"; + for (action, room) in [ + ("x.xpath('descendant::c').length()", JsValue::Number(0.0)), + ("x.xpath('self::*[string() = \"q\"]').length()", JsValue::Number(0.0)), + ("x.xpath('descendant::b | self::a').length()", JsValue::Number(2.0)), + ] { + for (spare, want) in [(20_000, Err(RunErrorKind::Limit(LimitKind::Heap))), (50_000_000, Ok(room.clone()))] { + let mut r = Realm::with_options(Options { max_depth: 1_000_000, ..e4x() }, Limits::default()); + let interp = r.interp(); + let global = interp.global; + assert!(crate::builtins::method(interp, global, "squeeze", squeeze, 1).is_ok()); + r.run(deep).unwrap(); + assert_eq!(r.run(&format!("squeeze({spare}); {action}")).map_err(|e| e.kind), want, "{action} {spare}"); + } + } + let long = "var s = ''; for (var i = 0; i < 20000; i++) s += ''; var l = new XML(s + '').a;"; + assert_eq!(after_setup(long, "squeeze(50000); l.xpath('self::c').length()"), Err(RunErrorKind::Limit(LimitKind::Heap))); + assert_eq!(after_setup(long, "squeeze(50000000); l.xpath('self::c').length()"), Ok(JsValue::Number(0.0))); +} + +#[test] +fn xpath_reading_a_long_string_takes_fuel_for_its_length() { + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var x = ; for (var i = 0; i < 200; i++) x.appendChild(y);"; + for expr in ["a[string-length($s) = 1]", "a[starts-with($s, $s) = false()]", "a[$s = concat('', 'x')]", "a[number($s) = 1]", "a[. = $s]"] { + let mut r = realm(); + r.run(setup).unwrap(); + let src = format!("x.xpath({expr:?}, {{ s: s }}).length()"); + assert_eq!(r.run(&src), Ok(JsValue::Number(0.0)), "{expr}"); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}"); + } +} + +#[test] +fn xpath_string_functions_hold_the_bytes_they_build() { + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var d = '1'; for (var i = 0; i < 20; i++) d += d; var o = { s: s, d: d };"; + for (expr, spare) in [("translate('a', 'b', $s) = 'a'", 1_000_000), ("number($d) > 1", 500_000), ("substring($s, 1) != 'z'", 4_000_000)] { + let action = |spare: u32| format!("squeeze({spare}); ().xpath(\"self::*[{expr}]\", o).length()"); + assert_eq!(after_setup(setup, &action(spare)), Err(RunErrorKind::Limit(LimitKind::Heap)), "{expr}"); + assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0)), "{expr}"); + } +} + +#[test] +fn xpath_string_variables_hold_the_bytes_of_their_flat_copy() { + // The setup ends with `0` so that its completion value does not flatten `s`. + let setup = "var s = 'x'; for (var i = 0; i < 20; i++) s += s; var o = { s: s }; 0;"; + let action = |spare: u32| format!("squeeze({spare}); ().xpath('self::*[$s]', o).length()"); + assert_eq!(after_setup(setup, &action(1_000_000)), Err(RunErrorKind::Limit(LimitKind::Heap))); + assert_eq!(after_setup(setup, &action(100_000_000)), Ok(JsValue::Number(1.0))); +} + +#[test] +fn xpath_substring_searches_take_fuel_for_every_position_tried() { + let setup = "var h = 'a'; for (var i = 0; i < 16; i++) h += h; var n = h.substring(0, 4095) + 'b'; var o = { h: h, n: n };"; + for expr in ["contains($h, $n)", "substring-before($h, $n) = ''", "substring-after($h, $n) = ''"] { + let mut r = realm(); + r.run(setup).unwrap(); + let src = format!("().xpath(\"self::*[{expr}]\", o).length()"); + let want = if expr.starts_with("contains") { 0.0 } else { 1.0 }; + assert_eq!(r.run(&src), Ok(JsValue::Number(want)), "{expr}"); + r.set_limits(Limits { fuel: 5_000_000, ..Limits::default() }); + assert_eq!(r.run(&src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel)), "{expr}"); + } +} + +#[test] +fn xpath_lang_takes_fuel_for_every_attribute_it_reads() { + let setup = "var s = '');"; + let mut r = realm(); + r.run(setup).unwrap(); + let src = "x.xpath('a[lang(\"en\")]').length()"; + assert_eq!(r.run(src), Ok(JsValue::Number(0.0))); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run(src).map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel))); +} + +#[test] +fn xpath_on_a_large_tree_ends_within_the_default_limits() { + let setup = "var x = ; for (var i = 0; i < 3000; i++) x.appendChild();"; + truth(&format!("{setup} x.xpath('//a[@n = 2999]/b').length() === 1 && x.xpath('//b/..').length() === 3000")); + let mut r = realm(); + r.run("var x = ; for (var i = 0; i < 2000; i++) x.appendChild();").unwrap(); + r.set_limits(Limits { fuel: 2_000_000, ..Limits::default() }); + assert_eq!(r.run("x.xpath('//a/following::a')").map_err(|e| e.kind), Err(RunErrorKind::Limit(LimitKind::Fuel))); +} + +const XPATH_PIECES: &[&str] = &[ + "/", + "//", + "a", + "b", + "*", + "@", + "@*", + ".", + "..", + "[", + "]", + "(", + ")", + "1", + "'x'", + "$v", + "|", + "+", + "-", + "=", + "!=", + "<", + ">=", + " and ", + " or ", + " div ", + " mod ", + "child::", + "following::", + "preceding::", + "ancestor::", + "namespace::", + "node()", + "text()", + "count(", + "last()", + "position()", + "concat(", + "substring(", + ",", + "p:", + "p:*", + "xml:lang", + "::", + ":", + "'", + " ", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn random_xpath_expressions_select_nodes_or_throw(parts in proptest::collection::vec(0..XPATH_PIECES.len(), 0..30)) { + let expr: String = parts.iter().map(|&i| XPATH_PIECES[i]).collect(); + let quoted = format!("{expr:?}"); + let src = format!("var x = 23t; typeof XML.prototype.xpath === 'function' && x.a[0].xpath({quoted}, {{ v: 1 }}).length() >= 0"); + let mut r = limited(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + match r.run(&src) { + Ok(v) => prop_assert_eq!(v, JsValue::Bool(true), "{}", expr), + Err(e) => prop_assert!(matches!(e.kind, RunErrorKind::Thrown | RunErrorKind::Limit(_)), "{expr:?}: {e:?}"), + } + } +} + // ---- generated input ---- const PIECES: &[&str] = &[ diff --git a/crates/js/src/xpath.rs b/crates/js/src/xpath.rs new file mode 100644 index 0000000000..30707e4de0 --- /dev/null +++ b/crates/js/src/xpath.rs @@ -0,0 +1,2377 @@ +//! `xpath` on XML and XMLList values: XPath 1.0 expressions +//! () evaluated on E4X trees. +//! +//! An expression is read into tokens, then into a syntax tree whose nodes live in one arena, and is +//! evaluated with an explicit task stack, so neither step uses native recursion. Brackets nest at +//! most [`crate::Options::max_depth`] deep. Every token, task and visited node takes fuel, and the +//! token, tree, node-set and string buffers are held against the heap limit while they live. +//! +//! `/` is a root node above the topmost node of the context node's tree. A node-set result is +//! returned as an XMLList of the selected nodes themselves, in document order with attributes +//! before children. The root node and namespace nodes can be selected and tested inside an +//! expression, but a result that holds one throws a `TypeError`, since E4X has no such value. The +//! tree keeps no DTD, so `id()` selects nothing. Prefixes in the expression are read from the +//! namespaces in scope on the context element, and `xml` is always bound. + +use std::collections::{HashMap, HashSet}; +use std::mem::replace; +use std::rc::Rc; + +use crate::builtins::arg; +use crate::error::TOO_DEEP; +use crate::heap::{Key, ObjId, XmlKind, XmlName, XmlNs, key}; +use crate::interp::{Abrupt, ErrorType, HeldBytes, Interp, JsResult}; +use crate::regexp_vm::use_fuel_n; +use crate::string_builtins::{self, charge_units, flat_charged, push_held}; +use crate::value::Value; +use crate::xml::{Xv, is_name_part, is_name_start, is_xml_space, valid_xml_units}; +use crate::xml_builtins::scope_of; + +const XML_URI: &str = "http://www.w3.org/XML/1998/namespace"; +const COLON: u16 = b':' as u16; +const STAR: u16 = b'*' as u16; +const SLASH: u16 = b'/' as u16; +const DOT: u16 = b'.' as u16; +const EQUALS: u16 = b'=' as u16; +const DASH: u16 = b'-' as u16; +/// The estimated bytes of a syntax tree node's own lists, beyond its slot in the arena. +const EXPR_BYTES: usize = 64; +/// The estimated bytes of one entry of a hash map or set the evaluator keeps. +const ENTRY_BYTES: usize = 64; + +fn empty() -> Key { + Rc::from(Vec::new()) +} + +fn syntax(interp: &mut Interp, message: &str) -> Abrupt { + interp.throw(ErrorType::Syntax, message) +} + +fn internal() -> Abrupt { + Abrupt::Internal("XPath evaluation state".into()) +} + +fn is_digit(c: u16) -> bool { + (0x30..=0x39).contains(&c) +} + +// ---- tokens ---- + +#[derive(Clone, Copy, PartialEq)] +enum Punct { + LParen, + RParen, + LBracket, + RBracket, + Dot, + DotDot, + At, + Comma, + ColonColon, + Slash, + SlashSlash, + Pipe, + Plus, + Minus, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +/// A token as the lexer reads it, before names and `*` are told apart by their position. +enum Raw { + Punct(Punct), + Star, + Name(Option, Key), + PrefixStar(Key), + Literal(Key), + Number(f64), + Var(Key), +} + +#[derive(Clone, Copy, PartialEq)] +enum Axis { + Ancestor, + AncestorOrSelf, + Attribute, + Child, + Descendant, + DescendantOrSelf, + Following, + FollowingSibling, + Namespace, + Parent, + Preceding, + PrecedingSibling, + Itself, +} + +const AXES: [(&str, Axis); 13] = [ + ("ancestor", Axis::Ancestor), + ("ancestor-or-self", Axis::AncestorOrSelf), + ("attribute", Axis::Attribute), + ("child", Axis::Child), + ("descendant", Axis::Descendant), + ("descendant-or-self", Axis::DescendantOrSelf), + ("following", Axis::Following), + ("following-sibling", Axis::FollowingSibling), + ("namespace", Axis::Namespace), + ("parent", Axis::Parent), + ("preceding", Axis::Preceding), + ("preceding-sibling", Axis::PrecedingSibling), + ("self", Axis::Itself), +]; + +impl Axis { + /// Whether the axis lists its nodes in reverse document order. + fn reverse(self) -> bool { + matches!(self, Axis::Ancestor | Axis::AncestorOrSelf | Axis::Preceding | Axis::PrecedingSibling) + } +} + +/// A node test. A prefix is resolved when the expression runs. +#[derive(Clone)] +enum Test { + Any, + Prefix(Key), + Name(Option, Key), + Node, + Text, + Comment, + Pi(Option), +} + +#[derive(Clone, Copy, PartialEq)] +enum Bin { + Or, + And, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + Add, + Sub, + Mul, + Div, + Mod, + Union, +} + +impl Bin { + fn precedence(self) -> u8 { + match self { + Bin::Or => 1, + Bin::And => 2, + Bin::Eq | Bin::Ne => 3, + Bin::Lt | Bin::Le | Bin::Gt | Bin::Ge => 4, + Bin::Add | Bin::Sub => 5, + Bin::Mul | Bin::Div | Bin::Mod => 6, + Bin::Union => 8, + } + } +} + +/// The precedence of unary minus: tighter than `*`, looser than `|`. +const NEG_PRECEDENCE: u8 = 7; + +#[derive(Clone, Copy, PartialEq)] +enum Func { + Last, + Position, + Count, + Id, + LocalName, + NamespaceUri, + Name, + String, + Concat, + StartsWith, + Contains, + SubstringBefore, + SubstringAfter, + Substring, + StringLength, + NormalizeSpace, + Translate, + Boolean, + Not, + True, + False, + Lang, + Number, + Sum, + Floor, + Ceiling, + Round, +} + +/// The core function library (XPath 1.0 section 4): each name, and its least and most arguments. +const FUNCS: [(&str, Func, usize, usize); 27] = [ + ("last", Func::Last, 0, 0), + ("position", Func::Position, 0, 0), + ("count", Func::Count, 1, 1), + ("id", Func::Id, 1, 1), + ("local-name", Func::LocalName, 0, 1), + ("namespace-uri", Func::NamespaceUri, 0, 1), + ("name", Func::Name, 0, 1), + ("string", Func::String, 0, 1), + ("concat", Func::Concat, 2, usize::MAX), + ("starts-with", Func::StartsWith, 2, 2), + ("contains", Func::Contains, 2, 2), + ("substring-before", Func::SubstringBefore, 2, 2), + ("substring-after", Func::SubstringAfter, 2, 2), + ("substring", Func::Substring, 2, 3), + ("string-length", Func::StringLength, 0, 1), + ("normalize-space", Func::NormalizeSpace, 0, 1), + ("translate", Func::Translate, 3, 3), + ("boolean", Func::Boolean, 1, 1), + ("not", Func::Not, 1, 1), + ("true", Func::True, 0, 0), + ("false", Func::False, 0, 0), + ("lang", Func::Lang, 1, 1), + ("number", Func::Number, 0, 1), + ("sum", Func::Sum, 1, 1), + ("floor", Func::Floor, 1, 1), + ("ceiling", Func::Ceiling, 1, 1), + ("round", Func::Round, 1, 1), +]; + +/// A token the parser reads: names and `*` with their roles decided (XPath 1.0 section 3.7). +enum Tok { + Punct(Punct), + Op(Bin), + Test(Test), + Axis(Axis), + Func(Func, &'static str), + Literal(Key), + Number(f64), + Var(Key), +} + +fn ncname_end(src: &[u16], at: usize) -> Option { + let first = *src.get(at)?; + if first == COLON || !is_name_start(first) { + return None; + } + let mut end = at + 1; + while src.get(end).is_some_and(|&c| c != COLON && is_name_part(c)) { + end += 1; + } + valid_xml_units(src.get(at..end)?).then_some(end) +} + +fn lex_name(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let end = ncname_end(src, at)?; + let first: Key = Rc::from(src.get(at..end)?); + if src.get(end) != Some(&COLON) || src.get(end + 1) == Some(&COLON) { + return Some((Raw::Name(None, first), end)); + } + if src.get(end + 1) == Some(&STAR) { + return Some((Raw::PrefixStar(first), end + 2)); + } + let local_end = ncname_end(src, end + 1)?; + Some((Raw::Name(Some(first), Rc::from(src.get(end + 1..local_end)?)), local_end)) +} + +fn lex_number(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let mut end = at; + while src.get(end).copied().is_some_and(is_digit) { + end += 1; + } + if src.get(end) == Some(&DOT) { + end += 1; + while src.get(end).copied().is_some_and(is_digit) { + end += 1; + } + } + let text = String::from_utf16(src.get(at..end)?).ok()?; + Some((Raw::Number(text.parse().ok()?), end)) +} + +fn lex_literal(src: &[u16], at: usize, quote: u16) -> Option<(Raw, usize)> { + let rest = src.get(at + 1..)?; + let len = rest.iter().position(|&c| c == quote)?; + Some((Raw::Literal(Rc::from(rest.get(..len)?)), at + len + 2)) +} + +fn lex_var(src: &[u16], at: usize) -> Option<(Raw, usize)> { + let (Raw::Name(..), end) = lex_name(src, at + 1)? else { return None }; + Some((Raw::Var(Rc::from(src.get(at + 1..end)?)), end)) +} + +/// The token that starts at `src[at]`, and the offset after it. +fn lex_one(src: &[u16], at: usize, c: u16) -> Option<(Raw, usize)> { + if c != COLON && is_name_start(c) { + return lex_name(src, at); + } + let next = src.get(at + 1).copied(); + let p = |p: Punct, n: usize| Some((Raw::Punct(p), at + n)); + match char::from_u32(u32::from(c))? { + '(' => p(Punct::LParen, 1), + ')' => p(Punct::RParen, 1), + '[' => p(Punct::LBracket, 1), + ']' => p(Punct::RBracket, 1), + '@' => p(Punct::At, 1), + ',' => p(Punct::Comma, 1), + '|' => p(Punct::Pipe, 1), + '+' => p(Punct::Plus, 1), + '-' => p(Punct::Minus, 1), + '=' => p(Punct::Eq, 1), + '*' => Some((Raw::Star, at + 1)), + '/' if next == Some(SLASH) => p(Punct::SlashSlash, 2), + '/' => p(Punct::Slash, 1), + ':' if next == Some(COLON) => p(Punct::ColonColon, 2), + '!' if next == Some(EQUALS) => p(Punct::Ne, 2), + '<' if next == Some(EQUALS) => p(Punct::Le, 2), + '<' => p(Punct::Lt, 1), + '>' if next == Some(EQUALS) => p(Punct::Ge, 2), + '>' => p(Punct::Gt, 1), + '.' if next == Some(DOT) => p(Punct::DotDot, 2), + '.' if next.is_some_and(is_digit) => lex_number(src, at), + '.' => p(Punct::Dot, 1), + '"' | '\'' => lex_literal(src, at, c), + '$' => lex_var(src, at), + _ if is_digit(c) => lex_number(src, at), + _ => None, + } +} + +fn lex(interp: &mut Interp, held: &mut HeldBytes, src: &[u16]) -> JsResult> { + let mut out = Vec::new(); + let mut at = 0; + loop { + while src.get(at).copied().is_some_and(is_xml_space) { + at += 1; + } + let Some(&c) = src.get(at) else { return Ok(out) }; + let Some((raw, end)) = lex_one(src, at, c) else { + return Err(syntax(interp, &format!("the XPath expression cannot be read at offset {at}"))); + }; + charge_units(interp, end - at)?; + push_held(interp, held, &mut out, raw)?; + at = end; + } +} + +fn units_are(units: &[u16], text: &str) -> bool { + units.iter().copied().eq(text.encode_utf16()) +} + +fn operator_name(name: &[u16]) -> Option { + [("and", Bin::And), ("or", Bin::Or), ("mod", Bin::Mod), ("div", Bin::Div)].into_iter().find(|(n, _)| units_are(name, n)).map(|(_, b)| b) +} + +fn punct_token(p: Punct) -> Tok { + match p { + Punct::Pipe => Tok::Op(Bin::Union), + Punct::Plus => Tok::Op(Bin::Add), + Punct::Minus => Tok::Op(Bin::Sub), + Punct::Eq => Tok::Op(Bin::Eq), + Punct::Ne => Tok::Op(Bin::Ne), + Punct::Lt => Tok::Op(Bin::Lt), + Punct::Le => Tok::Op(Bin::Le), + Punct::Gt => Tok::Op(Bin::Gt), + Punct::Ge => Tok::Op(Bin::Ge), + other => Tok::Punct(other), + } +} + +/// Whether a token at this point is an operator: there is a token before it, and it is not `@`, +/// `::`, `(`, `[`, `,` or an operator (XPath 1.0 section 3.7). +fn operator_position(last: Option<&Tok>) -> bool { + last.is_some_and(|t| { + !matches!( + t, + Tok::Punct(Punct::LParen | Punct::LBracket | Punct::At | Punct::Comma | Punct::Slash | Punct::SlashSlash) + | Tok::Op(_) + | Tok::Axis(_) + | Tok::Func(..) + ) + }) +} + +/// A name followed by `(`: a node type test, which also takes its `)`, or a function call. +/// Returns the token and how many raw tokens it takes. +fn call_or_node_type(interp: &mut Interp, prefix: Option<&Key>, name: &Key, after: &[Raw]) -> JsResult<(Tok, usize)> { + if prefix.is_none() { + let plain = [("node", Test::Node), ("text", Test::Text), ("comment", Test::Comment)].into_iter().find(|(n, _)| units_are(name, n)); + if let Some((_, test)) = plain { + return match after.first() { + Some(Raw::Punct(Punct::RParen)) => Ok((Tok::Test(test), 3)), + _ => Err(syntax(interp, "an XPath node type test takes no argument")), + }; + } + if units_are(name, "processing-instruction") { + return match after { + [Raw::Punct(Punct::RParen), ..] => Ok((Tok::Test(Test::Pi(None)), 3)), + [Raw::Literal(target), Raw::Punct(Punct::RParen), ..] => Ok((Tok::Test(Test::Pi(Some(target.clone()))), 4)), + _ => Err(syntax(interp, "processing-instruction() takes one string literal or none")), + }; + } + } + let found = FUNCS.iter().find(|(n, ..)| prefix.is_none() && units_are(name, n)); + match found { + Some(&(n, f, ..)) => Ok((Tok::Func(f, n), 2)), + None => { + let shown = String::from_utf16_lossy(name); + Err(interp.type_error(&format!("{shown} is not an XPath 1.0 function"))) + } + } +} + +fn classify(interp: &mut Interp, held: &mut HeldBytes, raw: &[Raw]) -> JsResult> { + let mut out: Vec = Vec::new(); + let mut at = 0; + while let Some(r) = raw.get(at) { + interp.use_fuel()?; + let operator = operator_position(out.last()); + let next = raw.get(at + 1); + let (tok, used) = match r { + Raw::Star if operator => (Tok::Op(Bin::Mul), 1), + Raw::Star => (Tok::Test(Test::Any), 1), + Raw::Name(None, n) if operator => match operator_name(n) { + Some(op) => (Tok::Op(op), 1), + None => return Err(syntax(interp, "an XPath operator is missing")), + }, + Raw::Name(p, n) if matches!(next, Some(Raw::Punct(Punct::LParen))) => { + call_or_node_type(interp, p.as_ref(), n, raw.get(at + 2..).unwrap_or_default())? + } + Raw::Name(p, n) if matches!(next, Some(Raw::Punct(Punct::ColonColon))) => { + let axis = AXES.iter().find(|(a, _)| p.is_none() && units_are(n, a)).map(|&(_, a)| a); + match axis { + Some(a) => (Tok::Axis(a), 2), + None => return Err(syntax(interp, &format!("{} is not an XPath axis", String::from_utf16_lossy(n)))), + } + } + Raw::Name(p, n) => (Tok::Test(Test::Name(p.clone(), n.clone())), 1), + Raw::PrefixStar(p) => (Tok::Test(Test::Prefix(p.clone())), 1), + Raw::Punct(Punct::ColonColon) => return Err(syntax(interp, "an XPath axis name is missing before ::")), + Raw::Punct(p) => (punct_token(*p), 1), + Raw::Literal(s) => (Tok::Literal(s.clone()), 1), + Raw::Number(n) => (Tok::Number(*n), 1), + Raw::Var(v) => (Tok::Var(v.clone()), 1), + }; + push_held(interp, held, &mut out, tok)?; + at += used; + } + Ok(out) +} + +// ---- the syntax tree ---- + +struct Step { + axis: Axis, + test: Test, + preds: Vec, +} + +enum Expr { + Bin(Bin, usize, usize), + Neg(usize), + Num(f64), + Lit(Key), + Var(usize), + Call(Func, Vec), + /// A location path, or a filter expression with its predicates, followed by steps. + Path { + filter: Option<(usize, Vec)>, + absolute: bool, + steps: Vec, + }, +} + +/// A parsed expression: the tree in its arena, its root, and the variables and prefixes it names. +struct Parsed { + exprs: Vec, + root: usize, + vars: Vec, + prefixes: Vec, + _held: HeldBytes, +} + +#[derive(Clone, Copy)] +enum Op { + Bin(Bin), + Neg, +} + +impl Op { + fn precedence(self) -> u8 { + match self { + Op::Bin(b) => b.precedence(), + Op::Neg => NEG_PRECEDENCE, + } + } +} + +/// A path expression being read. +#[derive(Default)] +struct Operand { + filter: Option<(usize, Vec)>, + absolute: bool, + steps: Vec, + /// The last step is `.` or `..`, which take no predicate. + abbreviated: bool, +} + +impl Operand { + fn bare_root(&self) -> bool { + self.absolute && self.steps.is_empty() && self.filter.is_none() + } +} + +enum FrameKind { + Top, + Group, + Pred, + Args(Func, &'static str, Vec), +} + +/// One bracket level: its operators and operands, read by operator precedence. +struct Frame { + kind: FrameKind, + ops: Vec, + vals: Vec, + operand: Option, +} + +impl Frame { + fn new(kind: FrameKind) -> Self { + Frame { kind, ops: Vec::new(), vals: Vec::new(), operand: None } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Want { + Operand, + Step, + Test(Axis), + After, +} + +struct Parser<'i> { + interp: &'i mut Interp, + held: HeldBytes, + exprs: Vec, + vars: Vec, + var_index: HashMap, + prefixes: HashSet, + frames: Vec, + want: Want, +} + +impl Parser<'_> { + fn error(&mut self, message: &str) -> Abrupt { + syntax(self.interp, message) + } + + fn push_expr(&mut self, e: Expr) -> JsResult { + self.held.add(self.interp, EXPR_BYTES)?; + push_held(self.interp, &mut self.held, &mut self.exprs, e)?; + Ok(self.exprs.len() - 1) + } + + fn top(&mut self) -> JsResult<&mut Frame> { + self.frames.last_mut().ok_or_else(internal) + } + + fn operand(&mut self) -> JsResult<&mut Operand> { + self.top()?.operand.as_mut().ok_or_else(internal) + } + + fn open(&mut self, kind: FrameKind) -> JsResult<()> { + if self.frames.len() >= self.interp.options.max_depth { + return Err(self.error(TOO_DEEP)); + } + self.held.add(self.interp, size_of::())?; + self.frames.push(Frame::new(kind)); + self.want = Want::Operand; + Ok(()) + } + + fn token(&mut self, tok: Tok) -> JsResult<()> { + match self.want { + Want::Operand => self.operand_token(tok), + Want::Step => self.step_token(tok), + Want::Test(axis) => match tok { + Tok::Test(test) => self.push_step(axis, test, false), + _ => Err(self.error("an XPath node test is missing")), + }, + Want::After => self.after_token(tok), + } + } + + fn primary(&mut self, e: Expr) -> JsResult<()> { + let id = self.push_expr(e)?; + self.top()?.operand = Some(Operand { filter: Some((id, Vec::new())), ..Operand::default() }); + self.want = Want::After; + Ok(()) + } + + fn operand_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Op(Bin::Sub) => { + self.top()?.ops.push(Op::Neg); + Ok(()) + } + Tok::Punct(Punct::Slash) => { + self.top()?.operand = Some(Operand { absolute: true, ..Operand::default() }); + self.want = Want::Step; + Ok(()) + } + Tok::Punct(Punct::SlashSlash) => { + self.top()?.operand = Some(Operand { absolute: true, ..Operand::default() }); + self.descendants_step() + } + Tok::Literal(s) => self.primary(Expr::Lit(s)), + Tok::Number(n) => self.primary(Expr::Num(n)), + Tok::Var(name) => { + let next = self.vars.len(); + let at = *self.var_index.entry(name.clone()).or_insert(next); + if at == next { + push_held(self.interp, &mut self.held, &mut self.vars, name)?; + } + self.primary(Expr::Var(at)) + } + Tok::Func(f, n) => self.open(FrameKind::Args(f, n, Vec::new())), + Tok::Punct(Punct::LParen) => self.open(FrameKind::Group), + Tok::Punct(Punct::RParen) if self.empty_call()? => self.close_paren(), + tok @ (Tok::Test(_) | Tok::Axis(_) | Tok::Punct(Punct::At | Punct::Dot | Punct::DotDot)) => { + self.top()?.operand = Some(Operand::default()); + self.step_token(tok) + } + _ => Err(self.error("an XPath operand is missing")), + } + } + + /// Whether the open bracket is a function call's, with nothing in it yet. + fn empty_call(&mut self) -> JsResult { + let f = self.top()?; + Ok(matches!(&f.kind, FrameKind::Args(_, _, args) if args.is_empty()) && f.ops.is_empty() && f.vals.is_empty() && f.operand.is_none()) + } + + fn descendants_step(&mut self) -> JsResult<()> { + self.operand()?.steps.push(Step { axis: Axis::DescendantOrSelf, test: Test::Node, preds: Vec::new() }); + self.want = Want::Step; + Ok(()) + } + + fn push_step(&mut self, axis: Axis, test: Test, abbreviated: bool) -> JsResult<()> { + if let Test::Prefix(p) | Test::Name(Some(p), _) = &test + && !self.prefixes.contains(p) + { + self.held.add(self.interp, ENTRY_BYTES)?; + self.prefixes.insert(p.clone()); + } + self.held.add(self.interp, size_of::())?; + let operand = self.operand()?; + operand.steps.push(Step { axis, test, preds: Vec::new() }); + operand.abbreviated = abbreviated; + self.want = Want::After; + Ok(()) + } + + fn step_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Punct(Punct::Dot) => self.push_step(Axis::Itself, Test::Node, true), + Tok::Punct(Punct::DotDot) => self.push_step(Axis::Parent, Test::Node, true), + Tok::Punct(Punct::At) => { + self.want = Want::Test(Axis::Attribute); + Ok(()) + } + Tok::Axis(a) => { + self.want = Want::Test(a); + Ok(()) + } + Tok::Test(t) => self.push_step(Axis::Child, t, false), + tok if self.operand()?.bare_root() => { + self.want = Want::After; + self.after_token(tok) + } + _ => Err(self.error("an XPath step is missing")), + } + } + + fn after_token(&mut self, tok: Tok) -> JsResult<()> { + match tok { + Tok::Punct(p @ (Punct::LBracket | Punct::Slash | Punct::SlashSlash)) => { + let operand = self.operand()?; + let (bare, abbreviated) = (operand.bare_root(), operand.abbreviated); + if bare || (p == Punct::LBracket && abbreviated) { + return Err(self.error("an XPath predicate or step cannot follow here")); + } + match p { + Punct::LBracket => self.open(FrameKind::Pred), + Punct::Slash => { + self.want = Want::Step; + Ok(()) + } + _ => self.descendants_step(), + } + } + Tok::Op(b) => self.binary(b), + Tok::Punct(Punct::RParen) => self.close_paren(), + Tok::Punct(Punct::RBracket) => self.close_bracket(), + Tok::Punct(Punct::Comma) => self.next_argument(), + _ => Err(self.error("an XPath operator is missing")), + } + } + + fn finish_operand(&mut self, operand: Operand) -> JsResult { + match operand { + Operand { filter: Some((e, preds)), steps, .. } if preds.is_empty() && steps.is_empty() => Ok(e), + Operand { filter, absolute, steps, .. } => self.push_expr(Expr::Path { filter, absolute, steps }), + } + } + + fn reduce_one(&mut self, frame: &mut Frame) -> JsResult<()> { + let op = frame.ops.pop().ok_or_else(internal)?; + let rhs = frame.vals.pop().ok_or_else(internal)?; + let e = match op { + Op::Neg => Expr::Neg(rhs), + Op::Bin(b) => Expr::Bin(b, frame.vals.pop().ok_or_else(internal)?, rhs), + }; + let id = self.push_expr(e)?; + frame.vals.push(id); + Ok(()) + } + + fn binary(&mut self, b: Bin) -> JsResult<()> { + let mut frame = self.frames.pop().ok_or_else(internal)?; + let result = self.binary_in(&mut frame, b); + self.frames.push(frame); + result?; + self.want = Want::Operand; + Ok(()) + } + + fn binary_in(&mut self, frame: &mut Frame, b: Bin) -> JsResult<()> { + let operand = frame.operand.take().ok_or_else(internal)?; + let e = self.finish_operand(operand)?; + frame.vals.push(e); + while frame.ops.last().is_some_and(|op| op.precedence() >= b.precedence()) { + self.interp.use_fuel()?; + self.reduce_one(frame)?; + } + self.held.add(self.interp, 2 * size_of::())?; + frame.ops.push(Op::Bin(b)); + Ok(()) + } + + /// Ends the innermost bracket and returns its kind and expression. + fn close(&mut self) -> JsResult<(FrameKind, usize)> { + if self.frames.len() < 2 { + return Err(self.error("the XPath expression has an unmatched closing bracket")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let e = self.reduce(&mut frame)?; + Ok((frame.kind, e)) + } + + fn reduce(&mut self, frame: &mut Frame) -> JsResult { + let operand = frame.operand.take().ok_or_else(|| syntax(self.interp, "an XPath operand is missing"))?; + let e = self.finish_operand(operand)?; + frame.vals.push(e); + while !frame.ops.is_empty() { + self.interp.use_fuel()?; + self.reduce_one(frame)?; + } + match frame.vals.as_slice() { + [e] => Ok(*e), + _ => Err(internal()), + } + } + + fn close_paren(&mut self) -> JsResult<()> { + let (kind, e) = if self.top()?.operand.is_none() { + (self.frames.pop().ok_or_else(internal)?.kind, None) + } else { + let (kind, e) = self.close()?; + (kind, Some(e)) + }; + match kind { + FrameKind::Group => self.primary_id(e.ok_or_else(internal)?), + FrameKind::Args(f, name, mut args) => { + args.extend(e); + let (_, _, least, most) = FUNCS.iter().find(|(_, g, ..)| *g == f).copied().ok_or_else(internal)?; + if args.len() < least || args.len() > most { + return Err(self.interp.type_error(&format!("the XPath function {name} takes the wrong number of arguments"))); + } + let call = self.push_expr(Expr::Call(f, args))?; + self.primary_id(call) + } + _ => Err(self.error("the XPath expression has an unmatched )")), + } + } + + fn primary_id(&mut self, id: usize) -> JsResult<()> { + if self.frames.is_empty() { + return Err(self.error("the XPath expression has an unmatched )")); + } + self.top()?.operand = Some(Operand { filter: Some((id, Vec::new())), ..Operand::default() }); + self.want = Want::After; + Ok(()) + } + + fn close_bracket(&mut self) -> JsResult<()> { + let (kind, e) = self.close()?; + if !matches!(kind, FrameKind::Pred) { + return Err(self.error("the XPath expression has an unmatched ]")); + } + let operand = self.operand()?; + match (operand.steps.last_mut(), operand.filter.as_mut()) { + (Some(step), _) => step.preds.push(e), + (None, Some((_, preds))) => preds.push(e), + (None, None) => return Err(internal()), + } + self.want = Want::After; + Ok(()) + } + + fn next_argument(&mut self) -> JsResult<()> { + if !matches!(self.top()?.kind, FrameKind::Args(..)) || self.frames.len() < 2 { + return Err(self.error("a comma in an XPath expression must separate function arguments")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let e = self.reduce(&mut frame); + if let (Ok(e), FrameKind::Args(_, _, args)) = (&e, &mut frame.kind) { + args.push(*e); + } + frame.ops.clear(); + frame.vals.clear(); + self.frames.push(frame); + e?; + self.want = Want::Operand; + Ok(()) + } + + fn finish(mut self) -> JsResult { + let complete = self.want == Want::After || (self.want == Want::Step && self.operand()?.bare_root()); + if !complete || self.frames.len() != 1 { + return Err(self.error("the XPath expression ends early")); + } + let mut frame = self.frames.pop().ok_or_else(internal)?; + let root = self.reduce(&mut frame)?; + Ok(Parsed { exprs: self.exprs, root, vars: self.vars, prefixes: self.prefixes.into_iter().collect(), _held: self.held }) + } +} + +fn parse(interp: &mut Interp, src: &[u16]) -> JsResult { + let mut held = HeldBytes::new(interp); + let raw = lex(interp, &mut held, src)?; + let toks = classify(interp, &mut held, &raw)?; + drop(raw); + let mut p = Parser { + interp, + held, + exprs: Vec::new(), + vars: Vec::new(), + var_index: HashMap::new(), + prefixes: HashSet::new(), + frames: vec![Frame::new(FrameKind::Top)], + want: Want::Operand, + }; + for tok in toks { + p.interp.use_fuel()?; + p.token(tok)?; + } + p.finish() +} + +// ---- values ---- + +/// A node of the XPath data model. `Root` is the root above the topmost node it names, and `Ns` +/// is a namespace node of `owner`, at position `at` among its namespaces. +#[derive(Clone, PartialEq, Eq, Hash)] +enum XNode { + Root(ObjId), + Node(ObjId), + Ns { owner: ObjId, at: u32, prefix: Key, uri: Key }, +} + +#[derive(Clone, Copy, PartialEq)] +enum NodeKind { + Root, + Ns, + Xml(XmlKind), +} + +/// A list of nodes whose buffer is held against the heap limit. +struct Nodes { + list: Vec, + held: HeldBytes, +} + +impl Nodes { + fn new(interp: &Interp) -> Self { + Nodes { list: Vec::new(), held: HeldBytes::new(interp) } + } + + fn push(&mut self, interp: &mut Interp, n: XNode) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.list, n) + } +} + +/// A string value whose units are held against the heap limit when the evaluator built them. +struct Text { + units: Key, + _held: HeldBytes, +} + +impl Text { + fn plain(interp: &Interp, units: Key) -> Self { + Text { units, _held: HeldBytes::new(interp) } + } +} + +struct TextBuf { + units: Vec, + held: HeldBytes, +} + +impl TextBuf { + fn new(interp: &Interp) -> Self { + TextBuf { units: Vec::new(), held: HeldBytes::new(interp) } + } + + fn push(&mut self, interp: &mut Interp, more: &[u16]) -> JsResult<()> { + charge_units(interp, more.len())?; + let want = self.units.len().saturating_add(more.len()); + let cap = self.units.capacity(); + if want > cap { + let grown = want.max(cap.saturating_mul(2)).max(16); + self.held.add(interp, (grown - cap).saturating_mul(2))?; + self.units.reserve_exact(grown - self.units.len()); + } + self.units.extend_from_slice(more); + Ok(()) + } + + fn finish(self) -> Text { + Text { units: Rc::from(self.units), _held: self.held } + } +} + +enum Val { + Set(Nodes), + Bool(bool), + Num(f64), + Str(Text), +} + +/// A variable's value, read from the variables object before evaluation starts. +enum Bound { + Set(Vec), + Bool(bool), + Num(f64), + Str(Key), +} + +#[derive(Clone)] +struct Ctx { + node: XNode, + pos: usize, + size: usize, +} + +/// A location path being evaluated. Stage 0 applies the filter expression's predicates to the +/// filter's nodes; stage `s` applies step `s - 1` to each node of `inputs` in turn. +struct PathRun { + path: usize, + stage: usize, + inputs: Nodes, + ci: usize, + cur: Nodes, + pred: usize, + at: usize, + kept: Nodes, + waiting: bool, + out: Nodes, + seen: HashSet, + seen_held: HeldBytes, +} + +enum Task { + Eval(usize, Ctx), + Bin(Bin), + Neg, + Logic { and: bool, rhs: usize, ctx: Ctx }, + Bool, + Call(Func, usize, Ctx), + PathStart(usize), + Path(Box), +} + +fn to_bool(v: &Val) -> bool { + match v { + Val::Set(s) => !s.list.is_empty(), + Val::Bool(b) => *b, + Val::Num(n) => *n != 0.0 && !n.is_nan(), + Val::Str(t) => !t.units.is_empty(), + } +} + +/// The number a string stands for (XPath 1.0 section 4.4): optional white space, an optional +/// minus, digits with an optional fraction, optional white space; anything else is NaN. +fn str_num(interp: &mut Interp, units: &[u16]) -> JsResult { + let start = units.iter().position(|&c| !is_xml_space(c)).unwrap_or(units.len()); + let end = units.iter().rposition(|&c| !is_xml_space(c)).map_or(start, |e| e + 1); + let body = units.get(start..end).unwrap_or_default(); + let (negative, digits) = match body.split_first() { + Some((&DASH, rest)) => (true, rest), + _ => (false, body), + }; + let dots = digits.iter().filter(|&&c| c == DOT).count(); + if dots > 1 || !digits.iter().all(|&c| c == DOT || is_digit(c)) || !digits.iter().any(|&c| is_digit(c)) { + return Ok(f64::NAN); + } + let mut held = HeldBytes::new(interp); + held.add(interp, digits.len())?; + let text: String = digits.iter().map(|&c| char::from(c as u8)).collect(); + let n: f64 = text.parse().unwrap_or(f64::NAN); + Ok(if negative { -n } else { n }) +} + +/// A number as a string (XPath 1.0 section 4.2): integers without a decimal point, and no +/// exponent. +fn num_text(n: f64) -> String { + if n.is_nan() { + "NaN".into() + } else if n.is_infinite() { + if n > 0.0 { "Infinity".into() } else { "-Infinity".into() } + } else if n == 0.0 { + "0".into() + } else { + format!("{n}") + } +} + +/// `round` (XPath 1.0 section 4.4): the nearest integer, halves toward positive infinity, with +/// negative zero for values from -0.5 up to zero. +fn round(n: f64) -> f64 { + if !n.is_finite() || n == 0.0 { + return n; + } + let floor = n.floor(); + let r = if n - floor >= 0.5 { floor + 1.0 } else { floor }; + if r == 0.0 && n < 0.0 { -0.0 } else { r } +} + +fn compare_numbers(op: Bin, a: f64, b: f64) -> bool { + match op { + Bin::Eq => a == b, + Bin::Ne => a != b, + Bin::Lt => a < b, + Bin::Le => a <= b, + Bin::Gt => a > b, + _ => a >= b, + } +} + +fn flip(op: Bin) -> Bin { + match op { + Bin::Lt => Bin::Gt, + Bin::Le => Bin::Ge, + Bin::Gt => Bin::Lt, + Bin::Ge => Bin::Le, + other => other, + } +} + +fn code_points(units: &[u16]) -> Vec { + char::decode_utf16(units.iter().copied()).map(|r| r.map_or_else(|e| u32::from(e.unpaired_surrogate()), u32::from)).collect() +} + +fn push_point(out: &mut Vec, p: u32) { + match char::from_u32(p) { + Some(c) => { + let mut buf = [0u16; 2]; + out.extend_from_slice(c.encode_utf16(&mut buf)); + } + None => out.extend(u16::try_from(p).ok()), + } +} + +fn lower(c: u16) -> u16 { + if (0x41..=0x5A).contains(&c) { c + 0x20 } else { c } +} + +fn same_ignoring_case(a: &[u16], b: &[u16]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(&x, &y)| lower(x) == lower(y)) +} + +fn name_uri(name: &XmlName) -> &[u16] { + name.uri.as_deref().unwrap_or_default() +} + +fn kind_of(interp: &Interp, n: &XNode) -> JsResult { + Ok(match n { + XNode::Root(_) => NodeKind::Root, + XNode::Ns { .. } => NodeKind::Ns, + XNode::Node(x) => NodeKind::Xml(interp.xml_kind(*x)?), + }) +} + +fn parent_of(interp: &Interp, n: &XNode) -> JsResult> { + Ok(match n { + XNode::Root(_) => None, + XNode::Node(x) => Some(interp.xml_node(*x)?.parent.map_or(XNode::Root(*x), XNode::Node)), + XNode::Ns { owner, .. } => Some(XNode::Node(*owner)), + }) +} + +/// The parent of `x` and the position of `x` among its children, when `x` is a child. +fn child_position(interp: &mut Interp, x: ObjId) -> JsResult> { + let node = interp.xml_node(x)?; + let Some(p) = node.parent.filter(|_| node.kind != XmlKind::Attribute) else { return Ok(None) }; + let len = interp.xml_node(p)?.children.len(); + for i in 0..len { + interp.use_fuel()?; + if interp.xml_node(p)?.children.get(i) == Some(&x) { + return Ok(Some((p, i))); + } + } + Ok(None) +} + +fn top_of(interp: &mut Interp, mut x: ObjId) -> JsResult { + while let Some(p) = interp.xml_node(x)?.parent { + interp.use_fuel()?; + x = p; + } + Ok(x) +} + +/// The XPath expression evaluator for one call: the parsed expression, the variables, the +/// prefixes resolved for the current context element, and the document order of the trees met. +struct Ev<'p> { + parsed: &'p Parsed, + bound: Vec, + uris: HashMap, + order: HashMap, + root_order: HashMap, + next: u64, + held: HeldBytes, + tasks: Vec, + vals: Vec, +} + +impl<'p> Ev<'p> { + fn new(interp: &mut Interp, parsed: &'p Parsed, bound: Vec) -> JsResult { + let mut ev = Ev { + parsed, + bound: Vec::new(), + uris: HashMap::new(), + order: HashMap::new(), + root_order: HashMap::new(), + next: 0, + held: HeldBytes::new(interp), + tasks: Vec::new(), + vals: Vec::new(), + }; + for b in bound { + let b = match b { + Bound::Set(list) => { + let mut nodes = Nodes::new(interp); + for n in list { + nodes.push(interp, n)?; + } + let sorted = ev.sort(interp, nodes)?; + ev.held.add(interp, sorted.list.len().saturating_mul(size_of::()))?; + Bound::Set(sorted.list) + } + other => other, + }; + ev.bound.push(b); + } + Ok(ev) + } + + // ---- document order ---- + + fn put_order(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.held.add(interp, ENTRY_BYTES)?; + self.order.insert(x, self.next); + self.next += 1; + Ok(()) + } + + /// Numbers the tree under `top` in document order: the root, then each node followed by its + /// attributes and then its children. + fn number_tree(&mut self, interp: &mut Interp, top: ObjId) -> JsResult<()> { + if self.root_order.contains_key(&top) { + return Ok(()); + } + self.held.add(interp, ENTRY_BYTES)?; + self.root_order.insert(top, self.next); + self.next += 1; + self.number_node(interp, top)?; + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (top, 0usize))?; + while let Some(&(x, at)) = stack.last() { + interp.use_fuel()?; + let Some(c) = interp.xml_node(x)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + self.number_node(interp, c)?; + push_held(interp, &mut held, &mut stack, (c, 0))?; + } + Ok(()) + } + + fn number_node(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.put_order(interp, x)?; + let count = interp.xml_node(x)?.attrs.len(); + for i in 0..count { + interp.use_fuel()?; + if let Some(a) = interp.xml_node(x)?.attrs.get(i).copied() { + self.put_order(interp, a)?; + } + } + Ok(()) + } + + fn order_of(&mut self, interp: &mut Interp, n: &XNode) -> JsResult<(u64, u32)> { + match n { + XNode::Root(t) => { + self.number_tree(interp, *t)?; + Ok((self.root_order.get(t).copied().ok_or_else(internal)?, 0)) + } + XNode::Node(x) => { + if !self.order.contains_key(x) { + let top = top_of(interp, *x)?; + self.number_tree(interp, top)?; + } + Ok((self.order.get(x).copied().ok_or_else(internal)?, 0)) + } + XNode::Ns { owner, at, .. } => { + let (o, _) = self.order_of(interp, &XNode::Node(*owner))?; + Ok((o, at.saturating_add(1))) + } + } + } + + /// The nodes in document order, without repeats. + fn sort(&mut self, interp: &mut Interp, set: Nodes) -> JsResult { + if set.list.len() < 2 { + return Ok(set); + } + let n = set.list.len() as u64; + let mut held = HeldBytes::new(interp); + let mut keyed = Vec::new(); + for x in set.list { + let k = self.order_of(interp, &x)?; + push_held(interp, &mut held, &mut keyed, (k, x))?; + } + use_fuel_n(interp, n.saturating_mul(u64::from(n.ilog2()) + 1) / 8 + 1)?; + keyed.sort_by_key(|(k, _)| *k); + keyed.dedup_by_key(|(k, _)| *k); + let mut out = Nodes::new(interp); + for (_, x) in keyed { + out.push(interp, x)?; + } + Ok(out) + } + + // ---- axes and node tests ---- + + fn prefix_uri(&self, p: &Key) -> JsResult<&Key> { + self.uris.get(p).ok_or_else(internal) + } + + fn matches(&self, interp: &Interp, axis: Axis, test: &Test, n: &XNode) -> JsResult { + let kind = kind_of(interp, n)?; + let principal = match axis { + Axis::Attribute => NodeKind::Xml(XmlKind::Attribute), + Axis::Namespace => NodeKind::Ns, + _ => NodeKind::Xml(XmlKind::Element), + }; + let name = match n { + XNode::Node(x) => interp.xml_node(*x)?.name.as_ref(), + _ => None, + }; + Ok(match test { + Test::Node => true, + Test::Text => kind == NodeKind::Xml(XmlKind::Text), + Test::Comment => kind == NodeKind::Xml(XmlKind::Comment), + Test::Pi(target) => kind == NodeKind::Xml(XmlKind::Pi) && target.as_ref().is_none_or(|t| name.is_some_and(|q| q.local == *t)), + Test::Any => kind == principal, + Test::Prefix(p) => { + let uri = self.prefix_uri(p)?; + kind == principal && name.is_some_and(|q| name_uri(q) == &**uri) + } + Test::Name(p, local) => kind == principal && self.name_matches(n, name, p.as_ref(), local)?, + }) + } + + fn name_matches(&self, n: &XNode, name: Option<&XmlName>, prefix: Option<&Key>, local: &Key) -> JsResult { + if let XNode::Ns { prefix: ns_prefix, .. } = n { + return Ok(prefix.is_none() && ns_prefix == local); + } + let Some(q) = name else { return Ok(false) }; + let uri: &[u16] = match prefix { + Some(p) => self.prefix_uri(p)?, + None => &[], + }; + Ok(q.local == *local && name_uri(q) == uri) + } + + fn add(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: XNode) -> JsResult<()> { + interp.use_fuel()?; + if self.matches(interp, axis, test, &n)? { + out.push(interp, n)?; + } + Ok(()) + } + + /// Adds `x` and the nodes under it, in document order. + fn subtree(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, x: ObjId) -> JsResult<()> { + self.add(interp, out, axis, test, XNode::Node(x))?; + self.under(interp, out, axis, test, x) + } + + /// Adds the nodes under `x`, in document order. + fn under(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, x: ObjId) -> JsResult<()> { + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (x, 0usize))?; + while let Some(&(p, at)) = stack.last() { + let Some(c) = interp.xml_node(p)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + self.add(interp, out, axis, test, XNode::Node(c))?; + push_held(interp, &mut held, &mut stack, (c, 0))?; + } + Ok(()) + } + + fn descendants(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + match n { + XNode::Root(t) => self.subtree(interp, out, axis, test, *t), + XNode::Node(x) => self.under(interp, out, axis, test, *x), + XNode::Ns { .. } => Ok(()), + } + } + + fn children(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let x = match n { + XNode::Root(t) => return self.add(interp, out, axis, test, XNode::Node(*t)), + XNode::Node(x) => *x, + XNode::Ns { .. } => return Ok(()), + }; + let count = interp.xml_node(x)?.children.len(); + for i in 0..count { + if let Some(c) = interp.xml_node(x)?.children.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(c))?; + } + } + Ok(()) + } + + fn ancestors(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let mut cur = parent_of(interp, n)?; + while let Some(p) = cur { + cur = parent_of(interp, &p)?; + self.add(interp, out, axis, test, p)?; + } + Ok(()) + } + + fn siblings(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + let Some((p, at)) = child_position(interp, *x)? else { return Ok(()) }; + let count = interp.xml_node(p)?.children.len(); + let range: Box> = if axis == Axis::FollowingSibling { Box::new(at + 1..count) } else { Box::new((0..at).rev()) }; + for i in range { + if let Some(c) = interp.xml_node(p)?.children.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(c))?; + } + } + Ok(()) + } + + /// The node the following and preceding axes start from: an attribute's or a namespace + /// node's element, or the node itself. The flag is true for the first two. + fn start_of(interp: &Interp, n: &XNode) -> JsResult> { + Ok(match n { + XNode::Root(_) => None, + XNode::Ns { owner, .. } => Some((*owner, true)), + XNode::Node(x) => match interp.xml_node(*x)?.parent { + Some(p) if interp.xml_kind(*x)? == XmlKind::Attribute => Some((p, true)), + _ => Some((*x, false)), + }, + }) + } + + fn following(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let Some((mut cur, owned)) = Self::start_of(interp, n)? else { return Ok(()) }; + if owned { + self.under(interp, out, axis, test, cur)?; + } + while let Some((p, at)) = child_position(interp, cur)? { + let count = interp.xml_node(p)?.children.len(); + for i in at + 1..count { + if let Some(s) = interp.xml_node(p)?.children.get(i).copied() { + self.subtree(interp, out, axis, test, s)?; + } + } + cur = p; + } + Ok(()) + } + + fn preceding(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let Some((mut cur, _)) = Self::start_of(interp, n)? else { return Ok(()) }; + while let Some((p, at)) = child_position(interp, cur)? { + for i in (0..at).rev() { + let Some(s) = interp.xml_node(p)?.children.get(i).copied() else { continue }; + let mut part = Nodes::new(interp); + self.subtree(interp, &mut part, axis, test, s)?; + for x in part.list.into_iter().rev() { + out.push(interp, x)?; + } + } + cur = p; + } + Ok(()) + } + + fn attributes(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + if interp.xml_kind(*x)? != XmlKind::Element { + return Ok(()); + } + let count = interp.xml_node(*x)?.attrs.len(); + for i in 0..count { + if let Some(a) = interp.xml_node(*x)?.attrs.get(i).copied() { + self.add(interp, out, axis, test, XNode::Node(a))?; + } + } + Ok(()) + } + + fn namespaces(&self, interp: &mut Interp, out: &mut Nodes, axis: Axis, test: &Test, n: &XNode) -> JsResult<()> { + let XNode::Node(x) = n else { return Ok(()) }; + if interp.xml_kind(*x)? != XmlKind::Element { + return Ok(()); + } + let xml = key("xml"); + let mut at = 0u32; + let mut has_xml = false; + for ns in scope_of(interp, Some(*x))? { + let Some(prefix) = ns.prefix else { continue }; + if prefix.is_empty() && ns.uri.is_empty() { + continue; + } + has_xml |= prefix == xml; + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix, uri: ns.uri })?; + at += 1; + } + if !has_xml { + self.add(interp, out, axis, test, XNode::Ns { owner: *x, at, prefix: xml, uri: key(XML_URI) })?; + } + Ok(()) + } + + /// The nodes `axis` reaches from `n` that pass `test`, nearest first. + fn axis(&self, interp: &mut Interp, axis: Axis, test: &Test, n: &XNode) -> JsResult { + let mut out = Nodes::new(interp); + match axis { + Axis::Itself => self.add(interp, &mut out, axis, test, n.clone())?, + Axis::Child => self.children(interp, &mut out, axis, test, n)?, + Axis::Descendant => self.descendants(interp, &mut out, axis, test, n)?, + Axis::DescendantOrSelf => { + self.add(interp, &mut out, axis, test, n.clone())?; + self.descendants(interp, &mut out, axis, test, n)?; + } + Axis::Parent => { + if let Some(p) = parent_of(interp, n)? { + self.add(interp, &mut out, axis, test, p)?; + } + } + Axis::Ancestor => self.ancestors(interp, &mut out, axis, test, n)?, + Axis::AncestorOrSelf => { + self.add(interp, &mut out, axis, test, n.clone())?; + self.ancestors(interp, &mut out, axis, test, n)?; + } + Axis::FollowingSibling | Axis::PrecedingSibling => self.siblings(interp, &mut out, axis, test, n)?, + Axis::Following => self.following(interp, &mut out, axis, test, n)?, + Axis::Preceding => self.preceding(interp, &mut out, axis, test, n)?, + Axis::Attribute => self.attributes(interp, &mut out, axis, test, n)?, + Axis::Namespace => self.namespaces(interp, &mut out, axis, test, n)?, + } + Ok(out) + } + + // ---- string values and conversions ---- + + /// The string-value of a node (XPath 1.0 section 5): the text under an element or the root, + /// the value of any other node, and a namespace node's URI. + fn string_value(&mut self, interp: &mut Interp, n: &XNode) -> JsResult { + let x = match n { + XNode::Ns { uri, .. } => return Ok(Text::plain(interp, uri.clone())), + XNode::Root(t) => *t, + XNode::Node(x) => *x, + }; + let kind = interp.xml_kind(x)?; + if kind == XmlKind::Element { + return self.text_under(interp, x); + } + if matches!(n, XNode::Root(_)) && kind != XmlKind::Text { + return Ok(Text::plain(interp, empty())); + } + let value = interp.xml_node(x)?.value.clone(); + let units = flat_charged(interp, &value)?; + charge_units(interp, units.len())?; + Ok(Text::plain(interp, units)) + } + + fn text_under(&mut self, interp: &mut Interp, x: ObjId) -> JsResult { + let mut buf = TextBuf::new(interp); + let mut held = HeldBytes::new(interp); + let mut stack = Vec::new(); + push_held(interp, &mut held, &mut stack, (x, 0usize))?; + while let Some(&(p, at)) = stack.last() { + interp.use_fuel()?; + let Some(c) = interp.xml_node(p)?.children.get(at).copied() else { + stack.pop(); + continue; + }; + if let Some(frame) = stack.last_mut() { + frame.1 += 1; + } + match interp.xml_kind(c)? { + XmlKind::Text => { + let value = interp.xml_node(c)?.value.clone(); + let units = flat_charged(interp, &value)?; + buf.push(interp, &units)?; + } + XmlKind::Element => push_held(interp, &mut held, &mut stack, (c, 0))?, + _ => {} + } + } + Ok(buf.finish()) + } + + fn text_of(&mut self, interp: &mut Interp, v: Val) -> JsResult { + match v { + Val::Set(s) => match s.list.first() { + Some(n) => self.string_value(interp, n), + None => Ok(Text::plain(interp, empty())), + }, + Val::Bool(b) => Ok(Text::plain(interp, key(if b { "true" } else { "false" }))), + Val::Num(n) => Ok(Text::plain(interp, key(&num_text(n)))), + Val::Str(t) => { + // The caller reads the units, so they take fuel each time a value is used. + charge_units(interp, t.units.len())?; + Ok(t) + } + } + } + + /// The number a node's string-value stands for. + fn node_number(&mut self, interp: &mut Interp, n: &XNode) -> JsResult { + let t = self.string_value(interp, n)?; + str_num(interp, &t.units) + } + + fn number_of(&mut self, interp: &mut Interp, v: Val) -> JsResult { + match v { + Val::Num(n) => Ok(n), + Val::Bool(b) => Ok(f64::from(u8::from(b))), + other => { + let t = self.text_of(interp, other)?; + str_num(interp, &t.units) + } + } + } + + // ---- comparisons and operators ---- + + fn compare(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult { + match (a, b) { + (Val::Set(x), Val::Set(y)) => self.compare_sets(interp, op, &x, &y), + (Val::Set(x), other) => self.compare_set(interp, op, &x, other), + (other, Val::Set(y)) => self.compare_set(interp, flip(op), &y, other), + (x, y) => self.compare_values(interp, op, x, y), + } + } + + fn compare_values(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult { + if !matches!(op, Bin::Eq | Bin::Ne) { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + return Ok(compare_numbers(op, x, y)); + } + let equal = if matches!(a, Val::Bool(_)) || matches!(b, Val::Bool(_)) { + to_bool(&a) == to_bool(&b) + } else if matches!(a, Val::Num(_)) || matches!(b, Val::Num(_)) { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + return Ok(compare_numbers(op, x, y)); + } else { + self.text_of(interp, a)?.units == self.text_of(interp, b)?.units + }; + Ok(equal == (op == Bin::Eq)) + } + + /// Compares each node of `set` with `other`, which is not a node-set; `set` is on the left. + fn compare_set(&mut self, interp: &mut Interp, op: Bin, set: &Nodes, other: Val) -> JsResult { + if let Val::Str(t) = &other { + charge_units(interp, t.units.len())?; + } + let n = match other { + Val::Bool(b) => return self.compare_values(interp, op, Val::Bool(!set.list.is_empty()), Val::Bool(b)), + Val::Str(t) if matches!(op, Bin::Eq | Bin::Ne) => { + for node in &set.list { + if (self.string_value(interp, node)?.units == t.units) == (op == Bin::Eq) { + return Ok(true); + } + } + return Ok(false); + } + Val::Str(t) => str_num(interp, &t.units)?, + Val::Num(n) => n, + Val::Set(_) => return Err(internal()), + }; + for node in &set.list { + let v = self.node_number(interp, node)?; + if compare_numbers(op, v, n) { + return Ok(true); + } + } + Ok(false) + } + + fn strings_of(&mut self, interp: &mut Interp, held: &mut HeldBytes, set: &Nodes) -> JsResult> { + let mut out = HashSet::new(); + for node in &set.list { + let t = self.string_value(interp, node)?; + held.add(interp, ENTRY_BYTES.saturating_add(t.units.len().saturating_mul(2)))?; + out.insert(t.units); + } + Ok(out) + } + + /// The smallest and largest numbers the nodes' string-values stand for, leaving out NaN. + fn number_range(&mut self, interp: &mut Interp, set: &Nodes) -> JsResult> { + let mut range: Option<(f64, f64)> = None; + for node in &set.list { + let v = self.node_number(interp, node)?; + if !v.is_nan() { + range = Some(range.map_or((v, v), |(lo, hi)| (lo.min(v), hi.max(v)))); + } + } + Ok(range) + } + + fn compare_sets(&mut self, interp: &mut Interp, op: Bin, a: &Nodes, b: &Nodes) -> JsResult { + if a.list.is_empty() || b.list.is_empty() { + return Ok(false); + } + if matches!(op, Bin::Eq | Bin::Ne) { + let mut held = HeldBytes::new(interp); + let right = self.strings_of(interp, &mut held, b)?; + for node in &a.list { + let s = self.string_value(interp, node)?; + let found = if op == Bin::Eq { right.contains(&s.units) } else { right.len() > 1 || !right.contains(&s.units) }; + if found { + return Ok(true); + } + } + return Ok(false); + } + let (Some((a_lo, a_hi)), Some((b_lo, b_hi))) = (self.number_range(interp, a)?, self.number_range(interp, b)?) else { return Ok(false) }; + Ok(match op { + Bin::Lt | Bin::Le => compare_numbers(op, a_lo, b_hi), + _ => compare_numbers(op, a_hi, b_lo), + }) + } + + fn binary(&mut self, interp: &mut Interp, op: Bin, a: Val, b: Val) -> JsResult { + match op { + Bin::Union => { + let (Val::Set(mut x), Val::Set(y)) = (a, b) else { + return Err(interp.type_error("both sides of an XPath | must select nodes")); + }; + if x.list.is_empty() { + return Ok(Val::Set(y)); + } + for n in y.list { + x.push(interp, n)?; + } + Ok(Val::Set(self.sort(interp, x)?)) + } + Bin::Add | Bin::Sub | Bin::Mul | Bin::Div | Bin::Mod => { + let (x, y) = (self.number_of(interp, a)?, self.number_of(interp, b)?); + Ok(Val::Num(match op { + Bin::Add => x + y, + Bin::Sub => x - y, + Bin::Mul => x * y, + Bin::Div => x / y, + _ => x % y, + })) + } + Bin::Or | Bin::And => Err(internal()), + _ => Ok(Val::Bool(self.compare(interp, op, a, b)?)), + } + } + + // ---- functions ---- + + fn set_arg(interp: &mut Interp, v: Option, name: &str) -> JsResult { + match v { + Some(Val::Set(s)) => Ok(s), + _ => Err(interp.type_error(&format!("the XPath function {name} needs a node-set"))), + } + } + + fn text_arg(&mut self, interp: &mut Interp, v: Option, ctx: &Ctx) -> JsResult { + match v { + Some(v) => self.text_of(interp, v), + None => self.string_value(interp, &ctx.node), + } + } + + fn number_arg(&mut self, interp: &mut Interp, v: Option) -> JsResult { + let v = v.ok_or_else(internal)?; + self.number_of(interp, v) + } + + fn call(&mut self, interp: &mut Interp, f: Func, args: Vec, ctx: &Ctx) -> JsResult { + let mut args = args.into_iter(); + Ok(match f { + Func::Last => Val::Num(ctx.size as f64), + Func::Position => Val::Num(ctx.pos as f64), + Func::Count => Val::Num(Self::set_arg(interp, args.next(), "count")?.list.len() as f64), + Func::Id => Val::Set(Nodes::new(interp)), + Func::LocalName | Func::NamespaceUri | Func::Name => Val::Str(self.name_function(interp, f, args.next(), ctx)?), + Func::Sum => { + let set = Self::set_arg(interp, args.next(), "sum")?; + let mut total = 0.0; + for n in &set.list { + total += self.node_number(interp, n)?; + } + Val::Num(total) + } + Func::Boolean => Val::Bool(to_bool(&args.next().ok_or_else(internal)?)), + Func::Not => Val::Bool(!to_bool(&args.next().ok_or_else(internal)?)), + Func::True => Val::Bool(true), + Func::False => Val::Bool(false), + Func::Lang => { + let wanted = self.text_arg(interp, args.next(), ctx)?; + Val::Bool(self.lang(interp, &ctx.node, &wanted.units)?) + } + Func::Number => match args.next() { + Some(v) => Val::Num(self.number_of(interp, v)?), + None => Val::Num(self.node_number(interp, &ctx.node)?), + }, + Func::Floor => Val::Num(self.number_arg(interp, args.next())?.floor()), + Func::Ceiling => Val::Num(self.number_arg(interp, args.next())?.ceil()), + Func::Round => Val::Num(round(self.number_arg(interp, args.next())?)), + _ => return self.string_function(interp, f, args.collect(), ctx), + }) + } + + fn string_function(&mut self, interp: &mut Interp, f: Func, args: Vec, ctx: &Ctx) -> JsResult { + let mut texts = Vec::new(); + let mut held = HeldBytes::new(interp); + for v in args { + let t = self.text_of(interp, v)?; + push_held(interp, &mut held, &mut texts, t)?; + } + if texts.is_empty() && matches!(f, Func::String | Func::StringLength | Func::NormalizeSpace) { + texts.push(self.string_value(interp, &ctx.node)?); + } + if f == Func::String { + return Ok(Val::Str(texts.into_iter().next().ok_or_else(internal)?)); + } + let units = |at: usize| texts.get(at).map(|t| &*t.units).unwrap_or_default(); + Ok(match f { + Func::StringLength => Val::Num(char::decode_utf16(units(0).iter().copied()).count() as f64), + Func::StartsWith => Val::Bool(units(0).starts_with(units(1))), + Func::Contains => Val::Bool(find(interp, units(0), units(1))?.is_some()), + Func::SubstringBefore => { + let at = find(interp, units(0), units(1))?; + Val::Str(self.slice(interp, at.and_then(|i| units(0).get(..i)).unwrap_or_default())?) + } + Func::SubstringAfter => { + let at = find(interp, units(0), units(1))?; + Val::Str(self.slice(interp, at.and_then(|i| units(0).get(i + units(1).len()..)).unwrap_or_default())?) + } + Func::Concat => { + let mut buf = TextBuf::new(interp); + for t in &texts { + buf.push(interp, &t.units)?; + } + Val::Str(buf.finish()) + } + Func::NormalizeSpace => Val::Str(normalize_space(interp, units(0))?), + Func::Translate => Val::Str(translate(interp, units(0), units(1), units(2))?), + _ => return Err(internal()), + }) + } + + fn slice(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult { + let mut buf = TextBuf::new(interp); + buf.push(interp, units)?; + Ok(buf.finish()) + } + + fn substring(&mut self, interp: &mut Interp, args: Vec) -> JsResult { + let mut args = args.into_iter(); + let text = self.text_of(interp, args.next().ok_or_else(internal)?)?; + let start = round(self.number_arg(interp, args.next())?); + let end = match args.next() { + Some(len) => start + round(self.number_of(interp, len)?), + None => f64::INFINITY, + }; + charge_units(interp, text.units.len())?; + let mut held = HeldBytes::new(interp); + // The code points take 4 bytes each, and the result up to twice its 2-byte units. + held.add(interp, text.units.len().saturating_mul(8))?; + let mut out = Vec::new(); + for (i, p) in code_points(&text.units).into_iter().enumerate() { + let position = (i + 1) as f64; + if position >= start && position < end { + push_point(&mut out, p); + } + } + Ok(Val::Str(Text { units: Rc::from(out), _held: held })) + } + + fn name_function(&mut self, interp: &mut Interp, f: Func, arg: Option, ctx: &Ctx) -> JsResult { + let node = match arg { + None => Some(ctx.node.clone()), + Some(Val::Set(s)) => s.list.into_iter().next(), + Some(_) => return Err(interp.type_error("the XPath name functions need a node-set")), + }; + let units = match node { + None | Some(XNode::Root(_)) => empty(), + Some(XNode::Ns { prefix, .. }) => { + if f == Func::NamespaceUri { + empty() + } else { + prefix + } + } + Some(XNode::Node(x)) => node_name(interp, f, x)?, + }; + Ok(Text::plain(interp, units)) + } + + /// Whether the context node's language, from the nearest `xml:lang` attribute on it or an + /// ancestor, is `wanted` or a sublanguage of it, ignoring case. + fn lang(&mut self, interp: &mut Interp, n: &XNode, wanted: &[u16]) -> JsResult { + let mut cur = match n { + XNode::Root(_) => None, + XNode::Node(x) => Some(*x), + XNode::Ns { owner, .. } => Some(*owner), + }; + let xml = key(XML_URI); + while let Some(x) = cur { + interp.use_fuel()?; + let node = interp.xml_node(x)?; + let parent = node.parent; + let count = node.attrs.len(); + for i in 0..count { + interp.use_fuel()?; + let Some(a) = interp.xml_node(x)?.attrs.get(i).copied() else { break }; + let attr = interp.xml_node(a)?; + if attr.name.as_ref().is_some_and(|q| name_uri(q) == &*xml && units_are(&q.local, "lang")) { + let value = attr.value.clone(); + let value = flat_charged(interp, &value)?; + let head = value.get(..wanted.len()).unwrap_or_default(); + return Ok(same_ignoring_case(head, wanted) && (value.len() == wanted.len() || value.get(wanted.len()) == Some(&DASH))); + } + } + cur = parent; + } + Ok(false) + } + + // ---- running ---- + + fn task(&mut self, interp: &mut Interp, t: Task) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.tasks, t) + } + + fn val(&mut self, interp: &mut Interp, v: Val) -> JsResult<()> { + push_held(interp, &mut self.held, &mut self.vals, v) + } + + fn pop(&mut self) -> JsResult { + self.vals.pop().ok_or_else(internal) + } + + fn root_of(interp: &mut Interp, n: &XNode) -> JsResult { + Ok(match n { + XNode::Root(t) => XNode::Root(*t), + XNode::Node(x) => XNode::Root(top_of(interp, *x)?), + XNode::Ns { owner, .. } => XNode::Root(top_of(interp, *owner)?), + }) + } + + fn bound_value(&mut self, interp: &mut Interp, at: usize) -> JsResult { + Ok(match self.bound.get(at).ok_or_else(internal)? { + Bound::Bool(b) => Val::Bool(*b), + Bound::Num(n) => Val::Num(*n), + Bound::Str(s) => Val::Str(Text::plain(interp, s.clone())), + Bound::Set(list) => { + let list = list.clone(); + let mut nodes = Nodes::new(interp); + nodes.held.add(interp, list.len().saturating_mul(size_of::()))?; + nodes.list = list; + Val::Set(nodes) + } + }) + } + + fn eval(&mut self, interp: &mut Interp, e: usize, ctx: Ctx) -> JsResult<()> { + let parsed = self.parsed; + match parsed.exprs.get(e).ok_or_else(internal)? { + Expr::Num(n) => self.val(interp, Val::Num(*n)), + Expr::Lit(s) => { + let t = Text::plain(interp, s.clone()); + self.val(interp, Val::Str(t)) + } + Expr::Var(at) => { + let v = self.bound_value(interp, *at)?; + self.val(interp, v) + } + Expr::Neg(a) => { + self.task(interp, Task::Neg)?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Bin(op @ (Bin::And | Bin::Or), a, b) => { + self.task(interp, Task::Logic { and: *op == Bin::And, rhs: *b, ctx: ctx.clone() })?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Bin(op, a, b) => { + self.task(interp, Task::Bin(*op))?; + self.task(interp, Task::Eval(*b, ctx.clone()))?; + self.task(interp, Task::Eval(*a, ctx)) + } + Expr::Call(f, args) => { + self.task(interp, Task::Call(*f, args.len(), ctx.clone()))?; + for a in args.iter().rev() { + self.task(interp, Task::Eval(*a, ctx.clone()))?; + } + Ok(()) + } + Expr::Path { filter: Some((primary, _)), .. } => { + self.task(interp, Task::PathStart(e))?; + self.task(interp, Task::Eval(*primary, ctx)) + } + Expr::Path { filter: None, absolute, steps } => { + let start = if *absolute { Self::root_of(interp, &ctx.node)? } else { ctx.node }; + let mut inputs = Nodes::new(interp); + inputs.push(interp, start)?; + if steps.is_empty() { + return self.val(interp, Val::Set(inputs)); + } + let first = self.step_axis(interp, e, 1, &inputs.list.first().cloned().ok_or_else(internal)?)?; + let run = self.new_run(interp, e, 1, inputs, first); + self.run_path(interp, Box::new(run)) + } + } + } + + fn new_run(&self, interp: &Interp, path: usize, stage: usize, inputs: Nodes, cur: Nodes) -> PathRun { + PathRun { + path, + stage, + inputs, + ci: 0, + cur, + pred: 0, + at: 0, + kept: Nodes::new(interp), + waiting: false, + out: Nodes::new(interp), + seen: HashSet::new(), + seen_held: HeldBytes::new(interp), + } + } + + fn step(&mut self, interp: &mut Interp, t: Task) -> JsResult<()> { + match t { + Task::Eval(e, ctx) => self.eval(interp, e, ctx), + Task::Bin(op) => { + let b = self.pop()?; + let a = self.pop()?; + let v = self.binary(interp, op, a, b)?; + self.val(interp, v) + } + Task::Neg => { + let a = self.pop()?; + let n = self.number_of(interp, a)?; + self.val(interp, Val::Num(-n)) + } + Task::Logic { and, rhs, ctx } => { + let left = to_bool(&self.pop()?); + if left != and { + return self.val(interp, Val::Bool(left)); + } + self.task(interp, Task::Bool)?; + self.task(interp, Task::Eval(rhs, ctx)) + } + Task::Bool => { + let v = to_bool(&self.pop()?); + self.val(interp, Val::Bool(v)) + } + Task::Call(f, n, ctx) => { + let args = self.vals.split_off(self.vals.len().checked_sub(n).ok_or_else(internal)?); + let v = if f == Func::Substring { self.substring(interp, args)? } else { self.call(interp, f, args, &ctx)? }; + self.val(interp, v) + } + Task::PathStart(e) => { + let Val::Set(set) = self.pop()? else { + return Err(interp.type_error("an XPath predicate or step follows a value that is not a node-set")); + }; + let run = self.new_run(interp, e, 0, Nodes::new(interp), set); + self.run_path(interp, Box::new(run)) + } + Task::Path(run) => self.run_path(interp, run), + } + } + + /// The predicates of a path's stage, and its step for a stage after the filter's. + fn stage_of(&self, path: usize, stage: usize) -> JsResult<(&'p [usize], Option<&'p Step>)> { + let parsed = self.parsed; + let Some(Expr::Path { filter, steps, .. }) = parsed.exprs.get(path) else { return Err(internal()) }; + match stage.checked_sub(1) { + None => Ok((filter.as_ref().map(|(_, p)| p.as_slice()).unwrap_or_default(), None)), + Some(s) => { + let step = steps.get(s).ok_or_else(internal)?; + Ok((&step.preds, Some(step))) + } + } + } + + fn step_axis(&self, interp: &mut Interp, path: usize, stage: usize, n: &XNode) -> JsResult { + let (_, step) = self.stage_of(path, stage)?; + let step = step.ok_or_else(internal)?; + self.axis(interp, step.axis, &step.test, n) + } + + /// Runs a path until a predicate needs evaluating, which it schedules before itself, or until + /// the path's node-set is complete, which it pushes. + fn run_path(&mut self, interp: &mut Interp, mut r: Box) -> JsResult<()> { + loop { + interp.use_fuel()?; + if r.waiting { + r.waiting = false; + let v = self.pop()?; + let keep = match v { + Val::Num(n) => n == (r.at + 1) as f64, + other => to_bool(&other), + }; + if keep && let Some(n) = r.cur.list.get(r.at).cloned() { + r.kept.push(interp, n)?; + } + r.at += 1; + } + let (preds, _) = self.stage_of(r.path, r.stage)?; + if let Some(&pred) = preds.get(r.pred) { + if let Some(node) = r.cur.list.get(r.at).cloned() { + let ctx = Ctx { node, pos: r.at + 1, size: r.cur.list.len() }; + r.waiting = true; + self.task(interp, Task::Path(r))?; + return self.task(interp, Task::Eval(pred, ctx)); + } + r.cur = replace(&mut r.kept, Nodes::new(interp)); + r.pred += 1; + r.at = 0; + continue; + } + if !self.next_context(interp, &mut r)? { + return Ok(()); + } + } + } + + /// Adds the current context's nodes to the stage's result and moves to the next context or + /// stage. Returns false when the path is complete and its node-set has been pushed. + fn next_context(&mut self, interp: &mut Interp, r: &mut PathRun) -> JsResult { + let cur = replace(&mut r.cur, Nodes::new(interp)); + if r.inputs.list.len() > 1 { + for n in cur.list { + if !r.seen.contains(&n) { + r.seen_held.add(interp, ENTRY_BYTES)?; + r.seen.insert(n.clone()); + r.out.push(interp, n)?; + } + } + } else { + r.out = cur; + } + r.ci += 1; + if let Some(n) = r.inputs.list.get(r.ci).cloned() { + r.cur = self.step_axis(interp, r.path, r.stage, &n)?; + r.pred = 0; + r.at = 0; + return Ok(true); + } + let out = replace(&mut r.out, Nodes::new(interp)); + let (_, step) = self.stage_of(r.path, r.stage)?; + let done = if r.inputs.list.len() > 1 { + self.sort(interp, out)? + } else if step.is_some_and(|s| s.axis.reverse()) { + let mut out = out; + out.list.reverse(); + out + } else { + out + }; + r.seen.clear(); + r.seen_held = HeldBytes::new(interp); + r.stage += 1; + let more = self.stage_of(r.path, r.stage).is_ok(); + let Some(first) = done.list.first().cloned().filter(|_| more) else { + self.val(interp, Val::Set(done))?; + return Ok(false); + }; + r.inputs = done; + r.ci = 0; + r.cur = self.step_axis(interp, r.path, r.stage, &first)?; + r.pred = 0; + r.at = 0; + Ok(true) + } + + fn run(&mut self, interp: &mut Interp, e: usize, ctx: Ctx) -> JsResult { + self.tasks.clear(); + self.vals.clear(); + self.task(interp, Task::Eval(e, ctx))?; + while let Some(t) = self.tasks.pop() { + interp.use_fuel()?; + self.step(interp, t)?; + } + let v = self.pop()?; + if !self.vals.is_empty() { + return Err(internal()); + } + Ok(v) + } + + /// Binds the expression's prefixes to the namespaces in scope on `x`. + fn resolve_prefixes(&mut self, interp: &mut Interp, x: ObjId) -> JsResult<()> { + self.uris.clear(); + if self.parsed.prefixes.is_empty() { + return Ok(()); + } + let scope = scope_of(interp, Some(x))?; + let parsed = self.parsed; + for p in &parsed.prefixes { + let uri = if units_are(p, "xml") { + key(XML_URI) + } else { + match scope.iter().find(|ns| ns.prefix.as_ref() == Some(p)) { + Some(ns) if !ns.uri.is_empty() => ns.uri.clone(), + _ => { + let shown = String::from_utf16_lossy(p); + return Err(interp.type_error(&format!("the XPath prefix {shown} is not bound to a namespace"))); + } + } + }; + self.held.add(interp, ENTRY_BYTES)?; + self.uris.insert(p.clone(), uri); + } + Ok(()) + } + + /// The nodes the expression selects with `x` as the context node. + fn select(&mut self, interp: &mut Interp, x: ObjId) -> JsResult> { + self.resolve_prefixes(interp, x)?; + let root = self.parsed.root; + let Val::Set(set) = self.run(interp, root, Ctx { node: XNode::Node(x), pos: 1, size: 1 })? else { + return Err(interp.type_error("the XPath expression does not evaluate to a node-set")); + }; + let mut out = Vec::new(); + let mut held = HeldBytes::new(interp); + for n in set.list { + match n { + XNode::Node(id) => push_held(interp, &mut held, &mut out, id)?, + XNode::Root(_) => return Err(interp.type_error("the XPath expression selects the root node, which is not an XML value")), + XNode::Ns { .. } => return Err(interp.type_error("the XPath expression selects a namespace node, which is not an XML value")), + } + } + Ok(out) + } +} + +fn node_name(interp: &mut Interp, f: Func, x: ObjId) -> JsResult { + let node = interp.xml_node(x)?; + let (kind, parent) = (node.kind, node.parent); + let Some(name) = node.name.clone() else { return Ok(empty()) }; + match (f, kind) { + (_, XmlKind::Text | XmlKind::Comment) => Ok(empty()), + (Func::NamespaceUri, XmlKind::Pi) => Ok(empty()), + (Func::NamespaceUri, _) => Ok(Rc::from(name_uri(&name))), + (Func::LocalName, _) | (_, XmlKind::Pi) => Ok(name.local), + _ => qualified_name(interp, x, parent, &name, kind == XmlKind::Attribute), + } +} + +/// An element's or attribute's name as a QName, with a prefix declared for its namespace. +fn qualified_name(interp: &mut Interp, x: ObjId, parent: Option, name: &XmlName, attribute: bool) -> JsResult { + let uri = name_uri(name); + if uri.is_empty() { + return Ok(name.local.clone()); + } + let prefix = if units_are(uri, XML_URI) { + key("xml") + } else { + match &name.prefix { + Some(p) if !(attribute && p.is_empty()) => p.clone(), + _ => { + let scope = scope_of(interp, if attribute { parent } else { Some(x) })?; + let usable = |ns: &XmlNs| *ns.uri == *uri && ns.prefix.as_ref().is_some_and(|p| !(attribute && p.is_empty())); + scope.into_iter().find(usable).and_then(|ns| ns.prefix).unwrap_or_else(empty) + } + } + }; + if prefix.is_empty() { + return Ok(name.local.clone()); + } + let mut units = prefix.to_vec(); + units.push(COLON); + units.extend_from_slice(&name.local); + Ok(Rc::from(units)) +} + +/// The first index where `needle` occurs in `hay`. Every position tried takes fuel. +fn find(interp: &mut Interp, hay: &[u16], needle: &[u16]) -> JsResult> { + string_builtins::find(interp, hay, needle, 0) +} + +fn normalize_space(interp: &mut Interp, units: &[u16]) -> JsResult { + let mut buf = TextBuf::new(interp); + for word in units.split(|&c| is_xml_space(c)).filter(|w| !w.is_empty()) { + if !buf.units.is_empty() { + buf.push(interp, &[0x20])?; + } + buf.push(interp, word)?; + } + Ok(buf.finish()) +} + +fn translate(interp: &mut Interp, units: &[u16], from: &[u16], to: &[u16]) -> JsResult { + charge_units(interp, units.len() + from.len() + to.len())?; + let mut held = HeldBytes::new(interp); + held.add(interp, from.len().saturating_add(units.len()).saturating_add(to.len()).saturating_mul(ENTRY_BYTES))?; + let to = code_points(to); + let mut map: HashMap> = HashMap::new(); + for (i, p) in code_points(from).into_iter().enumerate() { + map.entry(p).or_insert_with(|| to.get(i).copied()); + } + let mut out = Vec::new(); + for p in code_points(units) { + match map.get(&p) { + Some(Some(t)) => push_point(&mut out, *t), + Some(None) => {} + None => push_point(&mut out, p), + } + } + Ok(Text { units: Rc::from(out), _held: held }) +} + +/// Reads the variables the expression names from `variables`. +fn bind(interp: &mut Interp, held: &mut HeldBytes, parsed: &Parsed, variables: &Value) -> JsResult> { + let mut out = Vec::new(); + for name in &parsed.vars { + let v = match variables { + Value::Object(o) if interp.has_property(*o, name)? => interp.get_from(*o, name, variables)?, + _ => Value::Undefined, + }; + if matches!(v, Value::Undefined) { + let shown = String::from_utf16_lossy(name); + return Err(interp.type_error(&format!("the XPath variable ${shown} is not bound"))); + } + interp.temp_roots.push(v.clone()); + let b = match (interp.classify(&v), &v) { + (Xv::Node(n), _) => Bound::Set(vec![XNode::Node(n)]), + (Xv::List(l), _) => { + // A later getter may change the list, so its items are held as roots themselves. + let count = interp.xml_list(l)?.items.len(); + held.add(interp, count.saturating_mul(size_of::() + size_of::() + size_of::()))?; + let items = interp.xml_list(l)?.items.clone(); + interp.temp_roots.extend(items.iter().map(|&n| Value::Object(n))); + Bound::Set(items.into_iter().map(XNode::Node).collect()) + } + (Xv::Other, Value::Bool(b)) => Bound::Bool(*b), + (Xv::Other, Value::Number(n)) => Bound::Num(*n), + (Xv::Other, _) => { + let s = interp.to_str(&v)?; + Bound::Str(flat_charged(interp, &s)?) + } + }; + push_held(interp, held, &mut out, b)?; + } + Ok(out) +} + +/// The nodes an expression runs on: the XML value itself, or each element of an XMLList. +fn contexts(interp: &mut Interp, held: &mut HeldBytes, this: &Value) -> JsResult> { + match interp.classify(this) { + Xv::Node(n) => Ok(vec![n]), + Xv::List(l) => { + let count = interp.xml_list(l)?.items.len(); + let mut out = Vec::new(); + for i in 0..count { + interp.use_fuel()?; + let Some(item) = interp.xml_list(l)?.items.get(i).copied() else { break }; + if interp.xml_kind(item)? == XmlKind::Element { + push_held(interp, held, &mut out, item)?; + } + } + Ok(out) + } + Xv::Other => Err(interp.type_error("the method needs an XML value")), + } +} + +/// `xpath(expression[, variables])`: the nodes the XPath 1.0 `expression` selects with this XML +/// value as the context node, or, for an XMLList, with each of its elements in turn, as one +/// XMLList. Each property of `variables` binds the variable of its name. +pub(crate) fn xpath(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + if matches!(interp.classify(&this), Xv::Other) { + return Err(interp.type_error("the method needs an XML value")); + } + let Value::String(source) = arg(args, 0) else { + return Err(interp.type_error("xpath needs the expression as a string")); + }; + let variables = arg(args, 1); + if !matches!(variables, Value::Undefined | Value::Object(_)) { + return Err(interp.type_error("xpath needs its variables as an object")); + } + interp.rooted(&[this.clone(), variables.clone()], |i| { + let units = flat_charged(i, &source)?; + let parsed = parse(i, &units)?; + let mut held = HeldBytes::new(i); + let bound = bind(i, &mut held, &parsed, &variables)?; + let items = contexts(i, &mut held, &this)?; + let out = i.new_xml_list(None, None)?; + i.xml_keep(out); + let mut ev = Ev::new(i, &parsed, bound)?; + for item in items { + for id in ev.select(i, item)? { + i.list_push(out, id)?; + } + } + Ok(Value::Object(out)) + }) +} diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 00ff3a2d77..54baeeb0c3 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -77,6 +77,7 @@ fn export_tool_lists_engine_formats_and_options() { "eps", "emf", "wmf", + "hpgl", "tga", "psd" ]) diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 781a2bf14d..d4d69c945f 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -393,14 +393,14 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", + "Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf, hpgl pen moves for plotters and cutters), pdf and ai (one page per artboard: all, or `artboard` / `range`; ai is the PDF-compatible .ai file that Save writes, which carries the document when every artboard is written in order), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. For SVG/SVGZ, top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options.advanced.outlineText: true`, default false (live text). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", obj( json!({ "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, "path": string("Destination file (omit to get dataBase64)"), "scale": num("Raster pixels per point (without scale or options.ppi, uses the document's raster effects resolution; options.ppi wins)"), "artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF and .ai default: all)"}, - "range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"), + "range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF, WMF and HP-GL: one, or one file each with options.useArtboards: true)"), "selection": {"type": "boolean", "description": "Export only the selection, cropped to it"}, "outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"}, "options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"}, diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index ff8644431b..b07964b225 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -634,6 +634,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", ), + ( + "window.learn", + "Learn", + "", + "{show?: bool} show (true), hide (false) or toggle (omitted or null) the Learn panel, as Window › Learn and the app bar's Explore tutorials button do; any other value is an error and changes nothing; returns whether the panel shows", + ), + ( + "learn.list", + "List Tutorials", + "", + "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", + ), + ( + "learn.show", + "Show Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 5326e2f307..a517d516a6 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -84,7 +84,7 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result emf::write(&ops, r, &doc.title), + Kind::Emf => emf::write(&ops, r, doc.info_title()), Kind::Wmf => wmf::write(&ops, r, &mut warnings), }; Ok(Output { bytes, warnings }) diff --git a/crates/pathops/src/addons.rs b/crates/pathops/src/addons.rs index 3896d02d05..436ba984bd 100644 --- a/crates/pathops/src/addons.rs +++ b/crates/pathops/src/addons.rs @@ -10,12 +10,15 @@ //! - [`segment_reshape`]: bending a segment by dragging a point on it (shared by the tools that //! reshape segments directly). //! - [`pen`]: the Smart Pen's straight and curved segments and its point edits. +//! - [`stencil`]: Stencil Bridges, the strips cut out of art so that a stencil cut from it keeps +//! its islands attached. pub mod cleanup; pub mod keep_shape; pub mod pathtools; pub mod pen; pub mod segment_reshape; +pub mod stencil; pub use cleanup::{ Rejoined, close_nearly_closed, merge_duplicate_anchors, near_point_groups, rejoin, remove_collinear_anchors, retract_tiny_handles, diff --git a/crates/pathops/src/addons/stencil.rs b/crates/pathops/src/addons/stencil.rs new file mode 100644 index 0000000000..437433e40c --- /dev/null +++ b/crates/pathops/src/addons/stencil.rs @@ -0,0 +1,653 @@ +//! Stencil Bridges: the art a stencil is cut from, with strips cut out of it so that every island +//! of the stencil sheet stays attached. +//! +//! The art is the area the stencil's holes take. Each bounded area outside the art (the inside of +//! an O, any hole of a shape) is an island of the sheet. A bridge is a straight strip of a set +//! width cut out of the art, from an island across the art to the sheet beyond it, or to another +//! island. The bridges are picked as a spanning tree over the islands and the sheet (shortest +//! crossings first), so every island that has room for one is joined to the sheet, then each +//! island gets more bridges, spread around it, up to the number asked for. +//! +//! Crossings are measured along straight lines in one direction at a time (vertical, horizontal, +//! or every 15 degrees for the shortest crossing), spaced half a bridge width apart. + +#[cfg(test)] +mod tests; + +use std::cell::Cell; + +use kurbo::{BezPath, ParamCurve, PathEl, Point}; +use vectorcraft_geom::{FillRule, PathData, SubPath}; + +use crate::{BoolOp, DEFAULT_PRECISION, PathOpsError, try_boolean, try_unite_all}; + +/// The narrowest and widest bridge, in points. +pub const BRIDGE_WIDTH_RANGE: (f64, f64) = (0.01, 1000.0); +/// The most bridges one island is given. +pub const MAX_BRIDGES_PER_ISLAND: usize = 16; +/// The most straight segments the art is followed by. +pub const MAX_STENCIL_SEGMENTS: usize = 200_000; +/// The most outlines the art may have. +pub const MAX_STENCIL_CONTOURS: usize = 4000; +/// How far from the origin the art and its handles may reach, in points: beyond the coordinates a +/// document holds. +pub const MAX_STENCIL_EXTENT: f64 = 1.0e7; +/// The most segments all lines across the art may look at together. +const MAX_WORK: usize = 300_000_000; +/// The most edges the outlines are tested against when finding which ones bound islands. +const MAX_NESTING_WORK: usize = 50_000_000; +/// The most bands one direction's segments are listed in. +const MAX_LINES: f64 = 1000.0; +/// The most lines all islands are looked across along, in every direction together. +const MAX_ISLAND_LINES: usize = 400_000; +/// The most lines on each side of an island's middle, in one direction. +const MAX_LINES_PER_SIDE: usize = 64; +/// The most times segments are listed against the lines they reach, in one direction. +const MAX_BIN_ENTRIES: usize = 20_000_000; +/// The lines across one bridge its width is checked along. +const CHECKS: usize = 5; +/// The directions the shortest crossing is looked for in, in degrees. +const SHORTEST_STEP_DEG: usize = 15; + +/// Which way bridges run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BridgeDirection { + Vertical, + Horizontal, + /// The shortest crossing in any of 12 directions, 15 degrees apart. + Shortest, +} + +/// What [`stencil_bridges`] makes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BridgeSpec { + /// Each bridge's width (pt), in [`BRIDGE_WIDTH_RANGE`]. + pub width: f64, + /// The bridges each island is given, 1 to [`MAX_BRIDGES_PER_ISLAND`] (fewer where it has no + /// room for more, and more where other islands are joined to the sheet through it). + pub count: usize, + pub direction: BridgeDirection, +} + +/// The art with its bridges cut out. +#[derive(Clone, Debug, PartialEq)] +pub struct Bridged { + /// The art as one area, less the bridges. + pub path: PathData, + /// The islands the art held. + pub islands: usize, + /// The bridges cut. + pub bridges: usize, + /// The islands the cut result still encloses: no bridge of the width fits, or the bridges that + /// fit join them only to each other. + pub unbridged: usize, +} + +/// `art` (each path with its fill rule; open subpaths are closed as a fill closes them) as one +/// area with bridges cut out of it as `spec` says. With no islands, the area is returned as it is +/// and no bridge is cut. +pub fn stencil_bridges(art: &[(&PathData, FillRule)], spec: BridgeSpec) -> Result { + let (lo, hi) = BRIDGE_WIDTH_RANGE; + if !(spec.width.is_finite() && (lo..=hi).contains(&spec.width)) || !(1..=MAX_BRIDGES_PER_ISLAND).contains(&spec.count) { + return Err(PathOpsError::Degenerate); + } + if too_large(art) { + return Err(PathOpsError::TooComplex); + } + let closed: Vec = + art.iter().map(|(p, _)| PathData::new(p.subpaths.iter().map(|sp| SubPath { closed: true, ..sp.clone() }).collect())).collect(); + let refs: Vec<(&PathData, FillRule)> = closed.iter().zip(art).map(|(p, (_, r))| (p, *r)).collect(); + bridge_region(try_unite_all(&refs)?, spec) +} + +/// Whether `art` has more than [`MAX_STENCIL_SEGMENTS`] anchors or reaches past +/// [`MAX_STENCIL_EXTENT`]: art refused before it is united. +fn too_large(art: &[(&PathData, FillRule)]) -> bool { + let anchors: usize = art.iter().flat_map(|(p, _)| &p.subpaths).map(|sp| sp.anchors.len()).sum(); + anchors > MAX_STENCIL_SEGMENTS || !art.iter().all(|(p, _)| within_extent(&p.to_bezpath())) +} + +/// [`stencil_bridges`] on `region`, an area with no overlaps. +fn bridge_region(region: PathData, spec: BridgeSpec) -> Result { + let contours = contours(®ion, spec.width)?; + let holes = contours.iter().filter(|c| c.hole).count(); + if holes == 0 { + return Ok(Bridged { path: region, islands: 0, bridges: 0, unbridged: 0 }); + } + let (chosen, unjoined) = choose(&contours, spec, MAX_WORK)?; + if chosen.is_empty() { + return Ok(Bridged { path: region, islands: holes, bridges: 0, unbridged: unjoined }); + } + let strips = PathData::new(chosen.iter().map(|c| SubPath::polyline(&c.strip, true)).collect()); + let path = try_boolean(®ion, FillRule::NonZero, &strips, FillRule::NonZero, BoolOp::Difference, DEFAULT_PRECISION)?; + // The islands the result still encloses, counted on the cut outlines themselves (removing art + // never encloses a new one). + let unbridged = match self::contours(&path, spec.width) { + Ok(left) => left.iter().filter(|c| c.hole).count(), + Err(_) => unjoined, + }; + Ok(Bridged { path, islands: holes, bridges: chosen.len(), unbridged }) +} + +/// The bridges `spec` asks for across the art that `contours` outline, and how many islands they +/// leave apart from the sheet. Lines across the art look at no more than `work` segments in all; +/// past that, the art is refused when the islands are not all joined yet, and otherwise the extra +/// bridges found so far are kept. +fn choose(contours: &[Contour], spec: BridgeSpec, work: usize) -> Result<(Vec, usize), PathOpsError> { + let degrees: Vec = match spec.direction { + BridgeDirection::Vertical => vec![90.0], + BridgeDirection::Horizontal => vec![0.0], + BridgeDirection::Shortest => (0..180).step_by(SHORTEST_STEP_DEG).map(|d| d as f64).collect(), + }; + let budget = work / degrees.len().max(1); + let mut views = vec![]; + for deg in degrees { + views.push(View::new(contours, deg.to_radians(), spec.width, budget)?); + } + let mut picker = Picker::new(contours, &views, spec.width); + picker.connect(); + if views.iter().any(View::exhausted) { + return Err(PathOpsError::TooComplex); + } + picker.spread(spec.count); + let unjoined = picker.unjoined(); + Ok((picker.chosen, unjoined)) +} + +/// One closed outline of the art, followed in straight segments. +struct Contour { + points: Vec, + /// Whether it bounds an island (it lies inside an odd number of the others). + hole: bool, + /// Its area, without sign. + area: f64, + /// Its box: (min x, min y, max x, max y). + bounds: (f64, f64, f64, f64), +} + +/// The outlines of `region` (an area with no overlaps) followed within a tolerance set by the +/// bridge width: a twentieth of it, from 0.002 to 0.25 pt, raised for a curve that would need more +/// than [`vectorcraft_geom::MAX_FLATTEN_PER_CURVE`] segments. Art reaching past +/// [`MAX_STENCIL_EXTENT`] is refused. +fn contours(region: &PathData, width: f64) -> Result, PathOpsError> { + if !within_extent(®ion.to_bezpath()) { + return Err(PathOpsError::TooComplex); + } + let tolerance = (width / 20.0).clamp(0.002, 0.25); + let mut out: Vec = vec![]; + let mut segments = 0usize; + for sp in ®ion.subpaths { + let room = MAX_STENCIL_SEGMENTS.saturating_sub(segments); + let mut bp = BezPath::new(); + sp.to_bezpath_into(&mut bp); + let mut points = follow(&bp, tolerance, room)?; + while points.len() > 1 && points.first() == points.last() { + points.pop(); + } + if points.len() < 3 { + continue; + } + segments += points.len(); + if segments > MAX_STENCIL_SEGMENTS || out.len() >= MAX_STENCIL_CONTOURS { + return Err(PathOpsError::TooComplex); + } + if points.iter().any(|p| !(p.x.is_finite() && p.y.is_finite())) { + return Err(PathOpsError::NonFinite); + } + let area = signed_area(&points).abs(); + let bounds = points.iter().fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| { + (b.0.min(p.x), b.1.min(p.y), b.2.max(p.x), b.3.max(p.y)) + }); + out.push(Contour { points, hole: false, area, bounds }); + } + // An outline too small to see is dropped, so its inside counts as art (or, for a speck of art, + // as the area around it). + let tiny = (tolerance * tolerance).max(1e-9); + out.retain(|c| c.area > tiny); + // An outline inside an odd number of others bounds an island. Each outline is placed by the + // middle of its longest edge: outlines of an area with no overlaps meet at most at corners, so + // that point lies on no other outline. + let mut work = 0usize; + let mut depths = Vec::with_capacity(out.len()); + for (i, c) in out.iter().enumerate() { + let p = longest_edge_middle(&c.points); + let mut depth = 0usize; + for (j, o) in out.iter().enumerate() { + if j == i || o.area < c.area || !holds_box(o.bounds, c.bounds) { + continue; + } + work += o.points.len(); + if work > MAX_NESTING_WORK { + return Err(PathOpsError::TooComplex); + } + if contains(o, p) { + depth += 1; + } + } + depths.push(depth); + } + for (c, depth) in out.iter_mut().zip(depths) { + c.hole = depth % 2 == 1; + } + Ok(out) +} + +/// The middle of the longest edge of the closed outline through `points`. +fn longest_edge_middle(points: &[Point]) -> Point { + let n = points.len(); + (0..n) + .map(|i| (points[i], points[(i + 1) % n])) + .max_by(|(a, b), (c, d)| a.distance(*b).total_cmp(&c.distance(*d))) + .map_or(Point::ZERO, |(a, b)| a.midpoint(b)) +} + +/// Whether box `outer` holds box `inner`, edges included. Boxes are (min x, min y, max x, max y). +fn holds_box(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64)) -> bool { + outer.0 <= inner.0 && outer.1 <= inner.1 && outer.2 >= inner.2 && outer.3 >= inner.3 +} + +/// Whether `bp` and its handles stay within [`MAX_STENCIL_EXTENT`] of the origin. +fn within_extent(bp: &BezPath) -> bool { + let reach = bp.control_box(); + [reach.x0, reach.y0, reach.x1, reach.y1].iter().all(|v| v.abs() <= MAX_STENCIL_EXTENT) +} + +/// The points that follow outline `bp` within `tolerance`, segment by segment, or +/// [`PathOpsError::TooComplex`] once they pass `room` (and one more for a closed outline's +/// repeated start, which the caller drops). It stops at the first segment past the limit. +fn follow(bp: &BezPath, tolerance: f64, room: usize) -> Result, PathOpsError> { + let limit = room.saturating_add(1); + let mut points: Vec = vec![]; + for seg in bp.segments() { + if points.is_empty() { + points.push(seg.start()); + } + let mut over = false; + vectorcraft_geom::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tolerance, |el| match el { + PathEl::LineTo(p) if points.len() < limit => points.push(p), + PathEl::LineTo(_) => over = true, + _ => {} + }); + if over { + return Err(PathOpsError::TooComplex); + } + } + Ok(points) +} + +fn signed_area(points: &[Point]) -> f64 { + let n = points.len(); + (0..n).map(|i| points[i].to_vec2().cross(points[(i + 1) % n].to_vec2())).sum::() / 2.0 +} + +/// Whether `p` is inside contour `c` (even-odd). +fn contains(c: &Contour, p: Point) -> bool { + let (x0, y0, x1, y1) = c.bounds; + if p.x < x0 || p.x > x1 || p.y < y0 || p.y > y1 { + return false; + } + let n = c.points.len(); + let mut inside = false; + for i in 0..n { + let (a, b) = (c.points[i], c.points[(i + 1) % n]); + if (a.y <= p.y) != (b.y <= p.y) && p.x < a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) { + inside = !inside; + } + } + inside +} + +/// The area outside the art that a point is in: 0 for the sheet around the art, `i + 1` for the +/// island contour `i` bounds. +type Area = usize; +const SHEET: Area = 0; + +/// The art seen along lines in one direction: rotated so the lines run along x, with its segments +/// listed by the bands of y they reach. +struct View { + angle: f64, + /// Rotated segments, each with the contour it belongs to. + segments: Vec<(Point, Point, usize)>, + /// The lowest y of the rotated art. + y0: f64, + /// The band height, and the lines' spacing. + step: f64, + bands: Vec>, + /// Each contour's lowest and highest rotated y. + ranges: Vec<(f64, f64)>, + /// The segments lines may still look at; past it, every line is missing. + budget: Cell, +} + +/// Where a line crosses the art: the edges in order (even-odd pairs are the art), and the area +/// each stretch outside the art is in (one more than there are pairs). +struct Line { + xs: Vec, + areas: Vec, +} + +impl View { + fn new(contours: &[Contour], angle: f64, width: f64, budget: usize) -> Result { + let (sin, cos) = angle.sin_cos(); + let rotate = |p: Point| Point::new(p.x * cos + p.y * sin, -p.x * sin + p.y * cos); + let mut segments = vec![]; + let mut ranges = vec![]; + let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); + for (k, c) in contours.iter().enumerate() { + let pts: Vec = c.points.iter().map(|p| rotate(*p)).collect(); + let (lo, hi) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y))); + ranges.push((lo, hi)); + y0 = y0.min(lo); + y1 = y1.max(hi); + for i in 0..pts.len() { + segments.push((pts[i], pts[(i + 1) % pts.len()], k)); + } + } + let span = (y1 - y0).max(width); + let step = (width / 2.0).max(span / MAX_LINES); + let count = (span / step).ceil() as usize + 1; + let mut bands: Vec> = vec![vec![]; count]; + let band = |y: f64| (((y - y0) / step).floor().max(0.0) as usize).min(count - 1); + let mut entries = 0usize; + for (i, (a, b, _)) in segments.iter().enumerate() { + let (from, to) = (band(a.y.min(b.y)), band(a.y.max(b.y))); + entries += to - from + 1; + if entries > MAX_BIN_ENTRIES { + return Err(PathOpsError::TooComplex); + } + for list in &mut bands[from..=to] { + list.push(i as u32); + } + } + Ok(View { angle, segments, y0, step, bands, ranges, budget: Cell::new(budget) }) + } + + /// Whether lines looked at more segments than the budget allows. + fn exhausted(&self) -> bool { + self.budget.get() == 0 + } + + /// The art along the line at rotated y `y`, `None` where its edges don't pair up or the budget + /// is spent. + fn line(&self, y: f64, contours: &[Contour]) -> Option { + let band = ((y - self.y0) / self.step).floor(); + let list = self.bands.get(if band < 0.0 { usize::MAX } else { band as usize })?; + let left = self.budget.get().checked_sub(list.len()).filter(|left| *left > 0); + self.budget.set(left.unwrap_or(0)); + left?; + let mut hits: Vec<(f64, usize)> = vec![]; + for &i in list { + let (a, b, k) = self.segments[i as usize]; + if (a.y <= y) != (b.y <= y) { + hits.push((a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y), k)); + } + } + if hits.len() % 2 == 1 { + return None; + } + hits.sort_by(|p, q| p.0.total_cmp(&q.0)); + // Walking along the line, each contour crossed an odd number of times so far holds the + // point; the smallest island contour holding a stretch outside the art is its island. + let mut open: Vec = vec![]; + let mut areas = vec![SHEET]; + for (n, (_, k)) in hits.iter().enumerate() { + match open.iter().position(|o| o == k) { + Some(at) => { + open.swap_remove(at); + } + None => open.push(*k), + } + if n % 2 == 1 { + let island = open.iter().filter(|o| contours[**o].hole).min_by(|a, b| contours[**a].area.total_cmp(&contours[**b].area)); + areas.push(island.map_or(SHEET, |k| k + 1)); + } + } + Some(Line { xs: hits.into_iter().map(|(x, _)| x).collect(), areas }) + } + + /// The point at rotated `(x, y)` in the art's own coordinates. + fn unrotate(&self, x: f64, y: f64) -> Point { + let (sin, cos) = self.angle.sin_cos(); + Point::new(x * cos - y * sin, x * sin + y * cos) + } +} + +/// A crossing of the art along one line, between the areas on either side of it. +#[derive(Clone, Copy, Debug)] +struct Crossing { + view: usize, + y: f64, + /// Where it enters and leaves the art. + from: f64, + to: f64, + areas: (Area, Area), + /// How far its line is from the middle of the island it starts from. + off_middle: f64, +} + +impl Crossing { + fn len(&self) -> f64 { + self.to - self.from + } +} + +/// A bridge that fits: its crossing, its middle and its strip's corners. +struct Bridge { + areas: (Area, Area), + middle: Point, + strip: Vec, +} + +struct Picker<'a> { + contours: &'a [Contour], + views: &'a [View], + width: f64, + crossings: Vec, + /// The crossings that touch each area. + touching: Vec>, + parent: Vec, + chosen: Vec, +} + +impl<'a> Picker<'a> { + fn new(contours: &'a [Contour], views: &'a [View], width: f64) -> Self { + let areas = contours.len() + 1; + let holes = contours.iter().filter(|c| c.hole).count().max(1); + // Each island is looked across along lines through its middle and out from it on both + // sides, half a width apart, or fewer where there are many islands. + let each_side = (MAX_ISLAND_LINES / (holes * views.len()).max(1)).clamp(1, MAX_LINES_PER_SIDE) as f64; + let mut crossings = vec![]; + for (v, view) in views.iter().enumerate() { + for (k, _) in contours.iter().enumerate().filter(|(_, c)| c.hole) { + let island = k + 1; + let (lo, hi) = view.ranges[k]; + let (middle, reach) = ((lo + hi) / 2.0, (hi - lo) / 2.0); + let step = (width / 2.0).max(reach / each_side); + let mut ys = vec![middle]; + let mut d = step; + while d < reach { + ys.extend([middle - d, middle + d]); + d += step; + } + for y in ys { + let Some(line) = view.line(y, contours) else { continue }; + for (j, pair) in line.xs.as_chunks::<2>().0.iter().enumerate() { + let (a, b) = (line.areas[j], line.areas[j + 1]); + if a == b || (a != island && b != island) { + continue; + } + crossings.push(Crossing { view: v, y, from: pair[0], to: pair[1], areas: (a, b), off_middle: (y - middle).abs() }); + } + } + } + } + // Shortest first; among crossings of about the same length, the one nearest the middle of + // its island. + let grain = width * 0.05; + crossings.sort_by(|p, q| (p.len() / grain).round().total_cmp(&(q.len() / grain).round()).then(p.off_middle.total_cmp(&q.off_middle))); + let mut touching = vec![vec![]; areas]; + for (i, c) in crossings.iter().enumerate() { + touching[c.areas.0].push(i); + touching[c.areas.1].push(i); + } + Picker { contours, views, width, crossings, touching, parent: (0..areas).collect(), chosen: vec![] } + } + + fn root(&mut self, mut a: usize) -> usize { + while self.parent[a] != a { + self.parent[a] = self.parent[self.parent[a]]; + a = self.parent[a]; + } + a + } + + /// Join every island to the sheet by the shortest crossings that fit, as a spanning tree. Bridges + /// that only join islands that stay apart from the sheet are not cut. + fn connect(&mut self) { + let mut apart = self.contours.iter().filter(|c| c.hole).count(); + for i in 0..self.crossings.len() { + if apart == 0 { + break; + } + let c = self.crossings[i]; + let (a, b) = (self.root(c.areas.0), self.root(c.areas.1)); + if a == b { + continue; + } + if let Some(bridge) = self.fit(&c) { + self.parent[a] = b; + self.chosen.push(bridge); + apart -= 1; + } + } + let sheet = self.root(SHEET); + for bridge in std::mem::take(&mut self.chosen) { + if self.root(bridge.areas.0) == sheet { + self.chosen.push(bridge); + } + } + } + + /// Give each island more bridges, up to `count`, each far from the island's others (at least + /// two widths away, and as short as can be among those at least 80% as far as the farthest), + /// and no more than twice as long as its shortest crossing that a bridge fits plus a width. + /// A crossing a bridge does not fit is not tried again, and once the lines' budget is spent no + /// more bridges are found. + fn spread(&mut self, count: usize) { + let sheet = self.root(SHEET); + let mut misfit = vec![false; self.crossings.len()]; + for island in 1..self.touching.len() { + if !self.contours[island - 1].hole || self.root(island) != sheet { + continue; + } + let mut limit = None; + loop { + let mine: Vec = self.chosen.iter().filter(|b| b.areas.0 == island || b.areas.1 == island).map(|b| b.middle).collect(); + if mine.len() >= count { + break; + } + let longest = match limit { + Some(l) => l, + None => { + // The island's crossings are listed shortest first, so the first that fits + // is its shortest bridge. + let mut shortest = None; + for &i in &self.touching[island] { + if misfit[i] { + continue; + } + if self.fit(&self.crossings[i]).is_some() { + shortest = Some(self.crossings[i].len()); + break; + } + misfit[i] = true; + } + let Some(shortest) = shortest else { break }; + *limit.insert(shortest * 2.0 + self.width) + } + }; + let mut ranked: Vec<(f64, usize)> = self.touching[island] + .iter() + .filter(|&&i| !misfit[i] && self.crossings[i].len() <= longest) + .map(|&i| { + let m = self.middle(&self.crossings[i]); + (mine.iter().map(|p| p.distance(m)).fold(f64::INFINITY, f64::min), i) + }) + .filter(|(d, _)| *d >= self.width * 2.0) + .collect(); + // The crossings nearly as far as the farthest go first, in the order `connect` + // takes them (shortest, then nearest the middle); then the rest, farthest first. + let far = ranked.iter().map(|r| r.0).fold(0.0, f64::max) * 0.8; + ranked.sort_by(|p, q| (q.0 >= far).cmp(&(p.0 >= far)).then_with(|| if p.0 >= far { p.1.cmp(&q.1) } else { q.0.total_cmp(&p.0) })); + let mut found = None; + for &(_, i) in &ranked { + match self.fit(&self.crossings[i]) { + Some(bridge) => { + found = Some(bridge); + break; + } + None => misfit[i] = true, + } + } + match found { + Some(bridge) => self.chosen.push(bridge), + None => break, + } + if self.views.iter().any(View::exhausted) { + return; + } + } + } + } + + /// The islands not joined to the sheet. + fn unjoined(&mut self) -> usize { + let sheet = self.root(SHEET); + (1..self.parent.len()).filter(|&a| self.contours[a - 1].hole).filter(|&a| self.root(a) != sheet).count() + } + + fn middle(&self, c: &Crossing) -> Point { + self.views[c.view].unrotate((c.from + c.to) / 2.0, c.y) + } + + /// The bridge of crossing `c`, if a strip of the width fits there: along lines across its + /// width, the art is crossed once between the same two areas, and the strip ends outside the + /// art on each of them. + fn fit(&self, c: &Crossing) -> Option { + let view = &self.views[c.view]; + let half = self.width / 2.0; + let (mut start, mut end) = (f64::INFINITY, f64::NEG_INFINITY); + let (mut room_before, mut room_after) = (f64::NEG_INFINITY, f64::INFINITY); + for k in 0..CHECKS { + let y = c.y - half + self.width * k as f64 / (CHECKS - 1) as f64; + let line = view.line(y, self.contours)?; + let last = line.areas.len() - 1; + // The crossing between the same two areas that overlaps this one, the nearest if several do. + let (j, _) = line + .xs + .as_chunks::<2>() + .0 + .iter() + .enumerate() + .filter(|(j, p)| (line.areas[*j], line.areas[j + 1]) == c.areas && p[1] > c.from - self.width && p[0] < c.to + self.width) + .min_by(|(_, p), (_, q)| (p[0] - c.from).abs().total_cmp(&(q[0] - c.from).abs()))?; + start = start.min(line.xs[2 * j]); + end = end.max(line.xs[2 * j + 1]); + if j > 0 { + room_before = room_before.max(line.xs[2 * j - 1]); + } + if j + 1 < last { + room_after = room_after.min(line.xs[2 * j + 2]); + } + } + if room_before >= start || room_after <= end { + return None; + } + // The strip reaches a little past the art at each end, so no sliver of it is left. + let reach = self.width.min(1.0); + let s0 = (start - reach).max((room_before + start) / 2.0); + let s1 = (end + reach).min((room_after + end) / 2.0); + let strip = [(s0, c.y - half), (s1, c.y - half), (s1, c.y + half), (s0, c.y + half)].map(|(x, y)| view.unrotate(x, y)).to_vec(); + Some(Bridge { areas: c.areas, middle: self.middle(c), strip }) + } +} diff --git a/crates/pathops/src/addons/stencil/tests.rs b/crates/pathops/src/addons/stencil/tests.rs new file mode 100644 index 0000000000..48d2f8148e --- /dev/null +++ b/crates/pathops/src/addons/stencil/tests.rs @@ -0,0 +1,326 @@ +use kurbo::{BezPath, Point, Shape}; +use vectorcraft_geom::{FillRule, PathData, SubPath}; + +use super::*; + +fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath { + SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) +} + +/// A square ring: `outer` with the hole `inner`, each (x0, y0, x1, y1). +fn ring(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64)) -> PathData { + PathData::new(vec![rect(outer.0, outer.1, outer.2, outer.3), rect(inner.0, inner.1, inner.2, inner.3)]) +} + +fn spec(width: f64, count: usize, direction: BridgeDirection) -> BridgeSpec { + BridgeSpec { width, count, direction } +} + +fn run(art: &PathData, spec: BridgeSpec) -> Bridged { + stencil_bridges(&[(art, FillRule::EvenOdd)], spec).unwrap() +} + +/// Is `(x, y)` inside the area `p` draws? +fn inside(p: &PathData, x: f64, y: f64) -> bool { + p.to_bezpath().winding(Point::new(x, y)) != 0 +} + +/// The islands left once the bridges are cut. +fn islands_left(b: &Bridged) -> usize { + run(&b.path, spec(1.0, 1, BridgeDirection::Shortest)).islands +} + +/// A letter O made square: 100 pt across with bars 20 pt thick. +fn letter_o() -> PathData { + ring((0.0, 0.0, 100.0, 100.0), (20.0, 20.0, 80.0, 80.0)) +} + +#[test] +fn an_o_gets_a_bridge_and_its_island_joins_the_sheet() { + let b = run(&letter_o(), spec(4.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (1, 1, 0)); + assert_eq!(islands_left(&b), 0, "the inside of the O is joined to the sheet"); + // The bridge crosses the top bar at the middle of the hole; the rest of the O stays. + assert!(!inside(&b.path, 50.0, 10.0)); + assert!(inside(&b.path, 30.0, 10.0) && inside(&b.path, 50.0, 90.0) && inside(&b.path, 10.0, 50.0)); +} + +#[test] +fn the_bridge_is_as_wide_as_asked() { + for width in [2.0, 6.0] { + let b = run(&letter_o(), spec(width, 1, BridgeDirection::Vertical)); + let half = width / 2.0; + assert!(!inside(&b.path, 50.0 - half * 0.9, 10.0) && !inside(&b.path, 50.0 + half * 0.9, 10.0), "{width}"); + assert!(inside(&b.path, 50.0 - half * 1.2, 10.0) && inside(&b.path, 50.0 + half * 1.2, 10.0), "{width}"); + } +} + +#[test] +fn horizontal_bridges_cross_the_sides() { + let b = run(&letter_o(), spec(4.0, 1, BridgeDirection::Horizontal)); + assert_eq!((b.bridges, islands_left(&b)), (1, 0)); + assert!(!inside(&b.path, 10.0, 50.0) || !inside(&b.path, 90.0, 50.0)); + assert!(inside(&b.path, 50.0, 10.0) && inside(&b.path, 50.0, 90.0)); +} + +#[test] +fn the_shortest_crossing_takes_the_thinnest_bar() { + // The left bar is 5 pt thick, the others 20. + let art = ring((0.0, 0.0, 100.0, 100.0), (5.0, 20.0, 80.0, 80.0)); + let b = run(&art, spec(4.0, 1, BridgeDirection::Shortest)); + assert_eq!((b.bridges, islands_left(&b)), (1, 0)); + assert!(!inside(&b.path, 2.5, 50.0), "the bridge crosses the thin bar"); + assert!(inside(&b.path, 50.0, 10.0) && inside(&b.path, 90.0, 50.0)); + // Vertical bridges cross a 20 pt bar instead. + let v = run(&art, spec(4.0, 1, BridgeDirection::Vertical)); + assert!(inside(&v.path, 2.5, 50.0) && !inside(&v.path, 42.5, 10.0)); +} + +#[test] +fn a_shape_with_two_holes_gets_a_bridge_for_each() { + let art = PathData::new(vec![rect(0.0, 0.0, 100.0, 50.0), rect(10.0, 10.0, 40.0, 40.0), rect(60.0, 10.0, 90.0, 40.0)]); + let b = run(&art, spec(3.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (2, 2, 0)); + assert_eq!(islands_left(&b), 0); + assert!(!inside(&b.path, 25.0, 5.0) && !inside(&b.path, 75.0, 5.0)); +} + +#[test] +fn more_bridges_per_island_spread_around_it() { + let b = run(&letter_o(), spec(4.0, 2, BridgeDirection::Vertical)); + assert_eq!(b.bridges, 2); + assert!(!inside(&b.path, 50.0, 10.0) && !inside(&b.path, 50.0, 90.0), "one at the top, one at the bottom"); +} + +#[test] +fn a_short_crossing_too_narrow_for_a_bridge_does_not_limit_the_others() { + // Hole A has a 2 pt slot B just above it: the 5 pt crossing from A to B is the shortest, but + // no 4 pt bridge fits there. A's 20 pt crossings to the sheet take its two bridges. + let art = PathData::new(vec![rect(0.0, 0.0, 200.0, 100.0), rect(20.0, 20.0, 80.0, 80.0), rect(49.0, 5.0, 51.0, 15.0)]); + let b = run(&art, spec(4.0, 2, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (2, 2, 1)); +} + +#[test] +fn islands_that_only_reach_each_other_get_no_bridge() { + // Holes Y and X, one above the other, with combs of 2 pt slots above Y and below X: no 4 pt + // vertical bridge reaches the sheet from either, so the bridge that fits between them would + // join nothing to the sheet. + let mut subpaths = vec![rect(0.0, 0.0, 100.0, 100.0), rect(20.0, 20.0, 80.0, 30.0), rect(30.0, 40.0, 70.0, 60.0)]; + for k in 0..27 { + let x = 10.0 + 3.0 * k as f64; + subpaths.push(rect(x, 5.0, x + 2.0, 15.0)); + subpaths.push(rect(x, 85.0, x + 2.0, 95.0)); + } + let b = run(&PathData::new(subpaths), spec(4.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (56, 0, 56)); +} + +#[test] +fn an_island_inside_an_island_is_joined_through_it() { + // A ring in the hole of a ring: two islands, the gap between the rings and the inner hole. + let art = + PathData::new(vec![rect(0.0, 0.0, 100.0, 100.0), rect(10.0, 10.0, 90.0, 90.0), rect(30.0, 30.0, 70.0, 70.0), rect(40.0, 40.0, 60.0, 60.0)]); + let b = run(&art, spec(2.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (2, 2, 0)); + assert_eq!(islands_left(&b), 0); +} + +#[test] +fn an_island_too_narrow_for_the_width_is_counted() { + let art = ring((0.0, 0.0, 100.0, 100.0), (49.0, 20.0, 51.0, 80.0)); + let b = run(&art, spec(4.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (1, 0, 1)); + assert_eq!(islands_left(&b), 1); +} + +#[test] +fn art_without_islands_is_left_whole() { + let art = PathData::new(vec![rect(0.0, 0.0, 50.0, 50.0), rect(100.0, 0.0, 150.0, 50.0)]); + let b = run(&art, spec(4.0, 1, BridgeDirection::Shortest)); + assert_eq!((b.islands, b.bridges, b.unbridged), (0, 0, 0)); + assert!(inside(&b.path, 25.0, 25.0) && inside(&b.path, 125.0, 25.0)); +} + +#[test] +fn the_shortest_crossing_may_run_at_an_angle() { + // A diamond ring: its walls are 20 pt across horizontally and vertically but about 14 pt + // across at 45°, so only a diagonal line finds the shortest crossing. + let diamond = |r: f64| { + let pts = [(100.0, 100.0 - r), (100.0 + r, 100.0), (100.0, 100.0 + r), (100.0 - r, 100.0)].map(|(x, y)| Point::new(x, y)); + SubPath::polyline(&pts, true) + }; + let art = PathData::new(vec![diamond(60.0), diamond(40.0)]); + let b = run(&art, spec(2.0, 1, BridgeDirection::Shortest)); + assert_eq!((b.bridges, islands_left(&b)), (1, 0)); + // The middles of the four diagonal walls: one is cut. The middles of the walls straight + // above, below and beside the hole all stay. + let diagonal = [(125.0, 125.0), (75.0, 75.0), (125.0, 75.0), (75.0, 125.0)]; + assert!(diagonal.iter().any(|&(x, y)| !inside(&b.path, x, y)), "a diagonal bridge"); + for (x, y) in [(150.0, 100.0), (50.0, 100.0), (100.0, 150.0), (100.0, 50.0)] { + assert!(inside(&b.path, x, y), "({x}, {y}) stays"); + } + let h = run(&art, spec(2.0, 1, BridgeDirection::Horizontal)); + assert!(diagonal.iter().all(|&(x, y)| inside(&h.path, x, y))); +} + +#[test] +fn a_hole_touching_another_outline_at_a_corner_is_still_an_island() { + // A diamond hole whose bottom corner, where its outline starts, touches the bottom edge of the + // square around it. + let hole = SubPath::polyline(&[(50.0, 100.0), (70.0, 70.0), (50.0, 40.0), (30.0, 70.0)].map(|(x, y)| Point::new(x, y)), true); + let region = PathData::new(vec![rect(0.0, 0.0, 100.0, 100.0), hole]); + let found = contours(®ion, 1.0).unwrap(); + assert_eq!(found.iter().filter(|c| c.hole).count(), 1); +} + +#[test] +fn a_sliver_too_small_to_see_counts_as_art() { + // A hole 10 pt long and 0.005 pt high between the island and the top edge: a bridge that + // reaches only the sliver would join nothing to the sheet. + let region = PathData::new(vec![rect(0.0, 0.0, 100.0, 100.0), rect(40.0, 40.0, 60.0, 60.0), rect(45.0, 20.0, 55.0, 20.005)]); + let b = bridge_region(region, spec(5.0, 1, BridgeDirection::Vertical)).unwrap(); + assert_eq!((b.islands, b.bridges, b.unbridged), (1, 1, 0)); + assert_eq!(islands_left(&b), 0); +} + +#[test] +fn extra_bridges_stay_two_widths_apart() { + // Sixteen 10 pt bridges have no room around a hole 60 pt across with 20 pt between them. + let width = 10.0; + let found = contours(&letter_o(), width).unwrap(); + let (bridges, unjoined) = choose(&found, spec(width, 16, BridgeDirection::Shortest), MAX_WORK).unwrap(); + assert!(bridges.len() > 4 && bridges.len() < 16, "{}", bridges.len()); + assert_eq!(unjoined, 0); + for (i, a) in bridges.iter().enumerate() { + for b in &bridges[i + 1..] { + assert!(a.middle.distance(b.middle) >= width * 2.0, "{:?} {:?}", a.middle, b.middle); + } + } +} + +#[test] +fn bridges_found_before_the_budget_runs_out_are_kept() { + let found = contours(&letter_o(), 4.0).unwrap(); + // The least work that joins the island with one bridge also gives one with up to 16 asked + // for: the extra bridges stop where the budget ends. + let least = (1..100_000).find(|&work| choose(&found, spec(4.0, 1, BridgeDirection::Vertical), work).is_ok()).unwrap(); + let (bridges, unjoined) = choose(&found, spec(4.0, 16, BridgeDirection::Vertical), least).unwrap(); + assert_eq!((bridges.len(), unjoined), (1, 0)); + assert!(matches!(choose(&found, spec(4.0, 1, BridgeDirection::Vertical), least - 1), Err(PathOpsError::TooComplex))); +} + +#[test] +fn curves_are_followed_within_a_twentieth_of_the_width() { + let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.001)); + let mut counts = vec![]; + for (width, tolerance) in [(0.01, 0.002), (1.0, 0.05), (20.0, 0.25)] { + let found = contours(&circle, width).unwrap(); + let points = &found[0].points; + // The sag of each chord below the circle. + let sag = (0..points.len()).map(|i| 100.0 - points[i].midpoint(points[(i + 1) % points.len()]).to_vec2().hypot()).fold(0.0, f64::max); + assert!(sag <= tolerance + 0.002, "{width}: {sag}"); + counts.push(points.len()); + } + assert!(counts[0] > counts[1] && counts[1] > counts[2], "{counts:?}"); +} + +#[test] +fn open_outlines_are_closed_as_a_fill_closes_them() { + let mut art = letter_o(); + art.subpaths[0].closed = false; + let b = run(&art, spec(4.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, b.unbridged), (1, 1, 0)); +} + +#[test] +fn art_too_large_is_refused_before_it_is_combined() { + let far = PathData::new(vec![rect(0.0, 0.0, 1e8, 1e8)]); + let points: Vec = (0..=MAX_STENCIL_SEGMENTS).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64)).collect(); + let dense = PathData::new(vec![SubPath::polyline(&points, true)]); + assert!(too_large(&[(&far, FillRule::EvenOdd)]) && too_large(&[(&dense, FillRule::EvenOdd)])); + assert!(!too_large(&[(&letter_o(), FillRule::EvenOdd), (&letter_o(), FillRule::NonZero)])); +} + +#[test] +fn outlines_nested_too_deep_to_place_are_refused() { + // Rings of 190-sided polygons, one inside the next: placing each inside the others takes + // about 95 million tests of a point against an edge with 1000 of them, and 8 million with 300. + let polygon = |r: f64| { + let pts: Vec = (0..190).map(|i| (i as f64 / 190.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| Point::new(c * r, s * r)).collect(); + SubPath::polyline(&pts, true) + }; + let nested = |n: usize| PathData::new((0..n).map(|i| polygon(10.0 + i as f64)).collect()); + assert!(contours(&nested(300), 1.0).is_ok()); + assert!(matches!(contours(&nested(1000), 1.0), Err(PathOpsError::TooComplex))); +} + +#[test] +fn a_curved_o_is_bridged_at_its_thinnest() { + // Two circles, the hole 8 pt above the middle: the O is 2 pt thick at the top and 18 pt at + // the bottom. + let circle = |cy: f64, r: f64| PathData::from_bezpath(&kurbo::Circle::new((100.0, cy), r).to_path(0.01)); + let mut art = circle(100.0, 60.0); + art.subpaths.extend(circle(92.0, 50.0).subpaths); + let b = run(&art, spec(1.0, 1, BridgeDirection::Vertical)); + assert_eq!((b.islands, b.bridges, islands_left(&b)), (1, 1, 0)); + assert!(!inside(&b.path, 100.0, 41.0), "it crosses the thin top"); + assert!(inside(&b.path, 100.0, 151.0), "the thick bottom stays"); +} + +#[test] +fn art_with_too_many_outlines_is_refused() { + let squares = (0..MAX_STENCIL_CONTOURS + 1) + .map(|i| { + let (x, y) = ((i % 100) as f64 * 3.0, (i / 100) as f64 * 3.0); + rect(x, y, x + 1.0, y + 1.0) + }) + .collect(); + let region = PathData::new(squares); + assert!(matches!(contours(®ion, 1.0), Err(PathOpsError::TooComplex))); + let mut fewer = region.clone(); + fewer.subpaths.pop(); + assert_eq!(contours(&fewer, 1.0).unwrap().len(), MAX_STENCIL_CONTOURS); +} + +#[test] +fn art_as_large_as_a_document_holds_is_followed_and_larger_art_is_refused() { + // An O 8000000 pt across, the most a document holds, with a 60 pt hole near its edge, followed + // within 0.002 pt. + let circle = |c: (f64, f64), r: f64| PathData::from_bezpath(&kurbo::Circle::new(c, r).to_path(0.01)); + let mut art = circle((0.0, 0.0), 4e6); + art.subpaths.extend(circle((0.0, 0.0), 2e6).subpaths); + art.subpaths.extend(circle((4e6 - 100.0, 0.0), 30.0).subpaths); + let found = contours(&art, 0.01).unwrap(); + assert_eq!(found.iter().filter(|c| c.hole).count(), 2); + let far = PathData::new(vec![rect(0.0, 0.0, 1e8, 1e8)]); + assert!(matches!(contours(&far, 0.01), Err(PathOpsError::TooComplex))); + let r = stencil_bridges(&[(&far, FillRule::EvenOdd)], spec(4.0, 1, BridgeDirection::Vertical)); + assert!(matches!(r, Err(PathOpsError::TooComplex))); +} + +#[test] +fn art_with_too_many_anchors_is_refused() { + let points: Vec = (0..=MAX_STENCIL_SEGMENTS).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64)).collect(); + let art = PathData::new(vec![SubPath::polyline(&points, true)]); + let r = stencil_bridges(&[(&art, FillRule::EvenOdd)], spec(4.0, 1, BridgeDirection::Vertical)); + assert!(matches!(r, Err(PathOpsError::TooComplex))); +} + +#[test] +fn following_stops_past_the_limit_and_allows_the_closing_point() { + // A closed square: its four corners and the start again. + let square = rect(0.0, 0.0, 10.0, 10.0); + let mut bp = BezPath::new(); + square.to_bezpath_into(&mut bp); + assert_eq!(follow(&bp, 0.01, 4).unwrap().len(), 5); + assert!(matches!(follow(&bp, 0.01, 3), Err(PathOpsError::TooComplex))); +} + +#[test] +fn bad_widths_and_counts_are_refused() { + let art = letter_o(); + for s in [spec(0.0, 1, BridgeDirection::Vertical), spec(f64::NAN, 1, BridgeDirection::Vertical), spec(4.0, 0, BridgeDirection::Vertical)] { + assert!(stencil_bridges(&[(&art, FillRule::EvenOdd)], s).is_err(), "{s:?}"); + } +} diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 5a19e62564..788cacea3a 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -53,7 +53,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let raised = for_long_pages(set, &pages)?; let set = raised.as_ref().unwrap_or(set); - let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone())); + // The file's Title is File Info's; the page information names the document, as its tab does. + let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.info_title().to_owned())); + let name = opts.title.as_deref().unwrap_or(&doc.title); // The time of writing (none on the web) dates the editing data and the page information. let now = vectorcraft_doc::metadata::output_time(); let created_at = opts.created.or(now); @@ -108,7 +110,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1)); let (m, r) = (boxes.media, boxes.bleed); - let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() }; + let info = if marks.page_info { crate::marks::page_info(doc, name, i, now) } else { String::new() }; let art = crate::marks::art(doc, &marks, &boxes, bleed, &info); // The page is the media box, y down from its top-left corner (like ours), in units of // `unit` points. diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 983a7dcea7..1cb0a45273 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -24,6 +24,8 @@ const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FileInfo { + /// XMP `dc:title` (its `x-default` item, else its first), else Info `Title`. + pub title: Option, /// XMP `dc:creator` (its names joined with ", "), else Info `Author`. pub author: Option, /// XMP `dc:description` (its `x-default` item, else its first), else Info `Subject`. @@ -53,6 +55,7 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo { let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default(); let info = pdf.metadata(); let text = |b: &Option>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + f.title = f.title.or_else(|| text(&info.title)); f.author = f.author.or_else(|| text(&info.author)); f.description = f.description.or_else(|| text(&info.subject)); f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty())); @@ -90,6 +93,7 @@ fn xmp(packet: &[u8]) -> Option { let creators = get(DC, "creator"); let subjects = get(DC, "subject"); Some(FileInfo { + title: first(DC, "title"), author: (!creators.is_empty()).then(|| creators.join(", ")), description: first(DC, "description"), keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) }, diff --git a/crates/pdf/src/tests/file_info.rs b/crates/pdf/src/tests/file_info.rs index 7ce2f42f47..4c837ba67f 100644 --- a/crates/pdf/src/tests/file_info.rs +++ b/crates/pdf/src/tests/file_info.rs @@ -45,7 +45,7 @@ fn description(attrs: &str, body: &str) -> String { } const INFO: &str = "/Author (Grace Hopper) /Subject /Keywords (navy; compilers, , cobol) \ - /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Ignored title)"; + /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Compiler manual)"; /// Unix seconds of 2026-10-05 12:03:00 UTC (14:03 at +02:00). const CREATED: i64 = 1_791_201_780; @@ -58,6 +58,7 @@ fn the_information_dictionary_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Compiler manual".into()), author: Some("Grace Hopper".into()), description: Some("Cata log".into()), keywords: Some(vec!["navy".into(), "compilers".into(), "cobol".into()]), @@ -79,7 +80,8 @@ fn the_information_dictionary_gives_file_info() { fn the_xmp_packet_gives_file_info() { let body = description( "xmpRights:Marked=\"False\" xmp:ModifyDate=\"2026-10-06T09:00:00.25Z\"", - "Ada LovelaceCharles Babbage\ + "PlakatPoster\ + Ada LovelaceCharles Babbage\ Ein PlakatA poster\nfor the fair\ fair print \ (c) 2026 Ada\ @@ -90,6 +92,7 @@ fn the_xmp_packet_gives_file_info() { assert_eq!( f, FileInfo { + title: Some("Poster".into()), author: Some("Ada Lovelace, Charles Babbage".into()), description: Some("A poster\nfor the fair".into()), keywords: Some(vec!["fair".into(), "print".into()]), @@ -138,6 +141,13 @@ fn the_xmp_packet_wins_where_both_give_a_field() { assert_eq!(f.created, Some(1_791_331_200 + 5 * 3600 + 30 * 60)); assert_eq!(f.description.as_deref(), Some("Cata log")); assert_eq!(f.modified, Some(MODIFIED)); + // The title is the packet's where it gives one (a blank `x-default` item leaves the next one), + // else the dictionary's. + assert_eq!(f.title.as_deref(), Some("Compiler manual")); + let body = + description("", " Manuel"); + let f = file_info(&pdf(Some(INFO), Some(&packet(&body))), None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Manuel")); } #[test] @@ -197,17 +207,29 @@ fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() { #[test] fn a_written_pdf_gives_its_file_info_back() { let mut d = Document::new(100.0, 100.0); + d.title = "Fair poster".into(); d.metadata.author = "Ada Lovelace".into(); d.metadata.description = "A poster\nfor the fair".into(); d.metadata.keywords = vec!["fair".into(), "print".into()]; let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); let f = file_info(&bytes, None).unwrap(); + assert_eq!(f.title.as_deref(), Some("Fair poster")); assert_eq!(f.author.as_deref(), Some("Ada Lovelace")); assert_eq!(f.description.as_deref(), Some("A poster\nfor the fair")); assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()])); assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED))); } +#[test] +fn a_written_pdf_carries_the_file_info_title_not_the_documents_name() { + let mut d = Document::new(100.0, 100.0); + d.title = "poster.svg".into(); + d.metadata.title = Some("Fair poster".into()); + let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap(); + assert_eq!(file_info(&bytes, None).unwrap().title.as_deref(), Some("Fair poster")); + assert!(!String::from_utf8_lossy(&bytes).contains("poster.svg")); +} + #[test] fn a_written_pdf_gives_its_copyright_back() { use vectorcraft_doc::CopyrightStatus; diff --git a/crates/plugins/src/deferred.rs b/crates/plugins/src/deferred.rs index 1645dd55e0..a29d877d44 100644 --- a/crates/plugins/src/deferred.rs +++ b/crates/plugins/src/deferred.rs @@ -19,7 +19,7 @@ //! [`revision`] changes, so that drawings made with the deferred stage are drawn again. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -106,9 +106,108 @@ enum Phase { Open, } +/// The deferred records in the order they were made, indexed by stage and cache key. A stage holds +/// at most one record for each key. +#[derive(Default)] +struct Records { + /// The order number of the next record. + next: u64, + by_order: BTreeMap, + /// For each stage with records, the order number of its record for each key. + by_stage: HashMap>, +} + +impl Records { + /// Every record, oldest first. + fn iter(&self) -> impl Iterator { + self.by_order.values().inspect(|_| looked_at(1)) + } + + /// The stages with records, in no particular order. + fn stages(&self) -> impl Iterator + '_ { + self.by_stage.keys().copied() + } + + /// The records of `stage`, in no particular order. + fn of(&self, stage: Stage) -> impl Iterator { + self.by_stage.get(&stage).into_iter().flat_map(|keys| keys.values()).filter_map(|n| { + looked_at(1); + self.by_order.get(n) + }) + } + + /// Is `stage` recorded with cache key `key`? + fn contains(&self, stage: Stage, key: u64) -> bool { + looked_at(1); + self.by_stage.get(&stage).is_some_and(|keys| keys.contains_key(&key)) + } + + /// Adds `d` as the newest record, in place of the record of its stage and key. + fn push(&mut self, d: Deferred) { + let order = self.next; + self.next = self.next.wrapping_add(1); + if let Some(old) = self.by_stage.entry(d.stage).or_default().insert(d.key, order) + && let Some(old) = self.by_order.remove(&old) + { + unmark(old.key); + } + self.by_order.insert(order, d); + } + + /// Drops the records of `stage` whose key `drop` gives true for. + fn drop_where(&mut self, stage: Stage, drop: impl Fn(u64) -> bool) { + let Some(keys) = self.by_stage.get_mut(&stage) else { return }; + looked_at(keys.len()); + let by_order = &mut self.by_order; + keys.retain(|&key, order| { + let keep = !drop(key); + if !keep && let Some(d) = by_order.remove(order) { + unmark(d.key); + } + keep + }); + if keys.is_empty() { + self.by_stage.remove(&stage); + } + } + + /// Drops the records for which `keep` is false. + fn retain(&mut self, mut keep: impl FnMut(&Deferred) -> bool) { + let by_stage = &mut self.by_stage; + self.by_order.retain(|_, d| { + looked_at(1); + let k = keep(d); + if !k { + if let Some(keys) = by_stage.get_mut(&d.stage) { + keys.remove(&d.key); + if keys.is_empty() { + by_stage.remove(&d.stage); + } + } + unmark(d.key); + } + k + }); + } +} + +#[cfg(test)] +thread_local! { + /// How many records the lookups on this thread have looked at. + static LOOKED_AT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` records looked at, in tests. +fn looked_at(n: usize) { + #[cfg(test)] + LOOKED_AT.with(|c| c.set(c.get() + n)); + #[cfg(not(test))] + let _ = n; +} + struct State { phase: Phase, - deferred: Vec, + deferred: Records, /// How many stages the first view left deferred, until [`Deferrals::take_report`] takes it. report: Option, /// The refresh running, if one is ([`Deferrals::refresh`]). @@ -123,13 +222,13 @@ struct Refresh { /// When its budget started (`None`: no run yet). started: Option, /// The objects whose stages run again; the others' stay deferred. - ids: Vec, + ids: HashSet, /// The cache keys whose cached failures have been run again. - retried: Vec, + retried: HashSet, /// The stages of `ids` that were drawn. - visited: Vec, + visited: HashSet, /// The stage inputs of `ids` that ran, or were taken from the cache, or were deferred again. - touched: Vec<(Stage, u64)>, + touched: HashSet<(Stage, u64)>, } impl State { @@ -155,7 +254,7 @@ impl State { impl Drop for State { fn drop(&mut self) { - for d in &self.deferred { + for d in self.deferred.iter() { unmark(d.key); } } @@ -197,7 +296,7 @@ impl Deferrals { pub fn with_clock(clock: Clock) -> Self { Self(Arc::new(Mutex::new(State { phase: Phase::Budgeted { started: None, first: true }, - deferred: Vec::new(), + deferred: Records::default(), report: None, refresh: None, clock, @@ -211,7 +310,7 @@ impl Deferrals { /// The deferred stages, by object and effect, each once (a stage drawn with several inputs, /// such as the paths of a group, is listed with the first one's reason). pub fn list(&self) -> Vec { - let mut out = self.state().deferred.clone(); + let mut out: Vec = self.state().deferred.iter().cloned().collect(); out.sort_by_key(|d| (d.stage.object, d.stage.item, d.stage.effect)); out.dedup_by_key(|d| d.stage); out @@ -238,13 +337,10 @@ impl Deferrals { if pass.ran.is_empty() { return; } - self.state().deferred.retain(|d| { - let keep = !pass.ran.contains(&d.stage) || pass.seen.contains(&(d.stage, d.key)); - if !keep { - unmark(d.key); - } - keep - }); + let mut s = self.state(); + for &stage in &pass.ran { + s.deferred.drop_where(stage, |key| !pass.seen.contains(&(stage, key))); + } } /// The first view has been drawn: later drawings leave deferred stages deferred, and @@ -271,14 +367,14 @@ impl Deferrals { pub fn refresh(&self, objects: Option<&[u64]>, run: impl FnOnce(&[u64]) -> Vec) -> Refreshed { let (targets, ids) = { let mut s = self.state(); - let mut targets: Vec = s.deferred.iter().map(|d| d.stage).filter(|t| objects.is_none_or(|o| o.contains(&t.object))).collect(); + let asked: Option> = objects.map(|o| o.iter().copied().collect()); + let mut targets: Vec = s.deferred.stages().filter(|t| asked.as_ref().is_none_or(|o| o.contains(&t.object))).collect(); targets.sort_by_key(|t| (t.object, t.item, t.effect)); - targets.dedup(); let mut ids: Vec = targets.iter().map(|t| t.object).collect(); ids.dedup(); let thread = std::thread::current().id(); - let (retried, visited, touched) = (Vec::new(), Vec::new(), Vec::new()); - s.refresh = Some(Refresh { thread, started: None, ids: ids.clone(), retried, visited, touched }); + let (retried, visited, touched) = (HashSet::new(), HashSet::new(), HashSet::new()); + s.refresh = Some(Refresh { thread, started: None, ids: ids.iter().copied().collect(), retried, visited, touched }); (targets, ids) }; // The refresh ends when `run` returns, also when it unwinds. @@ -289,20 +385,16 @@ impl Deferrals { } } let reopen = Reopen(self); - let whole = run(&ids); + let whole: HashSet = run(&ids).into_iter().collect(); let mut s = self.state(); let touched = match s.refresh.take() { Some(r) => { - s.deferred.retain(|d| { - let gone = r.visited.contains(&d.stage) && whole.contains(&d.stage.object) && !r.touched.contains(&(d.stage, d.key)); - if gone { - unmark(d.key); - } - !gone - }); + for &stage in r.visited.iter().filter(|t| whole.contains(&t.object)) { + s.deferred.drop_where(stage, |key| !r.touched.contains(&(stage, key))); + } r.touched } - None => Vec::new(), + None => HashSet::new(), }; drop(s); drop(reopen); @@ -310,11 +402,11 @@ impl Deferrals { // than time; one with records left that this refresh did not run is still deferred. let s = self.state(); let mut out = Refreshed::default(); - for t in &targets { - let mut left = s.deferred.iter().filter(|d| d.stage == *t).peekable(); + for &t in &targets { + let mut left = s.deferred.of(t).peekable(); if left.peek().is_none() { out.refreshed += 1; - } else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(d.stage, d.key))) { + } else if left.any(|d| d.reason != Reason::Budget && touched.contains(&(t, d.key))) { out.failed += 1; } else { out.still_deferred += 1; @@ -355,7 +447,7 @@ impl Deferrals { pub(crate) fn holds(&self, stage: Stage, key: u64) -> bool { let mut s = self.state(); let settled = s.phase_here() == Phase::Open || s.refreshes(stage.object) == Some(false); - let held = settled && s.deferred.iter().any(|d| d.stage == stage && d.key == key); + let held = settled && s.deferred.contains(stage, key); drop(s); if held { seen(stage, key); @@ -367,30 +459,22 @@ impl Deferrals { /// reached the stage. pub(crate) fn visit(&self, stage: Stage) { let mut s = self.state(); - if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.visited.contains(&stage)) { - r.visited.push(stage); + if let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object)) { + r.visited.insert(stage); } } /// Drops the records of the stages for which `keep` (given the stage and the plug-in id) is /// false, such as those of effects or objects removed from the document. pub fn retain(&self, keep: impl Fn(Stage, &str) -> bool) { - self.state().deferred.retain(|d| { - let k = keep(d.stage, &d.plugin); - if !k { - unmark(d.key); - } - k - }); + self.state().deferred.retain(|d| keep(d.stage, &d.plugin)); } /// Should the failure cached for `stage` with cache key `key` be run again? Yes once per key /// while a refresh running here runs the stages of `stage`'s object. pub(crate) fn retries(&self, stage: Stage, key: u64) -> bool { let mut s = self.state(); - let Some(r) = s.refresh_here().filter(|r| r.ids.contains(&stage.object) && !r.retried.contains(&key)) else { return false }; - r.retried.push(key); - true + s.refresh_here().filter(|r| r.ids.contains(&stage.object)).is_some_and(|r| r.retried.insert(key)) } /// What a run starting now may take of the budget: the budget starts with the first run. @@ -425,13 +509,7 @@ impl State { /// plug-in was not looked at) stands for any input, so it goes whatever `key` is, and `key` 0 /// removes every record of the stage. fn remove(&mut self, stage: Stage, key: u64) { - self.deferred.retain(|d| { - let keep = d.stage != stage || (d.key != key && d.key != 0 && key != 0); - if !keep { - unmark(d.key); - } - keep - }); + self.deferred.drop_where(stage, |k| k == key || k == 0 || key == 0); } /// `stage` was run with cache key `key`, or taken from the cache, by the drawing on this @@ -439,12 +517,12 @@ impl State { /// view [`Deferrals::settle`] looks at the stage when that drawing ends. fn ran_here(&mut self, stage: Stage, key: u64) { if let Some(r) = self.refresh_here().filter(|r| r.ids.contains(&stage.object)) { - r.touched.push((stage, key)); + r.touched.insert((stage, key)); } if self.phase_here() == Phase::Open { let _ = PASSES.try_with(|p| { - if let Some(pass) = p.borrow_mut().last_mut().filter(|pass| !pass.ran.contains(&stage)) { - pass.ran.push(stage); + if let Some(pass) = p.borrow_mut().last_mut() { + pass.ran.insert(stage); } }); } @@ -452,9 +530,8 @@ impl State { /// The deferred stages, each once. fn stages(&self) -> Vec { - let mut out: Vec = self.deferred.iter().map(|d| d.stage).collect(); + let mut out: Vec = self.deferred.stages().collect(); out.sort_by_key(|t| (t.object, t.item, t.effect)); - out.dedup(); out } } @@ -469,15 +546,15 @@ thread_local! { /// deferred again), and the stage inputs it left deferred. #[derive(Default)] struct Pass { - ran: Vec, - seen: Vec<(Stage, u64)>, + ran: HashSet, + seen: HashSet<(Stage, u64)>, } /// The drawing on this thread left `stage` deferred with cache key `key`. fn seen(stage: Stage, key: u64) { let _ = PASSES.try_with(|p| { if let Some(pass) = p.borrow_mut().last_mut() { - pass.seen.push((stage, key)); + pass.seen.insert((stage, key)); } }); } @@ -676,6 +753,79 @@ mod tests { assert_eq!(d.list().iter().map(|x| x.stage).collect::>(), [st(2, 0), st(3, 0)]); } + /// Objects asked for or drawn whole more than once count once; a stage with several inputs + /// gives a result when its object is drawn whole, fails when an input it ran with fails again + /// (even beside an older failure it did not run), and stays deferred when it is reached but + /// not drawn whole or not reached at all. + #[test] + fn a_refresh_with_repeated_objects_and_several_inputs_per_stage() { + const K: u64 = 0xdef1_0000; + let marked = |key| MARKED.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|m| m.contains_key(&key)); + let d = Deferrals::with_clock(stepping(1)); + d.first_view_done(); + let st = |object| Stage { object, effect: 0, item: None }; + for (object, reason, key) in [ + (10, Reason::Budget, K + 1), + (10, Reason::Budget, K + 2), + (11, Reason::Timeout, K + 3), + (11, Reason::Budget, K + 4), + (12, Reason::Timeout, K + 5), + (13, Reason::Budget, K + 6), + (14, Reason::Budget, K + 7), + ] { + d.defer(st(object), "a", reason, key); + } + let before = revision(); + let seen = std::cell::RefCell::new(vec![]); + let counts = d.refresh(Some(&[10, 11, 12, 13, 10, 11, 99]), |ids| { + seen.borrow_mut().extend_from_slice(ids); + d.visit(st(10)); + d.visit(st(10)); + d.ran(st(10), K + 1); + d.visit(st(11)); + d.defer(st(11), "a", Reason::Failed, K + 8); + d.visit(st(12)); + vec![10, 13, 10, 13] + }); + assert_eq!(*seen.borrow(), [10, 11, 12, 13]); + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 2, failed: 1 }); + assert!(revision() > before); + let keys = || d.state().deferred.iter().map(|x| (x.stage.object, x.key)).collect::>(); + assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]); + assert!(!marked(K + 1) && !marked(K + 2), "the records of the stage that gave a result"); + assert!(marked(K + 3) && marked(K + 8)); + + // Again, with nothing reached: every stage asked for stays as it was. + let counts = d.refresh(Some(&[11, 12, 12]), |_| vec![11, 12]); + assert_eq!(counts, Refreshed { refreshed: 0, still_deferred: 2, failed: 0 }); + assert_eq!(keys(), [(11, K + 3), (11, K + 4), (12, K + 5), (13, K + 6), (14, K + 7), (11, K + 8)]); + } + + /// A refresh whose drawing unwinds ends: later drawings on this thread are no longer part of + /// it, and the next refresh has a budget of its own. + #[test] + fn a_refresh_that_unwinds_ends() { + let d = Deferrals::with_clock(stepping(1)); + d.first_view_done(); + let s = Stage { object: 5, effect: 0, item: None }; + d.defer(s, "a", Reason::Budget, 0xdef2_0001); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + d.refresh(None, |_| { + assert!(!d.holds(s, 0xdef2_0001)); + panic!("the drawing stopped") + }) + })); + assert!(unwound.is_err()); + assert!(matches!(d.allowance(), Allowance::Unlimited)); + assert!(d.holds(s, 0xdef2_0001), "the stage stays deferred"); + let counts = d.refresh(Some(&[5]), |_| { + assert!(matches!(d.allowance(), Allowance::Until { .. })); + d.visit(s); + vec![5] + }); + assert_eq!(counts, Refreshed { refreshed: 1, still_deferred: 0, failed: 0 }); + } + /// After the first view, a stage drawn with several inputs (the paths of a group) whose one /// changed input runs keeps the records of the inputs the same drawing left deferred, and drops /// the record of the input it no longer has. @@ -724,4 +874,62 @@ mod tests { drop((d, other)); assert!(!marked(), "the document closed"); } + + /// Deferrals after the first view with `n` stages, one input each, keyed from `base`. + fn many(n: u64, base: u64) -> Deferrals { + let d = Deferrals::with_clock(stepping(1)); + for object in 0..n { + d.defer(Stage { object, effect: 0, item: None }, "a", Reason::Budget, base + object); + } + d.first_view_done(); + d + } + + /// How many records `f` looks at on this thread. + fn looked_at_by(f: impl FnOnce()) -> usize { + let before = LOOKED_AT.with(std::cell::Cell::get); + f(); + LOOKED_AT.with(std::cell::Cell::get) - before + } + + /// Asking whether a stage input stays deferred looks at that stage's records only, however + /// many other stages are deferred. + #[test] + fn holding_a_stage_looks_at_its_own_records_only() { + const K: u64 = 0xdef3_0000; + let d = many(2_000, K); + let s = Stage { object: 1_000, effect: 0, item: None }; + let n = looked_at_by(|| { + assert!(d.holds(s, K + 1_000)); + assert!(!d.holds(s, K + 1_001), "another stage's input"); + assert!(!d.holds(Stage { effect: 1, ..s }, K + 1_000), "another stage"); + }); + assert!(n <= 3, "looked at {n} records"); + } + + /// A stage that gives a result, or is deferred again, replaces its own records only, and so + /// does the end of a drawing after the first view: the other stages' records are not looked at. + #[test] + fn replacing_a_stage_record_looks_at_its_own_records_only() { + const K: u64 = 0xdef4_0000; + let d = many(2_000, K); + let st = |object| Stage { object, effect: 0, item: None }; + let n = looked_at_by(|| { + d.ran(st(1_000), K + 1_000); + d.defer(st(1_001), "a", Reason::Failed, K + 9_999); + d.defer(st(1_002), "a", Reason::Missing, 0); + d.ran(st(1_002), 7); + }); + assert!(n <= 6, "looked at {n} records"); + let n = looked_at_by(|| { + d.view(|| { + assert!(!d.holds(st(1_003), K + 9_998)); + d.ran(st(1_003), K + 9_998); + }); + }); + assert!(n <= 4, "looked at {n} records"); + let keys = d.state().deferred.iter().filter(|x| (1_000..1_004).contains(&x.stage.object)).map(|x| x.key).collect::>(); + assert_eq!(keys, [K + 1_001, K + 9_999]); + assert_eq!(d.list().len(), 1_997); + } } diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 3f5ebbb2fe..2b6610beb1 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -781,7 +781,7 @@ mod tests { d.symbols.push(Symbol { dynamic: false, name: format!("S{k}"), art: Arc::new(art) }); } let first = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S0".into(), xf: Affine::IDENTITY }); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(first)], kind: Box::new(kind), @@ -815,7 +815,7 @@ mod tests { d.symbols.push(Symbol { dynamic: false, name: name.into(), art: art.clone() }); } let content = ["A", "B", "C"].map(|s| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: s.into(), xf: Affine::IDENTITY }))); - let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: content.to_vec(), kind: Box::new(kind), diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 8f6d68f575..0e7864b0b9 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -338,7 +338,7 @@ fn enveloped_type_redraws_when_its_font_becomes_available() { let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) }); let l = d.layers[0].id; for (i, content) in [typed, shown].into_iter().enumerate() { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(content)], kind: Box::new(kind), @@ -376,7 +376,7 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() { let mut d = Document::new(300.0, 150.0); let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() }; let typed = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 80.0), "Hamburg", style)))); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![Arc::new(typed)], kind: Box::new(kind), @@ -420,7 +420,7 @@ fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() { const FAMILY: &str = "Mapped Serif 4"; let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() }; let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = Node::new( vectorcraft_doc::NodeId(1), NodeKind::Envelope { diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index d5e6919f0d..f95735baec 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1252,7 +1252,7 @@ fn art_with_live_objects_draws_pattern_layout_edits() { d.patterns.push(def); let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0)); let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() }; let envelope = Node::new( d.alloc_id(), @@ -1398,7 +1398,7 @@ fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() { /// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort /// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it. fn arc_envelope(d: &mut Document, content: Vec, patterns: bool) -> Node { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance: patterns, distort_pattern_fills: patterns, ..Default::default() }; let content = content.into_iter().map(Arc::new).collect(); Node::new( diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index fe1e5ee1d1..7e4007c6ac 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -439,7 +439,7 @@ fn transforms_of(t: &Node) -> [(Value, Affine); 3] { /// A 100×100 document holding `content` in an envelope that leaves it as it is (an arc with no /// bend), with Distort Appearance on or off. fn envelope_doc(content: Node, distort_appearance: bool) -> Document { - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance, ..Default::default() }; let envelope = NodeKind::Envelope { content: vec![Arc::new(content)], diff --git a/crates/render/src/tests/placed_document.rs b/crates/render/src/tests/placed_document.rs index c3c00a4cf0..3a65b1abe0 100644 --- a/crates/render/src/tests/placed_document.rs +++ b/crates/render/src/tests/placed_document.rs @@ -135,7 +135,7 @@ fn an_enveloped_placed_document_shows_its_file_once_read() { let placed = d.remove(NodeId(50)).unwrap(); let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("a placed document") }; let p = (**p).clone(); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let envelope = NodeKind::Envelope { content: vec![placed], kind: Box::new(kind), diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index eed8ecd335..8eb18626f3 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -188,7 +188,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: // An artboard exported on its own is named after it (#3544), else after the document. // A name that is blank once the characters XML can't hold are dropped falls back too. let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| xml_text(&a.name)); - let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(&doc.title)); + let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(doc.info_title())); if !title.is_empty() { out.push_str(&w.indent(1)); out.push_str(&format!("{title}{nl}")); diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 3ab4003d7d..c5a98aebf1 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -121,6 +121,21 @@ impl Reuse { } } +/// Are `children` the pieces of a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`]): +/// more than one, each a clip group of a triangle and one and the same image? +fn image_pieces(children: &[std::sync::Arc]) -> bool { + let mut first: Option<(&str, u32, u32)> = None; + children.len() > 1 + && children.iter().all(|c| { + let NodeKind::Group { children, clip: true } = &c.kind else { return false }; + let [clip, im] = children.as_slice() else { return false }; + let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&clip.kind, &im.kind) else { return false }; + let triangle = path.subpaths.len() == 1 && path.anchor_count() == 3; + let image = (im.key.as_str(), im.width, im.height); + triangle && *first.get_or_insert(image) == image + }) +} + /// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas /// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement /// and the art of unlinked opacity masks. @@ -678,6 +693,10 @@ impl Writer<'_> { match &n.kind { NodeKind::Layer { template: true, .. } => {} NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + // The pieces a Warp effect cuts an image into share its pixels, as an envelope's do. + if image_pieces(children) { + self.share_images(n); + } let id = self.id_attr(n); let a = self.attrs(&self.group_props(n)); self.line(&format!("")); diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 4f91d6c970..7c30f23f5a 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -88,7 +88,7 @@ fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { let info = &doc.metadata; let mut terms = vec![("format", "image/svg+xml".to_string())]; let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); - let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; + let fields = [("title", doc.info_title()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string()))); diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 645ba3d751..e5a5248400 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -258,6 +258,19 @@ impl Writer<'_> { self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); return self.text_outlines(n, t); } + // The space before the first character is written as its `dx`; one before a character + // after a line break, which the `` writes as a space, keeps its look as outlines. + let plain = t.plain_text(); + let mut at = 0; + let spaced_break = t.runs.iter().any(|r| { + let start = at; + at += r.text.len(); + r.style.kerning_before != 0.0 && (start..at).any(|b| b > 0 && vectorcraft_text::line_start_space(&plain, b, &r.style, false) != 0.0) + }); + if spaced_break { + self.warn("type on a path with space before a character after a line break is outlined to keep its look"); + return self.text_outlines(n, t); + } // A `` holds the characters of the text, not the bullets or numbers drawn // before them. if !lay.markers.is_empty() { @@ -559,10 +572,17 @@ impl Writer<'_> { let a = self.attrs(&props); let offset = fmt_num(at * 100.0, 3); let mut s = format!(""); - for run in &drawn.runs { + let plain = drawn.plain_text(); + let first = drawn.runs.iter().position(|r| !r.text.is_empty()); + for (i, run) in drawn.runs.iter().enumerate() { let mut diff = run_diff(&base, self.char_props(&run.style, space)); diff.extend(direction_props(&run.style)); - let a = self.attrs(&diff); + let mut a = self.attrs(&diff); + // The space before the first character (`kerningBefore`) moves it along the path. + let before = if first == Some(i) { vectorcraft_text::line_start_space(&plain, 0, &run.style, false) } else { 0.0 }; + if before != 0.0 && before.is_finite() { + a.push_str(&format!(" dx=\"{}\"", fmt_num(before, self.opts.decimals))); + } s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); } s.push_str(""); @@ -751,6 +771,14 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let x = g.xf.translation().x - offset; (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) } + // The space before a line's first character (`kerningBefore`) is in its advance and + // moves it along the line: a reader sets it that far along, without it. (Shrink + // Text to Fit scales it with the sizes.) + None if !lay.vertical && !g.rtl && g.len > 0 && run.style.kerning_before != 0.0 => { + let before = vectorcraft_text::line_start_space(&plain, g.byte, &run.style, false) * lay.fit_scale; + let before = if before.is_finite() { before } else { 0.0 }; + (g.advance - before, g.origin.x + before) + } None => (g.advance, g.origin.x), }; let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); @@ -838,10 +866,13 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: // A reader spaces words and letters as the font does. p.justification = vectorcraft_doc::JustificationOptions::default(); } - if !kerning { - for r in &mut set.runs { + for r in &mut set.runs { + if !kerning { r.style.kerning.get_or_insert(0.0); } + // A reader puts no space before a line's first character (`kerningBefore`): the segment + // starts that much further along instead. + r.style.kerning_before = 0.0; } let relaid; let glyphs = if set == *t { diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 1e852ee531..47fcfbaf14 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -38,7 +38,7 @@ pub(super) use importer::PT_PER_IN; use importer::SymbolMade; pub(super) use layers::DEFAULT_FONT_SIZE; pub(crate) use layers::SUBLAYER; -use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name}; +use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name, root_title}; pub(super) use prepass::Edits; use prepass::{Extras, labels, prepass}; pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE}; @@ -158,6 +158,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul let k = units.k; let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k); doc.units = units.unit; + // The file's own title, when it gives one, is the document's File Info title; the document's + // name stays what `Document::new` gave it, for the caller to name after the file. + doc.metadata.title = root_title(xml.root_element()); let mut im = Importer { doc, warnings, diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs index 15a77e37f0..a6d6a46b30 100644 --- a/crates/svg/src/import/layers.rs +++ b/crates/svg/src/import/layers.rs @@ -16,6 +16,16 @@ pub(super) fn root_layer_name(root: XNode) -> Option { root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string) } +/// The title the file gives root element `root`: the text of its first `` child (the one +/// SVG 1.1 §5.4 and SVG 2 §5.4 give a document or element; a `<title>` inside a shape or group is +/// that element's tooltip, not the document's), its runs of white space made single spaces. `None` +/// without one, or when it is blank. +pub(super) fn root_title(root: XNode) -> Option<String> { + let title = root.children().find(|c| c.is_element() && c.tag_name().name() == "title" && c.tag_name().namespace() == Some(super::SVG_NS))?; + let text: Vec<&str> = title.descendants().filter_map(|n| n.text().filter(|_| n.is_text())).flat_map(str::split_whitespace).collect(); + (!text.is_empty()).then(|| text.join(" ")) +} + /// Is element `g` a `<g>` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or /// the label beside it) is its name. pub(super) fn marked_layer(g: XNode) -> bool { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index a031de7412..e858f1ba08 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -109,6 +109,60 @@ fn rich_text_writes_spacing_shift_and_scale() { assert_eq!(attr(tags[1], "y"), attr(tags[0], "y")); } +/// The space before a line's first character (`kerningBefore`, #5111) moves the line's first +/// `<tspan>` to where the character is drawn, left aligned or anchored at a centred line's centre. +#[test] +fn the_space_before_a_lines_first_character_moves_its_tspan() { + let before = CharStyle { kerning_before: 250.0, ..style(12.0) }; + // 250/1000 em of 12 pt: 3 pt. The second paragraph's first character takes it too; the "O" after + // the first "H" is inside its line, so its value does nothing. + let runs = vec![run("HO", before.clone()), run("H\n", style(12.0)), run("H", before.clone()), run("O", style(12.0))]; + let svg = export(&area_doc(runs, 200.0, Justify::Left), &ExportOptions::default()); + // The first <tspan> on each baseline. + let mut starts: Vec<(f64, f64)> = vec![]; + for t in tspans(&svg) { + let (x, y) = (attr(t, "x").unwrap(), attr(t, "y").unwrap()); + if starts.last().is_none_or(|&(_, last)| last != y) { + starts.push((x, y)); + } + } + assert_eq!(starts.iter().map(|s| s.0).collect::<Vec<_>>(), vec![3.0, 3.0], "{svg}"); + // Centred, the line is 3 pt longer and the anchor sits at the middle of what a reader draws. + let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default()); + let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap(); + assert!((x - 101.5).abs() < 0.01, "{svg}"); + // A justified line, which a reader's own advances place word by word, starts there too. + let words = vec![run("H", before.clone()), run("OH OH OH OH OH OH OH OH OH OH", style(12.0))]; + let svg = export(&area_doc(words, 90.0, Justify::JustifyLeft), &ExportOptions::default()); + assert_eq!(attr(tspans(&svg)[0], "x"), Some(3.0), "{svg}"); +} + +/// On a path, the space before the first character is its `dx` along the path. One before a +/// character after a line break, which the `<textPath>` can't write, is kept as outlines. +#[test] +fn the_space_before_the_first_character_on_a_path_is_its_dx() { + let doc = |runs: Vec<TextRun>| { + let mut t = TextObject::point(Point::ZERO, "", style(12.0)); + t.runs = runs; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + vectorcraft_svg::export_full(&d, &ExportOptions::default(), None) + }; + let before = CharStyle { kerning_before: 250.0, ..style(12.0) }; + let out = doc(vec![run("HO", before.clone()), run("H", style(12.0))]); + let tags = tspans(&out.svg); + assert!(out.svg.contains("<textPath"), "{}", out.svg); + assert_eq!((attr(tags[0], "dx"), attr(tags[1], "dx")), (Some(3.0), None), "{}", out.svg); + let out = doc(vec![run("HO\n", style(12.0)), run("H", before)]); + assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("line break")), "{:?}", out.warnings); +} + /// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own. #[test] fn proportional_metrics_write_palt() { diff --git a/crates/svg/tests/it/title_control_chars.rs b/crates/svg/tests/it/title_control_chars.rs index 3318ed346f..2318eebc67 100644 --- a/crates/svg/tests/it/title_control_chars.rs +++ b/crates/svg/tests/it/title_control_chars.rs @@ -69,3 +69,29 @@ fn dublin_core_metadata_control_chars_are_filtered() { assert!(svg.contains("<dc:subject>kw</dc:subject>")); assert!(svg.contains("<dc:rights>(c) me</dc:rights>")); } + +/// The root's `<title>` is the File Info title of the imported document ([`Document::info_title`]); +/// its name stays what `Document::new` gave it, and a shape's `<title>` is that shape's tooltip. +#[test] +fn the_roots_title_is_the_file_info_title_not_the_documents_name() { + let svg = |inner: &str| { + format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">{inner}<rect width="5" height="5"><title>Tip"#) + }; + let d = import(&svg("\n Access\n paths ")).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", Some("Access paths"), "Access paths")); + for none in [svg(""), svg(" ")] { + let d = import(&none).unwrap(); + assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", None, "Untitled-1")); + } +} + +/// Exports write the File Info title, where the document has one, in place of its name. +#[test] +fn exports_write_the_file_info_title() { + let mut d = Document::new(200.0, 200.0); + d.title = "box.svg".into(); + d.metadata.title = Some("Access paths".into()); + let svg = export(&d, &ExportOptions { metadata: true, ..ExportOptions::default() }); + assert!(svg.contains("Access paths") && svg.contains("Access paths"), "{svg}"); + assert!(!svg.contains("box.svg"), "{svg}"); +} diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs index a4505f9596..c2838fbbe8 100644 --- a/crates/text/src/edit.rs +++ b/crates/text/src/edit.rs @@ -214,6 +214,28 @@ pub fn next_char(s: &str, i: usize) -> usize { s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len()) } +/// Does the character at byte `i` of `s` start a line by itself: the first character of the text, +/// of a paragraph, or after a forced line break (U+2028)? +pub fn starts_line(s: &str, i: usize) -> bool { + i == 0 || s.get(..i).is_some_and(|before| before.ends_with(['\n', '\u{2028}'])) +} + +/// The character whose kerning the Type tool's caret at byte `i` of `s` sets (the Kerning field, +/// Alt+arrows, Reset Tracking and Kerning), and whether that kerning is the space before it +/// ([`vectorcraft_doc::CharStyle::kerning_before`]) or after it +/// ([`vectorcraft_doc::CharStyle::kerning`]). Inside a line it is the space after the character +/// before the caret. At the start of a line ([`starts_line`]) no character on the line comes before +/// the caret, so it is the space before the character after it (#5111). None at the start of an +/// empty line, where no character follows either. +pub fn caret_kerning_target(s: &str, i: usize) -> Option<(Range, bool)> { + let i = floor_char(s, i); + if !starts_line(s, i) { + return Some((prev_char(s, i)..i, false)); + } + let c = s.get(i..)?.chars().next()?; + (c != '\n' && c != '\u{2028}').then(|| (i..i + c.len_utf8(), true)) +} + /// The start of the grapheme cluster before `i` (Left arrow): a Thai consonant with its vowel and /// tone marks, an accented letter or an emoji sequence is passed over whole. pub fn prev_cluster(s: &str, i: usize) -> usize { diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index b7c4f1060e..435bbc4ed7 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -38,7 +38,8 @@ pub(crate) fn is_harakat(c: char) -> bool { /// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the /// stroke is drawn, overprint, and the character style name? Those are not read by the shaper -/// and change no glyph, advance or position. Stricter than [`crate::shape`]'s `shapes_alike` +/// and change no glyph, advance or position. Nor is the space before a line's first character +/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike` /// (which also ignores baseline shift, tracking, underline, …). pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { // Exhaustive: a new attribute must explicitly keep or relax this boundary rule. @@ -51,6 +52,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { leading, tracking, kerning, + kerning_before: _, optical_kerning, baseline_shift, h_scale, diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 10c44eb73e..6e02df73cd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -99,14 +99,17 @@ fn takes_cell(g: &SGlyph) -> bool { /// /// It stands in glyph space as it stands in vertical point type's line space: a quarter turn /// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift -/// kept across the column and a leading space taken off moving it along the path, then moved across +/// kept across the column and a leading space taken off (or the space before a column's first +/// character) moving it along the path, then moved across /// the path as a glyph lying on its side is. fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) { let cell = if takes_cell(g) { upright_cell(g) } else { advance }; let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift))); let about = Point::new(cell * 0.5, -upright_centre(g, cell, em)); - let mut turn = - Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift; + let mut turn = Affine::translate((g.kern_shift - g.lead, g.face.ideographic_centre() * em)) + * shift + * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) + * unshift; // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on // a path that runs to the left: it turns half a turn about its cell's center. let tangent = Vec2::new(angle.cos(), angle.sin()); @@ -359,19 +362,20 @@ impl Ctx<'_> { } else { self.db.outline(&g.face, g.gid) }; - // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an - // upright one in vertical type by moving it up the column once it stands upright. + // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier, and one + // moved along the line by manual kerning ([`SGlyph::kern_shift`]) that much later: an + // upright one in vertical type by moving it along the column once it stands upright. let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some()); - let lead = if upright { 0.0 } else { g.lead }; + let (lead, kern_shift) = if upright { (0.0, 0.0) } else { (g.lead, g.kern_shift) }; let local = match &g.inline { // Inline graphics: art space to glyph space (no font outline), moved along the line by // right-to-left manual kerning as a glyph is. - Some(ib) => Affine::translate((g.kern_shift - lead, -g.bshift)) * ib.xf, + Some(ib) => Affine::translate((kern_shift - lead, -g.bshift)) * ib.xf, // Baseline shift moves the glyph across its line, and Character Rotation then turns it // about its shifted pen position, so the shift is across the line at any rotation. // Manual kerning and composition trim move that pen position along the line. None => { - Affine::translate((g.kern_shift - lead, -g.bshift)) + Affine::translate((kern_shift - lead, -g.bshift)) * Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy)) * Affine::scale_non_uniform(g.sx, g.sy) @@ -379,7 +383,7 @@ impl Ctx<'_> { }; // The turning point in font units, which later moves of the glyph's transform keep. let pivot = match local.determinant() { - det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(g.kern_shift - lead, -g.bshift), + det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(kern_shift - lead, -g.bshift), _ => Point::ZERO, }; let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 { @@ -490,9 +494,10 @@ impl Ctx<'_> { // space after the cell, and must not push the glyph off the centre line). let em = self.style_at(g.byte).size; let cell = if takes_cell(g) { upright_cell(g) } else { advance }; - // A leading space taken off moves it up the column after the turn (moving the - // turning point instead would move it across the column too). - let turn = Affine::translate((-g.lead, 0.0)) + // A leading space taken off moves it up the column after the turn, and the space + // before a column's first character down it (moving the turning point instead + // would move it across the column too). + let turn = Affine::translate((g.kern_shift - g.lead, 0.0)) * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))); m = shift * turn * unshift * m; // Its own advance across, from where the cell centres it: where the shaper set the @@ -527,10 +532,16 @@ impl Ctx<'_> { }; // Where its own width starts along its advance: the shaper's placement, a leading space // taken off and right-to-left manual kerning move the glyph along the line. An upright or - // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance. - let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) { + // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance, an + // upright one after the space before a column's first character. + let along = { let [a, b, ..] = pre.as_coeffs(); - Some((g.kern_shift + g.dx - g.lead) * a.hypot(b)).filter(|s| s.is_finite()).unwrap_or(0.0) + a.hypot(b) + }; + let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) { + Some((g.kern_shift + g.dx - g.lead) * along).filter(|s| s.is_finite()).unwrap_or(0.0) + } else if upright { + Some(g.kern_shift * along).filter(|s| s.is_finite()).unwrap_or(0.0) } else { 0.0 }; @@ -676,6 +687,7 @@ mod emit_kashida_mark_last { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, @@ -737,6 +749,7 @@ mod emit_kashida_mark_last { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index a259ce0881..0f033d4fcb 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -734,8 +734,20 @@ fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() { } /// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`, in the -/// language of the word's first letter. +/// language of the word's first letter; never next to a Hangul letter, which takes no hyphen, and +/// nowhere in a Korean word that Korean Line Break Anywhere off keeps whole. fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec { + if g.get(ws..we).is_some_and(|word| word.iter().any(|h| h.keeps_word)) { + return Vec::new(); + } + let mut points = word_hyphen_breaks(text, g, ws, we, opts); + let hangul = |i: usize| g.get(i).is_some_and(|h| h.inline.is_none() && crate::shape::is_hangul(h.ch)); + points.retain(|&k| !hangul(k.wrapping_sub(1)) && !hangul(k)); + points +} + +/// [`hyphen_breaks`] before the points next to Hangul are left out. +fn word_hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec { // Strip leading/trailing punctuation (quotes, commas). let (mut a, mut b) = (ws, we); while a < b && !g[a].is_letter() { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 0fd3c1275d..d7ac206424 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -10,6 +10,7 @@ use super::{ ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; +use crate::shape::keep_korean_words; /// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, /// with kinsoku on)? @@ -224,6 +225,10 @@ fn prepare_paragraph<'a, 'text>( let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); + // Korean breaks between words, unless Korean Line Break Anywhere lets it break between syllables. + if !para.korean_line_break_anywhere { + keep_korean_words(&mut sg); + } // Japanese composition: consecutive punctuation shares one half-em space. let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; // …and a quarter em between Japanese and Latin text. diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index e077707a4b..0c8ffb4e2f 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -33,6 +33,7 @@ fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec { tcy: None, inline: None, word_end: false, + keeps_word: false, lead: 0.0, proportional: false, vpal: None, @@ -513,3 +514,101 @@ fn composition_counts_the_narrower_line_end() { assert_eq!(lines(38.0, &half, &hyphenated), None); assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)])); } + +/// The glyphs of `text`, each 10 pt wide, with Korean words kept whole unless `anywhere` (Korean +/// Line Break Anywhere on). +fn korean(text: &str, anywhere: bool) -> Vec { + let mut glyphs = full_width(text); + if !anywhere { + crate::shape::keep_korean_words(&mut glyphs); + } + glyphs +} + +/// Korean Line Break Anywhere off: both composers break a Korean word only at its spaces, so a +/// word that doesn't fit moves to the next line whole; on, they break between its syllables. +#[test] +fn korean_words_break_at_spaces_unless_line_break_anywhere() { + use KinsokuShoriType::PushOutOnly; + let text = "가나다 라마바"; + let greedy = + |g: &[SGlyph], width: f64| break_line(text, g, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0; + let ends = |g: &[SGlyph]| candidates(text, g, None, Kinsoku::Hard).iter().map(|b| b.end).collect::>(); + // 50 pt holds "가나다 라" and not "가나다 라마". + assert_eq!(greedy(&korean(text, false), 50.0), 4, "off: the line ends at the space"); + assert_eq!(greedy(&korean(text, true), 50.0), 5, "on: the line ends after 라"); + assert_eq!(ends(&korean(text, false)), [4]); + assert_eq!(ends(&korean(text, true)), [1, 2, 4, 5, 6]); + let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 }; + let off = korean(text, false); + let cands = candidates(text, &off, None, Kinsoku::Hard); + assert_eq!(compose(&off, &|_| 50.0, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms), Some(vec![(4, false), (7, false)])); + // The first word is measured whole, as the line loop measures it. + assert_eq!(breaking::word_width(&off, 0, &[], Kinsoku::Hard), 30.0); + assert_eq!(breaking::word_width(&korean(text, true), 0, &[], Kinsoku::Hard), 10.0); + // A word wider than the line still breaks, at the syllable that overflows. + assert_eq!(greedy(&korean("가나다라마바", false), 25.0), 2); + // A soft return still ends the line. + let soft = "가나\u{2028}다라"; + assert_eq!( + break_line(soft, &korean(soft, false), 0, 100.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutOnly, false, false, &[]).0, + 3 + ); +} + +/// Korean Line Break Anywhere off keeps the Han, kana and full-width letters of a Korean word with +/// it, lets the line break after East Asian punctuation in it (as kinsoku allows), and leaves +/// text without Hangul its own breaks. On, No Break still keeps its characters together. +#[test] +fn korean_words_keep_their_letters_and_break_after_punctuation() { + let ends = |text: &str, anywhere: bool| candidates(text, &korean(text, anywhere), None, Kinsoku::Hard).iter().map(|b| b.end).collect::>(); + assert_eq!(ends("漢字한국 ab", false), [5], "Han in a Korean word stays with it"); + assert_eq!(ends("日本語 한국어", false), [1, 2, 4], "Japanese keeps its breaks"); + assert_eq!(ends("가나。다라", false), [3], "after the full stop"); + assert_eq!(ends("「가나」다", false), [4], "after the closing bracket, not the opening one"); + assert_eq!(ends("가나AB다", false), Vec::::new(), "full-width letters stay with it"); + let text = "가나다라"; + let mut glyphs = korean(text, true); + for g in &mut glyphs[1..3] { + g.no_break = true; + } + let end = break_line(text, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]).0; + assert_eq!(end, 1, "No Break keeps 나다 together"); +} + +/// Hyphenation adds no hyphen to a Korean word, even where a hyphenation exception marks one: the +/// word that overflows breaks without one, and the every-line composer is offered no hyphenated +/// break in it. +#[test] +fn korean_words_take_no_hyphen() { + let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() }; + let text = "가나다라"; + crate::hyphen::with_hyphenation_exceptions_for_test("가나-다라", || { + let glyphs = korean(text, false); + let line = break_line( + text, + &glyphs, + 0, + 25.0, + &[], + 0.0, + Some((&opts, 0.0)), + Burasagari::None, + Kinsoku::Hard, + KinsokuShoriType::PushOutOnly, + false, + false, + &[], + ); + assert_eq!(line, (2, false)); + assert!(candidates(text, &glyphs, Some(&opts), Kinsoku::Hard).iter().all(|b| b.hyphen == 0.0)); + }); + // Nor between the Latin letters of a word that also holds Hangul. + let text = "가나foobar"; + crate::hyphen::with_hyphenation_exceptions_for_test("가나foo-bar", || { + let hyphens = + |anywhere: bool| candidates(text, &korean(text, anywhere), Some(&opts), Kinsoku::Hard).iter().filter(|b| b.hyphen > 0.0).count(); + assert_eq!(hyphens(false), 0); + assert_eq!(hyphens(true), 1, "on, the word's Latin letters hyphenate as before"); + }); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 1e4aeb41d9..d2e3cf1dc8 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -49,7 +49,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; pub use layout::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled}; -pub use shape::spacing_scale; +pub use shape::{line_start_space, spacing_scale}; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign}; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 3c56b2c914..ba7339bc6b 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -39,7 +39,9 @@ pub(crate) struct SGlyph { pub dy: f64, /// Right to left: the run's manual kerning, which moves the glyph right along the baseline, /// outside its Character Rotation; with whole-point advances, the difference the kerning makes - /// to the cluster's rounded advance. 0 otherwise. + /// to the cluster's rounded advance. Left to right and in vertical type: the space before a + /// line's first character ([`CharStyle::kerning_before`]), which moves it along the line the + /// same way. 0 otherwise. pub kern_shift: f64, /// Outline scale (font units -> points). pub sx: f64, @@ -74,6 +76,9 @@ pub(crate) struct SGlyph { /// A word of a script written without spaces (Thai, Lao, Khmer, Myanmar) ends after the /// glyph's cluster, so a line may break there ([`mark_word_ends`]). pub word_end: bool, + /// A letter of a Korean word kept whole (Korean Line Break Anywhere off): a CJK character + /// here doesn't let the line break after it ([`keep_korean_words`]). + pub keeps_word: bool, /// No Break (Character panel menu): keep this glyph on the same line as the neighbouring /// glyphs that have it. pub no_break: bool, @@ -156,7 +161,7 @@ impl SGlyph { self.is_space() || self.word_end || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN | '\u{2028}') - || is_cjk(self.ch) + || (is_cjk(self.ch) && !self.keeps_word) } /// A soft (discretionary) hyphen: invisible unless a line breaks after it. pub fn is_soft_hyphen(&self) -> bool { @@ -325,6 +330,30 @@ pub(crate) fn mark_word_ends(text: &str, start: usize, sg: &mut [SGlyph]) { } } +/// Korean Line Break Anywhere off: mark the letters of each Korean word of `sg` (a paragraph's +/// glyphs) as [`SGlyph::keeps_word`], so the word breaks only at its spaces and punctuation. A +/// Korean word is the run of glyphs between spaces and forced line breaks when any of them is +/// Hangul; its Han, kana and full-width letters are kept with it. Punctuation in it still lets +/// the line break after it, and runs without Hangul keep their own breaks. +pub(crate) fn keep_korean_words(sg: &mut [SGlyph]) { + let mut start = 0; + while start < sg.len() { + let len = sg[start..].iter().position(|g| g.is_space() || g.ch == '\u{2028}').unwrap_or(sg.len() - start); + let word = &mut sg[start..start + len]; + if word.iter().any(|g| g.inline.is_none() && is_hangul(g.ch)) { + for g in word.iter_mut().filter(|g| g.inline.is_none()) { + g.keeps_word = g.ch.is_alphanumeric(); + } + } + start += len + 1; + } +} + +/// Is `c` Hangul: a syllable, a jamo or a compatibility jamo (full or half width)? +pub(crate) fn is_hangul(c: char) -> bool { + c.script() == Script::Hangul +} + pub(crate) fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) } @@ -522,6 +551,37 @@ pub(crate) fn shape_runs( } } +/// [`CharStyle::kerning_before`] of the character at `byte` of `text` in style `st`, in points of +/// `spacing_em`: the space before it when it starts a line by itself ([`crate::edit::starts_line`]), else 0. Its +/// cluster takes the space in its advance, as manual kerning takes the space after a character, +/// and left to right (and in vertical type) its glyphs are drawn that much later on the line +/// (#5111). Right to left, the line starts at its right: the space the advance gains is already on +/// that side. +fn space_before(text: &str, byte: usize, st: &CharStyle, spacing_em: f64) -> f64 { + if st.kerning_before == 0.0 || !crate::edit::starts_line(text, byte) || text.get(byte..).is_some_and(|rest| rest.starts_with(['\n', '\u{2028}'])) + { + return 0.0; + } + Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0) +} + +/// The space before the character at `byte` of `text` in style `st`, in points, as the layout sets +/// it ([`CharStyle::kerning_before`]): its thousandths of the em the character is set in, when it +/// starts a line by itself, else 0. For writers that place a line's first character themselves. +pub fn line_start_space(text: &str, byte: usize, st: &CharStyle, vertical: bool) -> f64 { + let small = st.small_caps.is_some() && !st.all_caps && text.get(byte..).and_then(|s| s.chars().next()).is_some_and(char::is_lowercase); + space_before(text, byte, st, glyph_size(st, small) * spacing_scale(st, vertical)) +} + +/// The size characters in style `st` are shaped at: Superscript, Subscript and synthesized Small +/// Caps (`small`: a lowercase letter in them) shrink the glyphs; line metrics keep the full size. +fn glyph_size(st: &CharStyle, small: bool) -> f64 { + let full = st.size.max(0.0); + let (pos_scale, _) = st.position.scale_shift(full); + let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 }; + full * pos_scale * small_scale +} + /// Line segments of a glyph outline in font units, cached for the current shaping pass. type Profile = Vec<(f64, f64, f64, f64)>; type CachedProfile = (Profile, (f64, f64)); @@ -817,8 +877,11 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that /// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the -/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. +/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space +/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it +/// from its first character, so it doesn't split a ligature either. fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { + let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() }; if a == b { return true; } @@ -836,7 +899,8 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its -/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard +/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]) +/// and the tracking, as long as the tracking keeps or drops standard /// ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. @@ -849,6 +913,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { leading: _, tracking, kerning: _, + kerning_before: _, optical_kerning: _, baseline_shift: _, h_scale, @@ -948,8 +1013,9 @@ fn shaping_script(c: char) -> Script { /// /// The art is scaled uniformly to `scale` x the font size tall, its left edge on the pen and its /// vertical centre on the middle of the cap height raised by the inline's shift (see -/// [`InlineArt`]). Its advance is the scaled art width plus tracking and its character's manual -/// kerning, which right to left in horizontal type also moves the art right by the value, as it +/// [`InlineArt`]). Its advance is the scaled art width plus tracking, its character's manual +/// kerning and the space before it at a line's start ([`space_before`]). Right to left in +/// horizontal type the manual kerning also moves the art right by the value, as it /// moves a letter's glyph. The art doesn't take the run's Horizontal Scale; the tracking and the /// kerning do, as after a letter ([`spacing_scale`]). The glyph's ascent and descent grow to the /// art's extent so a tall graphic opens up its line. A missing symbol takes a square of the same @@ -983,7 +1049,8 @@ fn inline_glyph( let spacing_em = size * spacing_scale(st, vertical); let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0); let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0); - let adv = width + tracking + manual_kern; + let before = space_before(text, range.start, st, spacing_em); + let adv = width + tracking + manual_kern + before; SGlyph { face: face.clone(), gid: 0, @@ -994,7 +1061,7 @@ fn inline_glyph( tracking: if adv.is_finite() { tracking } else { 0.0 }, dx: 0.0, dy: 0.0, - kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 }, + kern_shift: if level.is_rtl() && !vertical { manual_kern } else { before }, // Not used to draw (the art has its own transform), but they make the glyph's em box // its run's em: Character Alignment and Top-to-Top leading treat an inline graphic as a // character of its run's size, whatever its scale (see `layout::glyph_em`). @@ -1017,6 +1084,7 @@ fn inline_glyph( level, inline: Some(InlineBox { art: drawn, xf }), word_end: false, + keeps_word: false, no_break: st.no_break, vertical_roman: false, cursive: false, @@ -1079,9 +1147,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins); let full = st.size.max(0.0); // Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size. - let (pos_scale, pos_shift) = st.position.scale_shift(full); - let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 }; - let size = full * pos_scale * small_scale; + let (_, pos_shift) = st.position.scale_shift(full); + let size = glyph_size(st, small); let k = size / face.upem; let km = full / face.upem; let hs = st.h_scale / 100.0; @@ -1407,15 +1474,19 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe adv = 0.0; } else { let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0); + let before = space_before(text, cl, gst, spacing_em); // Manual kerning spaces a character from the next one in reading order. Right to left, // that one is drawn to its left: the cluster's glyphs move right along the baseline by // the value, so that the space its last glyph's advance gains is at the cluster's left. + // The space before a line's first character moves them the other way. if level.is_rtl() && !feats.vertical { kern_shift = manual_kern; + } else { + kern_shift = before; } if last_in_cluster { tracked = gst.tracking / 1000.0 * spacing_em; - adv += tracked + manual_kern; + adv += tracked + manual_kern + before; } } out.push(SGlyph { @@ -1446,6 +1517,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe level: *level, inline: None, word_end: false, + keeps_word: false, no_break: gst.no_break, vertical_roman: gst.standard_vertical_roman, cursive, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8934090d5a..bcb233b8c3 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -19,6 +19,7 @@ mod inline; mod joiners; mod justify; mod kept_layouts; +mod kerning_before; mod ligature_carets; mod path_joins; mod ruby; @@ -2515,6 +2516,41 @@ fn thai_loanwords_stay_whole() { assert!(l.lines.iter().all(|ln| ln.end <= word || ln.end >= word + "โปรแกรม".len()), "{lines:?}"); } +/// Korean Line Break Anywhere (a paragraph attribute): off, a Korean word that does not fit moves +/// to the next line whole, with either composer and in vertical type; on, the line breaks between +/// its syllables. Each paragraph follows its own setting. +#[test] +fn korean_words_stay_whole_unless_line_break_anywhere_is_on() { + let text = "가나다 라마바"; + let first = "가나다 ".len(); + let measure = |vertical: bool| { + let mut t = point("가나다 라", style(20.0)); + t.vertical = vertical; + layout(db(), &t).glyphs.iter().map(|g| g.advance).sum::() + }; + let ends = |t: &TextObject| layout(db(), t).lines.iter().map(|l| l.end).collect::>(); + for vertical in [false, true] { + let fits = measure(vertical); + let frame = if vertical { Rect::new(0.0, 0.0, 400.0, fits + 1.0) } else { Rect::new(0.0, 0.0, fits + 1.0, 400.0) }; + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(text, style(20.0), frame, Justify::Left); + (t.vertical, t.para.composer) = (vertical, composer); + assert_eq!(ends(&t), [first, text.len()], "{composer:?} vertical {vertical}: off"); + t.para.korean_line_break_anywhere = true; + let on = ends(&t); + assert!(on.iter().any(|&e| e != first && e != text.len()), "{composer:?} vertical {vertical}: on {on:?}"); + } + } + // Per paragraph: the first breaks between syllables, the second between words. + let fits = measure(false); + let mut t = area(&format!("{text}\n{text}"), style(20.0), Rect::new(0.0, 0.0, fits + 1.0, 400.0), Justify::Left); + t.para.composer = Composer::SingleLine; + let anywhere = vectorcraft_doc::ParaStyle { korean_line_break_anywhere: true, ..t.para.clone() }; + t.set_paragraph_styles(vec![anywhere, t.para.clone()]); + let second = text.len() + 1; + assert_eq!(ends(&t), ["가나다 라".len(), text.len(), second + first, second + text.len()]); +} + /// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps /// at the space without the mark. #[test] diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index e590f103c7..9bb7700d66 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -171,6 +171,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { b.stroke_dash = a.stroke_dash.clone(); b.stroke_align = a.stroke_align; b.style_name = a.style_name.clone(); + b.kerning_before = a.kerning_before; *a == b } @@ -203,6 +204,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { field!(leading, Some(60.0), false); field!(tracking, 20.0, false); field!(kerning, Some(0.0), false); + field!(kerning_before, 250.0, true); field!(optical_kerning, true, false); field!(baseline_shift, 1.0, false); field!(h_scale, 90.0, false); @@ -312,6 +314,36 @@ fn harakat_ranges_normalise_their_range() { /// the same way, and only a character in that glyph's shaping cluster: repeated glyph ids alone /// don't do. A later in-cluster owner is kept only for a GDEF mark glyph on a harakat character; /// everything else stays at the cluster start (conservative). +/// A space before the line's first letter (#5111), set on the letter alone as the Type tool's caret +/// sets it, keeps the letter's harakat coloured apart attached and in their own colour. Right to +/// left the space is past the letter's right side, so every glyph stays where it was and the line +/// is the space longer. Checked with the in-memory Arabic test font (its fatha a `GDEF` mark) in +/// every environment, and with an installed Arabic face when there is one. +#[test] +fn a_space_before_the_first_letter_keeps_its_coloured_harakat() { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").unwrap()), 1); + check_spaced_harakat(&db, crate::test_fonts::lam_alef_family(b"rlig")); + if let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") { + check_spaced_harakat(FontDb::global(), &face.family); + } +} + +fn check_spaced_harakat(db: &FontDb, family: &str) { + const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8 + let coloured = text_with_harakat(WORD, family, Some(red())); + let mut spaced = coloured.clone(); + crate::edit::style_range(&mut spaced.runs, 0, 2, |st| st.kerning_before = 250.0); + let (a, b) = (layout(db, &coloured), layout(db, &spaced)); + let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.xf.as_coeffs().map(f64::to_bits))).collect::>(); + assert_eq!(placed(&a), placed(&b), "{family}: the same glyphs where they were"); + assert!(painted(&spaced, &b, &red()) > 0, "{family}: harakat painted apart"); + assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "{family}: the harakat keep their colour"); + // 250/1000 of the 48 pt em. + let length = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::(); + assert!((length(&b) - length(&a) - 12.0).abs() < 1e-6, "{family}: {} → {}", length(&a), length(&b)); +} + #[test] fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() { // بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and diff --git a/crates/text/src/tests/kerning_before.rs b/crates/text/src/tests/kerning_before.rs new file mode 100644 index 0000000000..f3d9e785a8 --- /dev/null +++ b/crates/text/src/tests/kerning_before.rs @@ -0,0 +1,136 @@ +//! The space before a character that starts a line by itself (`CharStyle::kerning_before`, #5111): +//! the first character of the text, of a paragraph or after a forced line break moves along its line +//! by the value, the rest of the line follows, and the value does nothing anywhere else. +use crate::*; +use kurbo::Shape; +use vectorcraft_doc::{CharStyle, Justify}; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn style(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +/// Where glyph `byte` is drawn along a horizontal line. +fn drawn_x(l: &TextLayout, byte: usize) -> f64 { + l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap() +} + +/// `t` with `kerning_before` set on the character at `byte`. +fn spaced(t: &TextObject, byte: usize, value: f64) -> TextObject { + let mut t = t.clone(); + let end = edit::next_char(&t.plain_text(), byte); + edit::style_range(&mut t.runs, byte, end, |st| st.kerning_before = value); + t +} + +/// How far each of `bytes` moved along the line from `a` to `b`. +fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec { + let (la, lb) = (layout(db(), a), layout(db(), b)); + bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect() +} + +fn near(values: &[f64], expected: &[f64]) -> bool { + values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6) +} + +#[test] +fn the_first_character_moves_along_its_line_and_the_rest_follow() { + let plain = TextObject::point(Point::ZERO, "HOH", style(100.0)); + // 250/1000 em of 100 pt: 25 pt, away from the line's start; a negative value pulls it back. + let away = moves(&plain, &spaced(&plain, 0, 250.0), &[0, 1, 2]); + assert!(near(&away, &[25.0, 25.0, 25.0]), "{away:?}"); + let back = moves(&plain, &spaced(&plain, 0, -100.0), &[0, 1, 2]); + assert!(near(&back, &[-10.0, -10.0, -10.0]), "{back:?}"); + // The line is that much longer, so a centred line moves its first character half as far. + let mut centred = plain.clone(); + centred.para.justify = Justify::Center; + let half = moves(¢red, &spaced(¢red, 0, 250.0), &[0, 2]); + assert!(near(&half, &[12.5, 12.5]), "{half:?}"); +} + +#[test] +fn it_does_nothing_on_a_character_inside_a_line() { + let plain = TextObject::point(Point::ZERO, "HOH", style(100.0)); + let inside = moves(&plain, &spaced(&plain, 1, 250.0), &[0, 1, 2]); + assert!(near(&inside, &[0.0, 0.0, 0.0]), "{inside:?}"); + // Nor on the first character of a line that wrapped in area type: the line break moves. + let area = super::area("HOH HOH", style(50.0), Rect::new(0.0, 0.0, 180.0, 400.0), Justify::Left); + let wrapped = layout(db(), &area); + let second_line = wrapped.glyphs.iter().find(|g| g.byte == 4).unwrap().line; + assert_eq!(second_line, 1, "the second HOH wraps"); + let moved = moves(&area, &spaced(&area, 4, 250.0), &[0, 4, 5]); + assert!(near(&moved, &[0.0, 0.0, 0.0]), "{moved:?}"); +} + +#[test] +fn a_paragraph_and_a_forced_line_break_start_lines_by_themselves() { + let paragraphs = TextObject::point(Point::ZERO, "HO\nHO", style(100.0)); + let moved = moves(¶graphs, &spaced(¶graphs, 3, 250.0), &[0, 1, 3, 4]); + assert!(near(&moved, &[0.0, 0.0, 25.0, 25.0]), "{moved:?}"); + let forced = TextObject::point(Point::ZERO, "HO\u{2028}HO", style(100.0)); + let after = "HO\u{2028}".len(); + let moved = moves(&forced, &spaced(&forced, after, 250.0), &[0, after, after + 1]); + assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}"); + // On a line break it stays: an empty line has nothing to move. + let empty = TextObject::point(Point::ZERO, "HO\n\nHO", style(100.0)); + let moved = moves(&empty, &spaced(&empty, 3, 250.0), &[0, 4]); + assert!(near(&moved, &[0.0, 0.0]), "{moved:?}"); +} + +#[test] +fn it_takes_the_em_of_the_character_as_manual_kerning_does() { + // Horizontal Scale 50%: half the space, as kerning after a character takes it. + let narrow = TextObject::point(Point::ZERO, "HOH", CharStyle { h_scale: 50.0, ..style(100.0) }); + let moved = moves(&narrow, &spaced(&narrow, 0, 250.0), &[0, 2]); + assert!(near(&moved, &[12.5, 12.5]), "{moved:?}"); +} + +#[test] +fn right_to_left_the_space_is_at_the_right_of_the_first_character() { + // The first character in reading order is drawn rightmost; the space before it is on its right, + // so the two keep their distance, and the line is 25 pt longer. + let plain = TextObject::point(Point::ZERO, "אב", style(100.0)); + let kerned = spaced(&plain, 0, 250.0); + let (a, b) = (layout(db(), &plain), layout(db(), &kerned)); + assert!(drawn_x(&a, 0) > drawn_x(&a, 2), "right to left"); + let gap = |l: &TextLayout| drawn_x(l, 0) - drawn_x(l, 2); + assert!((gap(&b) - gap(&a)).abs() < 1e-6, "{} {}", gap(&a), gap(&b)); + let length = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((length(&b) - length(&a) - 25.0).abs() < 1e-6, "{} {}", length(&a), length(&b)); +} + +#[test] +fn in_vertical_type_the_first_character_moves_down_its_column() { + // An upright glyph (§) and letters lying on their side both move 5 pt (250/1000 em of 20 pt) + // down the column, and stay in it. + for text in ["§§", "ab"] { + let mut plain = TextObject::point(Point::ZERO, text, style(20.0)); + plain.xf = Affine::IDENTITY; + plain.vertical = true; + let kerned = spaced(&plain, 0, 250.0); + let (a, b) = (layout(db(), &plain), layout(db(), &kerned)); + for i in 0..2 { + let (before, after) = (a.glyphs[i].outline.bounding_box(), b.glyphs[i].outline.bounding_box()); + assert!((after.center().y - before.center().y - 5.0).abs() < 1e-6, "{text} {i}: {before:?} {after:?}"); + assert!((after.center().x - before.center().x).abs() < 1e-6, "{text} {i}: {before:?} {after:?}"); + } + } +} + +#[test] +fn a_space_before_the_first_letter_of_a_ligature_keeps_it_whole() { + // Set on the "f" alone, as the caret before it sets it: Source Serif 4's "fi" ligature stays one + // glyph, and moves. + let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(100.0) }; + let plain = TextObject::point(Point::ZERO, "fit", serif); + let glyphs = |t: &TextObject| layout(db(), t).glyphs.len(); + assert_eq!(glyphs(&plain), 2, "the font joins fi"); + let kerned = spaced(&plain, 0, 250.0); + assert_eq!(glyphs(&kerned), 2, "still joined"); + let moved = moves(&plain, &kerned, &[0, 2]); + assert!(near(&moved, &[25.0, 25.0]), "{moved:?}"); + assert!((line_start_space("fit", 0, &kerned.runs[0].style, false) - 25.0).abs() < 1e-9); +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 1530caf105..2d945c1439 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -341,6 +341,9 @@ impl Revolve { pub struct Face { pub points: Vec, pub depth: f64, + /// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the + /// side the viewer sees. + pub normal: [f64; 3], pub brightness: f64, pub gradient: Option, /// RGB illumination and its unclipped smooth ramp, independent of the legacy white ramp. @@ -453,6 +456,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi Ok(Some(Face { points, depth: vertices.iter().map(|v| v.2).sum::() / vertices.len() as f64, + normal: [normal.0, normal.1, normal.2], brightness, gradient, illumination, @@ -470,6 +474,12 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result Result, String> { + Ok(revolve(path, bounds, options)?.into_iter().map(Facet::of_face).collect()) +} + const TOO_COMPLEX_PROFILE: &str = "Revolve profile is too complex"; const TOO_COMPLEX_SURFACE: &str = "Revolve surface is too complex; reduce Segments or simplify the profile"; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index d486f443cf..18c9959b19 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -294,6 +294,24 @@ impl Facet { } PathData::from_bezpath(&bp) } + + /// Revolve's face `face` as a facet: its polygon, curved outline, depth, normal and lighting. + /// A face that joins the profile's ends round the axis is a side; a cap is an end. + pub(crate) fn of_face(face: crate::Face) -> Self { + Facet { + contours: vec![face.points], + depth: face.depth, + brightness: face.brightness, + illumination: face.illumination, + normal: face.normal, + rgb_gradient: None, + outline: face.outline, + side: !face.cap, + away: false, + #[cfg(test)] + view: vec![], + } + } } fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 { diff --git a/crates/three-d/src/tests/classic.rs b/crates/three-d/src/tests/classic.rs index 1d807b4a3e..f460297453 100644 --- a/crates/three-d/src/tests/classic.rs +++ b/crates/three-d/src/tests/classic.rs @@ -94,3 +94,32 @@ fn surfaces_round_trip_through_their_ids() { assert_eq!(ClassicSurface::parse("glossy"), None); assert_eq!(ClassicSurface::default(), ClassicSurface::Plastic); } + +#[test] +fn revolve_facets_keep_each_face_normal_turned_to_the_viewer() { + // A cylinder of radius 30 about the left edge, with closed ends, tipped toward the viewer. + let mut b = BezPath::new(); + b.move_to((100.0, 0.0)); + b.line_to((130.0, 0.0)); + b.line_to((130.0, 100.0)); + b.line_to((100.0, 100.0)); + let path = PathData::from_bezpath(&b); + let options = Revolve { rotation_x: -30.0, shade: false, smooth_shading: false, ..Revolve::default() }; + let faces = revolve(&path, path.bounds().unwrap(), options).unwrap(); + let facets = revolve_facets(&path, path.bounds().unwrap(), options).unwrap(); + assert_eq!(facets.len(), faces.len()); + for (facet, face) in facets.iter().zip(&faces) { + let [x, y, z] = facet.normal; + assert!(((x * x + y * y + z * z).sqrt() - 1.0).abs() < 1e-9, "unit length: {:?}", facet.normal); + assert!(z >= 0.0, "turned to the viewer: {:?}", facet.normal); + assert_eq!((facet.depth, facet.side), (face.depth, !face.cap)); + } + // Tipped 30°, the wall faces nearest the viewer turn 30° down from it, and the flat top and bottom + // (the profile's horizontal edges swept round) face up and out at 30°. + let (s, c) = 30f64.to_radians().sin_cos(); + assert!(facets.iter().any(|f| f.normal[2] > c - 0.01 && (f.normal[1] - s).abs() < 0.01), "{facets:?}"); + assert!(facets.iter().any(|f| (f.normal[1] + c).abs() < 1e-6 && (f.normal[2] - s).abs() < 1e-6), "{facets:?}"); + // Moved off the axis, the profile's open ends close with caps, which are ends rather than sides. + let ring = revolve_facets(&path, path.bounds().unwrap(), Revolve { offset: 10.0, ..options }).unwrap(); + assert!(ring.iter().any(|f| !f.side && (f.normal[1] + c).abs() < 1e-6 && (f.normal[2] - s).abs() < 1e-6)); +} diff --git a/crates/three-d/src/visibility.rs b/crates/three-d/src/visibility.rs index f66187fcf9..db8f5b0df7 100644 --- a/crates/three-d/src/visibility.rs +++ b/crates/three-d/src/visibility.rs @@ -293,6 +293,7 @@ mod tests { Face { points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)], depth: brightness, + normal: [0.0, 0.0, 1.0], brightness, gradient: None, illumination: [brightness; 3], diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index db3aad793e..32e1df2a16 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -1543,7 +1543,7 @@ mod tests { let d = doc_with_live_over_green(|d| { let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0); let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = vec![std::sync::Arc::new(red)]; let n = Node::new( d.alloc_id(), @@ -1574,7 +1574,7 @@ mod tests { use vectorcraft_geom::{Affine, Rect}; let d = doc_with_live_over_green(|d| { let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0)); - let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }; let content = vec![std::sync::Arc::new(red)]; let options = Default::default(); Node::new( diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index ced5eb33a8..522f82bd2b 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -5,6 +5,7 @@ pub mod cleanup; pub mod color; pub mod compounds; pub mod contour; +pub mod cutting; pub mod document; pub mod enclosing_lasso; pub mod image; @@ -25,6 +26,7 @@ pub fn plugin_menu() -> Vec { Item::Sub("Color Effects", color::menu()), Item::Sub("Compound Shapes", compounds::menu()), Item::Sub("Contour", contour::menu()), + Item::Sub("Cutting Tools", cutting::menu()), Item::Sub("Document Tools", document::menu()), Item::Sub("Enclosing Lasso", enclosing_lasso::menu()), Item::Sub("Image Tools", image::menu()), diff --git a/crates/ui-egui/src/addons/cutting.rs b/crates/ui-egui/src/addons/cutting.rs new file mode 100644 index 0000000000..82fd2d6e3b --- /dev/null +++ b/crates/ui-egui/src/addons/cutting.rs @@ -0,0 +1,20 @@ +//! Cutting Tools in the interface: its submenu (Object › Plug-ins › Cutting Tools). Stencil +//! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options +//! of `addon.cutting.bridges`. + +use serde_json::json; + +use crate::menus::Item; + +/// Stencil Bridges' command. +pub const BRIDGES: &str = "addon.cutting.bridges"; +/// Stencil Bridges › Direction: what the command's `direction` takes, by name. +pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Horizontal", "horizontal"), ("Shortest Crossing", "shortest")]; + +/// Object › Plug-ins › Cutting Tools. +pub fn menu() -> Vec { + vec![Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true}))] +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs new file mode 100644 index 0000000000..d5620f717b --- /dev/null +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -0,0 +1,54 @@ +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::tests_labels::painted_text; +use crate::{VectorcraftApp, menus}; + +/// Object › Plug-ins › Cutting Tools › Stencil Bridges… opens the command dialog on a 2 pt width, one +/// bridge per island, vertical bridges and Keep Original on; the direction is a list of its three +/// ways; OK cuts the bridges as one undo step, and a message shows when there are no islands. +#[test] +fn stencil_bridges_opens_its_dialog_and_runs_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let outer = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + let inner = app.run("shape.rectangle", json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [outer, inner]})).unwrap(); + let o = app.run("object.pathfinder.minusFront", json!({})).unwrap()["ids"][0].clone(); + app.run("select.set", json!({"ids": [o]})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let n = steps(&app); + let open = |app: &mut VectorcraftApp| { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Stencil Bridges…").expect("listed"); + assert!(item.enabled); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &cmd, p); + }; + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::BRIDGES.to_string(), "Stencil Bridges".to_string())); + assert_eq!((d.f64("width", 0.0), d.f64("count", 0.0), d.str("direction"), d.bool("keepOriginal")), (2.0, 1.0, "vertical".to_string(), true)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Stencil Bridges", "Width:", "Count:", "Direction:", "Vertical", "Keep Original"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + // Each way the list offers is one the command takes. + for (_, way) in super::BRIDGE_DIRECTIONS { + assert!(app.session.execute(super::BRIDGES, &json!({"direction": way, "width": 1000})).is_ok(), "{way}"); + } + assert_eq!(steps(&app), n, "no bridge fits that wide: nothing changed"); + + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(steps(&app), n + 1, "one undo step"); + let layers = &app.session.doc().unwrap().doc.layers; + assert!(layers.iter().any(|l| l.name.as_deref() == Some("Stencil Original") && !l.visible), "the original is on a hidden layer"); + + // Run again on the result, which has no islands left: a message, and nothing changes. + open(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), n + 1); + assert!(app.ui.status.contains("no islands"), "{}", app.ui.status); +} diff --git a/crates/ui-egui/src/addons/image/tests.rs b/crates/ui-egui/src/addons/image/tests.rs index 349c35533e..64563e611b 100644 --- a/crates/ui-egui/src/addons/image/tests.rs +++ b/crates/ui-egui/src/addons/image/tests.rs @@ -56,6 +56,26 @@ fn set(app: &mut VectorcraftApp, key: &str, v: serde_json::Value) { crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), key.into(), v); } +/// Properties' Quick Actions and the Control bar offer Remove Background for a selected image, and +/// the Control bar's button removes it at once (no dialog) with the defaults, as one step. +#[test] +fn properties_and_the_control_bar_remove_the_background_at_once() { + use crate::tests_removeanchors::{at, click_control, control_frame, has, properties_frame}; + let mut app = app(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let n = steps(&app); + assert!(has(&properties_frame(&mut app, 280.0), "Remove Background"), "Properties offers it"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let bar = control_frame(&mut app, &ctx, vec![]); + click_control(&mut app, &ctx, at(&bar, "Remove Background")); + assert!(app.ui.dialog.is_none(), "it runs at once"); + assert_eq!(steps(&app), n + 1, "one undo step"); + // The defaults (tolerance and softness 10): the white goes, the near-white ring fades. + let [corner, ring, block] = alphas(&app); + assert!(corner == 0 && ring > 0 && ring < 255 && block == 255, "{:?}", alphas(&app)); +} + /// Object › Plug-ins › Image Tools › Remove Background… opens the command dialog on Edge Color /// with Preview off. With Preview on the canvas shows the result for the values in the dialog, /// each change worked out again from the image as it was; Cancel puts the image back with no step diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 930daf8fd4..1a385d5642 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -293,9 +293,7 @@ pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, let tool = app.session.tool_id(); let Some(st) = app.session.active().filter(|_| matches!(tool, "selection" | "directSelection" | "groupSelection")) else { return false }; // The tool's interaction while it moves (or Alt-copies) the selection. - if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") { - return false; - } + let Some(it) = st.interaction.as_ref().filter(|i| i.label == "Move" || i.label == "Copy") else { return false }; if tool != "selection" && st.selection.objects.iter().any(|id| st.selection.partial(*id).is_some() || st.selection.segments.contains_key(id)) { return false; } @@ -305,7 +303,9 @@ pub(super) fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, if on_canvas(ui, resp, p) && ui.input(|i| i.pointer.has_pointer()) { return false; } - let ids = st.selection.objects.clone(); + // The art the drag started with: the release rolls the move back to it, and an Alt-copy's + // copies, selected while it previews, go with that. + let ids = it.selection.objects.clone(); egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids)); true } diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index a7a7f0a45c..da4edbf54e 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -96,11 +96,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } else { // Edit Path: the Direct Selection tool, to edit the path's anchors. let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); - // As in the reference app, an image offers Image Trace and Crop Image, and traced art offers - // Expand (). + // As in the reference app, an image offers Image Trace, Crop Image and Remove Background, and + // traced art offers Expand (, + // ). if n == 1 && is_image { items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none())); items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none())); + items.push((tl!("Remove Background"), "eraser", crate::addons::image::REMOVE_BACKGROUND, none())); } // Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar // offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does @@ -151,10 +153,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { match kind { TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())), TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), - TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())), + TextKind::OnPath { .. } => { + items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())); + center_on_path_items(app, &mut items); + } } // Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does; - // left out for point type and type in a thread + // left out for point type and type in a thread, which is VectorCraft's own rule // (). if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); @@ -348,6 +353,18 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } +/// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a +/// closed path. +fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) { + for (label, icon, id) in + [(tl!("Center on Top"), "dc-al-top", "type.pathCenter.top"), (tl!("Center on Bottom"), "dc-al-bottom", "type.pathCenter.bottom")] + { + if crate::menus::enabled(app, id) { + items.push((label, icon, id, json!({}))); + } + } +} + /// The Contextual Task Bar's Image Trace button: a trace with the Default preset. const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace"; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 58273af5f6..503178a22b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -7209,6 +7209,36 @@ fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() { assert_eq!(st.history.undo.len(), undo); } +/// Art Alt-dragged off the canvas (a copy while it moves there) reaches the panels as the art the +/// drag started with, which is what stays when the release over a panel takes the copy back. +#[test] +fn art_alt_dragged_off_the_canvas_reaches_the_panels_as_the_original() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = vectorcraft_doc::NodeId(id); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let before = app.session.active().unwrap().doc.clone(); + let button = |pos: Pos2, pressed: bool| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT }; + let start = xf.to_screen(Point::new(100.0, 100.0)); + frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(start), button(start, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(start + vec2(40.0, 0.0))]); + let st = app.session.active().unwrap(); + assert_eq!(st.interaction.as_ref().map(|i| i.label.as_str()), Some("Copy")); + assert!(!st.selection.objects.contains(&id), "the copy is selected while it moves"); + frame(&mut app, &ctx, vec![Event::PointerMoved(pos2(850.0, 100.0))]); + assert_eq!(egui::DragAndDrop::payload::(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id]))); + frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none()); + assert_eq!(*st.doc, *before, "the copy goes, the original stays"); + assert!(st.doc.node(id).is_some()); +} + /// #4233: the Direct and Group Selection tools hand art they move whole to the panels too (Asset /// Export, Graphic Styles…), as the Selection tool does; the move waits and nothing is recorded. #[test] @@ -9323,6 +9353,33 @@ fn the_task_bar_traces_an_image_then_expands_the_trace() { assert_eq!(images, 0, "only the traced paths are kept"); } +/// For a selected image the Contextual Task Bar offers Remove Background, which clears the +/// background in the image's pixels at once (no dialog) with the command's defaults. +#[test] +fn the_task_bar_removes_a_selected_images_background() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let img = + image::RgbaImage::from_fn(20, 20, |x, y| image::Rgba(if (6..14).contains(&x) && (6..14).contains(&y) { [200, 0, 0, 255] } else { [255; 4] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "dot.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + let labels = task_bar_labels(&mut app, &ctx); + let at = labels.iter().find(|(t, _)| t == "Remove Background").map(|(_, r)| r.center()).expect("Remove Background is offered"); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + assert!(app.ui.dialog.is_none(), "it runs at once"); + let st = app.session.active().unwrap(); + let Some(NodeKind::Image(im)) = st.selection.objects.first().and_then(|id| st.doc.node(*id)).map(|n| &n.kind) else { panic!("an image") }; + let px = image::load_from_memory(&st.doc.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(10, 10).0[3]), (0, 255), "the white background is cleared, the red dot kept"); +} + /// The labels the Contextual Task Bar draws in a headless frame (after one to lay it out), with /// their rects; none while it isn't shown. The SVG loaders are installed so the icons draw as /// images instead of egui's ⚠ placeholder, which the label list would otherwise pick up. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1765c58e17..513aa54df4 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -72,8 +72,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { // With the macOS menu bar the menus are at the top of the screen instead. let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) }; // The right-side group fills the space after the menus from the right; when it runs - // short, Discord goes first (it is also under Help), then the search icon (the palette - // stays under its shortcut), then the workspace switcher narrows. + // short, Discord goes first (it is also under Help), then Explore tutorials (also + // Window › Learn), then the search icon (the palette stays under its shortcut), then + // the workspace switcher narrows. let full = ui.max_rect(); let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() }; let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom())); @@ -97,7 +98,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let icon = 28.0; let search = switcher && room >= 64.0 + 8.0 + gap + icon; let ws_w = ws_w.min(room - 8.0 - gap - if search { icon } else { 0.0 }).max(64.0); - let discord = search && room >= ws_w + 8.0 + gap + icon + 10.0 + gap + crate::community::discord_width(ui, false); + // Explore tutorials, beside the search icon, opens the Learn panel. + let learn = search && room >= ws_w + 8.0 + 2.0 * (gap + icon); + let discord = learn && room >= ws_w + 8.0 + 2.0 * (gap + icon) + 10.0 + gap + crate::community::discord_width(ui, false); let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center))); let ui = &mut rui; // Workspace switcher: shows the current workspace, opens Window → Workspace. @@ -127,6 +130,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.palette_query.clear(); } } + if learn { + let shown = crate::panels::learn::visible(&app.ui); + if widgets::icon_button(ui, "graduation-cap", tl!("Explore tutorials"), shown, icon).clicked() + && let Err(e) = app.run("window.learn", json!({})) + { + app.ui.status = e; + } + } if discord { ui.add_space(10.0); crate::community::discord_button(app, ui, false); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..c25fa2f7ed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -18,6 +18,7 @@ pub mod blend_options; mod brush_art_options; pub mod brush_delete; pub mod brush_options; +pub mod brush_replace_art; pub mod bullets_numbering; pub mod char_style_options; pub mod color_balance; @@ -322,8 +323,10 @@ registry! { SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC, Confirm: [confirm::KIND] => confirm::SPEC, BrushDelete: [brush_delete::KIND] => brush_delete::SPEC, + BrushReplaceArt: [brush_replace_art::KIND] => brush_replace_art::SPEC, Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC, Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC, + ReplaceFiles: [confirm::REPLACE_FILES] => confirm::REPLACE_FILES_SPEC, ResetPreferences: [confirm::RESET_PREFS] => confirm::RESET_PREFS_SPEC, NewSwatch: [new_swatch::KIND] => new_swatch::SPEC, NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC, @@ -876,6 +879,9 @@ mod tests_eps; #[cfg(test)] mod tests_metafile; +#[cfg(test)] +mod tests_hpgl; + #[cfg(test)] mod tests_tiff_bmp_tga; diff --git a/crates/ui-egui/src/dialogs/brush_replace_art.rs b/crates/ui-egui/src/dialogs/brush_replace_art.rs new file mode 100644 index 0000000000..cc1a08b251 --- /dev/null +++ b/crates/ui-egui/src/dialogs/brush_replace_art.rs @@ -0,0 +1,100 @@ +//! The Brushes panel's question when art Alt-dragged (Option-dragged on macOS) onto a brush that +//! strokes use is to replace its art ([`ask`]): Apply to Strokes, Leave Strokes or Cancel. Apply to +//! Strokes runs `brush.replaceArt` and the strokes redraw with the new art; Leave Strokes runs it +//! with `applyToStrokes: false`, which keeps the brush and its strokes as they were and adds a copy +//! of the brush with the new art. +//! +//! Fields: `name` (the brush), `ids` (the art), `apply` (set by the button pressed) and `__doc` (the +//! uid of the document it asks about). + +use serde_json::{Value, json}; + +use super::{DialogAction, DialogSpec}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::widgets; + +/// The dialog kind of the question. +pub const KIND: &str = "brushReplaceArt"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| { + tl!("Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art?") + .to_string() + }, + body_with_actions: Some(body), + confirm, + ok: None, + max_width: Some(540.0), + ..DialogSpec::FORM +}; + +/// The field that holds the uid of the document the question is about. +const DOC: &str = "__doc"; + +/// Replace the art of brush `name` with the objects `ids`: at once when no stroke uses the brush, +/// else after asking whether the strokes take the new art. Art the brush cannot take (art with no +/// width, say) is refused before anything asks, with the reason in the status bar. +pub fn replace(app: &mut VectorcraftApp, name: &str, ids: &[u64]) { + let params = json!({"name": name, "ids": ids}); + if super::brush_delete::used(app, &[name.to_string()]) != Some(true) { + if let Err(e) = app.run("brush.replaceArt", params) { + app.status(e); + } + return; + } + // A preview on a copy of the document checks the art without changing anything. + if !app.session.in_interaction() && app.session.begin_interaction("Replace Brush Art").is_ok() { + let checked = app.session.preview("brush.replaceArt", ¶ms); + // The interaction began above, on the active document: ending it puts that document back + // as it was, and can fail only when no document is open, when there is nothing to put back. + let _ = app.session.cancel_interaction(); + if let Err(e) = checked { + app.status(e.to_string()); + return; + } + } + ask(app, name, ids); +} + +/// Ask whether the strokes that use brush `name` take the art of the objects `ids`. +pub fn ask(app: &mut VectorcraftApp, name: &str, ids: &[u64]) { + let doc = app.session.active().map(|d| d.uid); + app.ui.dialog = Some(Dialog::new(KIND, json!({"name": name, "ids": ids, "apply": true, DOC: doc}))); +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> DialogAction { + let mut action = DialogAction::None; + ui.horizontal(|ui| { + if widgets::primary_button_enabled(ui, tl!("Apply to Strokes"), true).clicked() { + d.fields.insert("apply".into(), json!(true)); + action = DialogAction::Confirm; + } + if widgets::secondary_button(ui, tl!("Leave Strokes")).clicked() { + d.fields.insert("apply".into(), json!(false)); + action = DialogAction::Confirm; + } + if widgets::secondary_button(ui, tl!("Cancel")).clicked() { + action = DialogAction::Cancel; + } + }); + action +} + +/// Closes before running. When another document has become the active one, it closes as Cancel does +/// and changes no brush. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.dialog = None; + if d.fields.get(DOC).and_then(Value::as_u64) != app.session.active().map(|doc| doc.uid) { + return Ok(json!({})); + } + let params = json!({ + "name": d.fields.get("name").cloned().unwrap_or(Value::Null), + "ids": d.fields.get("ids").cloned().unwrap_or_else(|| json!([])), + "applyToStrokes": d.bool("apply"), + }); + app.run("brush.replaceArt", params) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/brush_replace_art/tests.rs b/crates/ui-egui/src/dialogs/brush_replace_art/tests.rs new file mode 100644 index 0000000000..f42b7805c2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/brush_replace_art/tests.rs @@ -0,0 +1,133 @@ +use egui::{Pos2, Rect, pos2, vec2}; +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use super::*; + +fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, time: f64) -> egui::FullOutput { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let input = egui::RawInput { screen_rect, time: Some(time), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + out +} + +fn press(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, time: f64) { + frame(ctx, app, vec![], time); + let out = frame(ctx, app, vec![], time + 0.1); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("dialog accessibility tree").nodes; + let button = nodes.iter().find(|(_, node)| node.label().map(str::trim) == Some(label)); + let bounds = button.and_then(|(_, node)| node.bounds()).expect("visible button"); + let at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true), button(false)], time + 0.2); +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + ctx +} + +fn rect(app: &mut VectorcraftApp, x: f64, y: f64, w: f64, h: f64) -> u64 { + app.run("shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap() +} + +fn art_height(app: &mut VectorcraftApp, name: &str) -> f64 { + let art: vectorcraft_doc::Node = serde_json::from_value(app.run("brush.get", json!({"name": name})).unwrap()["art"].clone()).unwrap(); + art.geometric_bounds().unwrap().height() +} + +/// An art brush "Bar" (40 × 8 pt) painting a path, and a 40 × 20 pt rectangle: → (path, rectangle). +fn used_brush(app: &mut VectorcraftApp) -> (NodeId, u64) { + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let bar = rect(app, 10.0, 10.0, 40.0, 8.0); + app.run("brush.new", json!({"type": "art", "name": "Bar", "ids": [bar]})).unwrap(); + let path = NodeId(rect(app, 100.0, 100.0, 80.0, 40.0)); + app.run("brush.apply", json!({"name": "Bar", "ids": [path.0]})).unwrap(); + (path, rect(app, 10.0, 40.0, 40.0, 20.0)) +} + +#[test] +fn an_unused_brush_takes_the_art_without_asking() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let bar = rect(&mut app, 10.0, 10.0, 40.0, 8.0); + app.run("brush.new", json!({"type": "art", "name": "Bar", "ids": [bar]})).unwrap(); + let tall = rect(&mut app, 10.0, 40.0, 40.0, 20.0); + replace(&mut app, "Bar", &[tall]); + assert!(app.ui.dialog.is_none()); + assert!((art_height(&mut app, "Bar") - 20.0).abs() < 1e-6); +} + +#[test] +fn apply_to_strokes_gives_the_brush_the_new_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (path, tall) = used_brush(&mut app); + replace(&mut app, "Bar", &[tall]); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND), "strokes use Bar: it asks"); + let ctx = context(); + let before = app.session.active().unwrap().history.undo.len(); + press(&ctx, &mut app, "Apply to Strokes", 0.0); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + assert!((art_height(&mut app, "Bar") - 20.0).abs() < 1e-6); + let stroke = app.session.active().unwrap().doc.node(path).and_then(|n| n.appearance.stroke()).unwrap(); + assert_eq!(stroke.brush.as_deref(), Some("Bar")); +} + +#[test] +fn leave_strokes_adds_a_copy_and_cancel_changes_nothing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (path, tall) = used_brush(&mut app); + let ctx = context(); + let before = app.session.active().unwrap().history.undo.len(); + replace(&mut app, "Bar", &[tall]); + press(&ctx, &mut app, "Cancel", 0.0); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.active().unwrap().history.undo.len(), before); + assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6); + replace(&mut app, "Bar", &[tall]); + press(&ctx, &mut app, "Leave Strokes", 1.0); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6, "Bar stays as it was"); + assert!((art_height(&mut app, "Bar copy") - 20.0).abs() < 1e-6); + let stroke = app.session.active().unwrap().doc.node(path).and_then(|n| n.appearance.stroke()).unwrap(); + assert_eq!(stroke.brush.as_deref(), Some("Bar")); +} + +/// Art a used brush cannot take is refused before the question: no dialog, the reason in the status +/// bar, and the document and its undo history as they were. +#[test] +fn art_with_no_width_is_refused_before_it_asks() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let _ = used_brush(&mut app); + let flat = app.run("shape.line", json!({"x1": 50, "y1": 100, "x2": 50, "y2": 160})).unwrap()["id"].as_u64().unwrap(); + let st = app.session.active().unwrap(); + let (doc, steps) = (st.doc.clone(), st.history.undo.len()); + replace(&mut app, "Bar", &[flat]); + assert!(app.ui.dialog.is_none(), "nothing asks"); + assert!(app.ui.status.contains("the selected art has no width"), "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none()); + assert_eq!(*st.doc, *doc); + assert_eq!(st.history.undo.len(), steps); +} + +#[test] +fn a_button_changes_no_brush_after_another_document_became_active() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let (_, tall) = used_brush(&mut app); + let ctx = context(); + replace(&mut app, "Bar", &[tall]); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + app.run("document.activate", json!({"index": 1})).unwrap(); + press(&ctx, &mut app, "Apply to Strokes", 0.0); + assert!(app.ui.dialog.is_none()); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert!((art_height(&mut app, "Bar") - 8.0).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 3ca205acdb..7c9aaf6873 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -106,6 +106,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], "object.pathfinder.trap" => &["thickness"], + crate::addons::cutting::BRIDGES => &["width"], _ => &[], } } @@ -119,6 +120,7 @@ fn order(command: &str) -> &'static [&'static str] { "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], crate::addons::typetools::RELEASE => &["mode", "keepOriginal"], crate::addons::image::REMOVE_BACKGROUND => &["background", "colors", "contiguous", "match", "tolerance", "softness"], + crate::addons::cutting::BRIDGES => &["width", "count", "direction", "keepOriginal"], _ => &[], } } @@ -129,6 +131,7 @@ fn choices(command: &str, key: &str) -> Option { ("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS), ("type.pathOptions", "alignToPath") => Some(PATH_ALIGN), (crate::addons::typetools::RELEASE, "mode") => Some(crate::addons::typetools::RELEASE_MODES), + (crate::addons::cutting::BRIDGES, "direction") => Some(crate::addons::cutting::BRIDGE_DIRECTIONS), ("text.areaOptions", "fit") => Some(AREA_FIT), ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index 06e96e45fe..6f51be44d9 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -63,6 +63,52 @@ pub(crate) fn ask_replace(app: &mut VectorcraftApp, path: &str, typed: &str) { app.ui.dialog = Some(Dialog::new(REPLACE, fields)); } +/// The dialog kind of the question whether a save or export may replace the files it writes beside +/// the one picked in the save panel ([`ask_replace_files`]). Fields: `message`, `detail`, `files` +/// (their paths), `allowed` (files an earlier OK allowed), `doc` (the document's uid), `__command` +/// and `__params`. +pub const REPLACE_FILES: &str = "replaceFiles"; + +pub(super) const REPLACE_FILES_SPEC: DialogSpec = DialogSpec { confirm: replace_files, ..SPEC }; + +/// Ask whether to replace `files`, files that are there beside the one a save or export of the +/// document `doc` (its uid) writes, which the save panel did not ask about (a file per artboard, a +/// linked image). OK runs `command` with `params` again on that document, now replacing those +/// files and `allowed` (those an earlier OK allowed); Cancel writes nothing. +pub(crate) fn ask_replace_files(app: &mut VectorcraftApp, doc: u64, files: &[String], allowed: &[String], command: &str, params: Value) { + let name = files.first().map(|f| vectorcraft_engine::cmd::fileio::file_name(f)).unwrap_or_default(); + let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", &name)]); + // Several: every one of them, by name. + let detail = match files { + [_, _, ..] => files.iter().map(|f| vectorcraft_engine::cmd::fileio::file_name(f)).collect::>().join("\n"), + _ => String::new(), + }; + let fields = + json!({"message": message, "detail": detail, "files": files, "allowed": allowed, "doc": doc, "__command": command, "__params": params}); + let d = Dialog::new(REPLACE_FILES, fields); + // Asked when a Background Save or Export ends: a dialog open then stays, and this waits its turn + // (however many messages wait: the save or export needs the answer). + match app.ui.dialog { + None => app.ui.dialog = Some(d), + Some(_) => app.ui.told.push(d), + } +} + +/// OK on [`ask_replace_files`]: what asked runs again on its document, and the files the question +/// named are replaced; one it writes now that was not named is asked about again. The save or export +/// reads the allowance when it starts, before a background one hands its work to the worker. +fn replace_files(app: &mut VectorcraftApp, d: &Dialog) -> Result { + app.ui.dialog = None; + if !crate::picks::on_document(app, d.fields.get("doc").and_then(Value::as_u64)) { + return Ok(Value::Null); + } + let paths = |k: &str| d.fields.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string); + app.picks.replace_files_ok = paths("allowed").chain(paths("files")).collect(); + let r = confirm(app, d); + app.picks.replace_files_ok.clear(); + r +} + /// The dialog kind of the question Reset Preferences asks over the Preferences dialog /// ([`ask_reset_prefs`]). Fields: `message`, `detail`. pub const RESET_PREFS: &str = "resetPreferences"; diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 3a58ee3a9e..777c872494 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -382,25 +382,28 @@ fn choices(effect: &str, key: &str) -> Option { /// The parameters whose dialogs list their values in their own bodies, as (label, value), each /// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI -/// language: Revolve's edge, the bevel of both Extrude & Bevels (Rounded translated in the "3d" -/// context first), Extrude & Bevel (Classic)'s surface and shading color, every Warp style's +/// language: the edge of both Revolves, the bevel of both Extrude & Bevels (Rounded translated in +/// the "3d" context first), the surface and shading color of the classic 3D effects, every Warp style's /// orientation, Rasterize's Color Model (its `document` value labeled /// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and /// the channel of Levels and Curves. pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option> { let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s); Some(match (effect, key) { - (vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")], + (vectorcraft_effects::REVOLVE | vectorcraft_effects::CLASSIC_REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")], ( vectorcraft_effects::REVOLVE | vectorcraft_effects::EXTRUDE | vectorcraft_effects::INFLATE | vectorcraft_effects::ROTATE, "shadowPosition", ) => { vec![(tl!("Behind Object"), "behind"), (tl!("Below Object"), "below")] } - (vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => { + (vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE, "surface") => { vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")] } - (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor") => vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")], + (vectorcraft_effects::CLASSIC_ROTATE, "surface") => vec![(tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse")], + (vectorcraft_effects::CLASSIC_EXTRUDE | vectorcraft_effects::CLASSIC_REVOLVE | vectorcraft_effects::CLASSIC_ROTATE, "shadingColor") => { + vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")] + } (vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![ (tl!("None"), "none"), (tl!("Classic"), "classic"), @@ -879,6 +882,13 @@ fn command(d: &Dialog) -> (&'static str, Value) { p["item"] = item.unwrap_or(Value::Null); p["params"] = params; let id = d.str("__effect"); + // Each target keeps the map its own Warp draws with: the dialog's `version` is the first + // target's. + if super::warp_effect::is_warp(&id) + && let Some(Value::Object(m)) = p.get_mut("params") + { + m.remove("version"); + } // The displayed style comes from the first target. Preserve other targets' Warp IDs // unless the user deliberately chooses Style, including after a preview rollback. if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") { @@ -965,6 +975,11 @@ mod tests { (vectorcraft_effects::CLASSIC_EXTRUDE, "bevel"), (vectorcraft_effects::CLASSIC_EXTRUDE, "surface"), (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor"), + (vectorcraft_effects::CLASSIC_REVOLVE, "edge"), + (vectorcraft_effects::CLASSIC_REVOLVE, "surface"), + (vectorcraft_effects::CLASSIC_REVOLVE, "shadingColor"), + (vectorcraft_effects::CLASSIC_ROTATE, "surface"), + (vectorcraft_effects::CLASSIC_ROTATE, "shadingColor"), ("warp.arc", "orientation"), ("warp.twist", "orientation"), (vectorcraft_effects::RASTERIZE, "background"), diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index f537595ebc..6c9d95b601 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -239,7 +239,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?; let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None }; let bar = match &**kind { - EnvelopeKind::Warp { style, bend, h, v, horizontal } => { + EnvelopeKind::Warp { style, bend, h, v, horizontal, .. } => { Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal } } EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols }, diff --git a/crates/ui-egui/src/dialogs/para_style_options.rs b/crates/ui-egui/src/dialogs/para_style_options.rs index 34a623cc5c..1d59bdd679 100644 --- a/crates/ui-egui/src/dialogs/para_style_options.rs +++ b/crates/ui-egui/src/dialogs/para_style_options.rs @@ -1,6 +1,7 @@ //! Paragraph Style Options fields shared by editing and creating a paragraph style: General, Basic //! Paragraph Formats (alignment, indents and spacing), Tabs (the style's tab stops), Composition -//! (composer and Roman Hanging Punctuation), Hyphenation and Justification. +//! (composer and Roman Hanging Punctuation), Hyphenation, Justification and East Asian Composition +//! Settings (Korean Line Break Anywhere). //! //! Each field is unset (empty, or a checkbox in its unset state) or set, and an unset field leaves //! its attribute out of the style. Distances are shown and typed in the General unit and kept in @@ -23,7 +24,7 @@ use crate::state::Dialog; pub const KIND: &str = "paraStyleOptions"; /// The section ids, in the order the section list shows them. -pub(crate) const SECTIONS: [&str; 6] = ["general", "basic", "tabs", "composition", "hyphenation", "justification"]; +pub(crate) const SECTIONS: [&str; 7] = ["general", "basic", "tabs", "composition", "hyphenation", "justification", "eastAsian"]; /// The alignments of a tab stop with the names the `tabs` field gives them, in the Tabs panel's /// order. const TAB_ALIGNS: [(TabAlign, &str); 4] = @@ -104,6 +105,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "glyph_max", "auto_leading", "single_word", + "korean_line_break_anywhere", "tabs", "section", ] @@ -232,7 +234,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map, unit: Unit) -> Map Result, String> { let same = unchanged("tabs", "__resetTabs"); let value = if same { None } else { tab_stops(d, &original, unit)? }; keep_or(&mut attrs, "tabs", same, value); + + let same = unchanged("korean_line_break_anywhere", "__resetEastAsian"); + keep_or(&mut attrs, "korean_line_break_anywhere", same, switch("korean_line_break_anywhere")); Ok(attrs) } @@ -554,9 +559,15 @@ fn reset_tabs(d: &mut Dialog) { d.fields.insert("__tabSel".into(), json!(0)); } +/// Reset Panel in East Asian Composition Settings: Korean Line Break Anywhere becomes unset. +fn reset_east_asian(d: &mut Dialog) { + d.fields.insert("__resetEastAsian".into(), json!(true)); + d.fields.insert("korean_line_break_anywhere".into(), Value::Null); +} + /// Whether the section `d` shows has a Reset Panel: every section but General. pub(crate) fn can_reset(d: &Dialog) -> bool { - ["basic", "tabs", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) + ["basic", "tabs", "composition", "hyphenation", "justification", "eastAsian"].contains(&d.str("section").as_str()) } /// Reset Panel: clear the settings of the section `d` shows. @@ -567,6 +578,7 @@ pub(crate) fn reset_section(d: &mut Dialog) { "composition" => reset_composition(d), "hyphenation" => reset_hyphenation(d), "justification" => reset_justification(d), + "eastAsian" => reset_east_asian(d), _ => {} } } @@ -588,7 +600,15 @@ pub fn reset_panel(app: &mut VectorcraftApp) -> Result { /// The section list of Paragraph Style Options and New Paragraph Style. pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) { ui.vertical(|ui| { - let labels = [tl!("General"), tl!("Basic Paragraph Formats"), tl!("Tabs"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]; + let labels = [ + tl!("General"), + tl!("Basic Paragraph Formats"), + tl!("Tabs"), + tl!("Composition"), + tl!("Hyphenation"), + tl!("Justification"), + tl!("East Asian Composition Settings"), + ]; for (id, label) in SECTIONS.into_iter().zip(labels) { if ui.selectable_label(d.str("section") == id, label).clicked() { d.fields.insert("section".into(), json!(id)); @@ -885,6 +905,14 @@ pub(crate) fn justification_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// East Asian Composition Settings: Korean Composition Settings › Korean Line Break Anywhere. +pub(crate) fn east_asian_body(ui: &mut egui::Ui, d: &mut Dialog) { + ui.strong(tl!("Korean Composition Settings")); + egui::Grid::new("paragraph-style-east-asian").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + switch(ui, d, "korean_line_break_anywhere", tl!("Korean Line Break Anywhere"), true); + }); +} + /// The heading of a spacing column, for the accessible name of its field. fn column_name(column: &str) -> &'static str { match column { @@ -916,6 +944,7 @@ pub(crate) fn section_body(ui: &mut egui::Ui, d: &mut Dialog) { "composition" => composition_body(ui, d), "hyphenation" => hyphenation_body(ui, d), "justification" => justification_body(ui, d), + "eastAsian" => east_asian_body(ui, d), _ => general_body(ui, d), } } diff --git a/crates/ui-egui/src/dialogs/para_style_options/tests.rs b/crates/ui-egui/src/dialogs/para_style_options/tests.rs index 38d158b102..467b0dc1ae 100644 --- a/crates/ui-egui/src/dialogs/para_style_options/tests.rs +++ b/crates/ui-egui/src/dialogs/para_style_options/tests.rs @@ -157,7 +157,8 @@ fn invalid_values_keep_the_dialog_open() { fn reset_panel_clears_only_its_section() { let mut app = app(); let all = json!({"justify": "Center", "space_after": 6.0, "composer": "singleLine", "roman_hanging": true, "hyphenate": true, - "hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}]}); + "hyphenation": {"words_longer_than": 8}, "justification": {"auto_leading": 150.0}, "tabs": [{"position": 20.0}], + "korean_line_break_anywhere": true}); app.run("paraStyle.new", json!({"name": "Body", "attrs": all})).unwrap(); let stored = def(&app, "Body"); for (section, cleared) in [ @@ -166,6 +167,7 @@ fn reset_panel_clears_only_its_section() { ("composition", &["composer", "roman_hanging"]), ("hyphenation", &["hyphenate", "hyphenation"]), ("justification", &["justification"]), + ("eastAsian", &["korean_line_break_anywhere"]), ] { app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); assert!(app.run("ui.paraStyleOptions.resetPanel", json!({})).is_err(), "General has no Reset Panel"); @@ -266,6 +268,24 @@ fn new_paragraph_style_offers_the_sections() { assert_eq!(paras(&app, id)[0].justify, Justify::Center); } +/// East Asian Composition Settings › Korean Line Break Anywhere sets the style's attribute, which +/// its paragraphs take; unset, the style leaves it out and the paragraphs keep their own. +#[test] +fn korean_line_break_anywhere_is_set_by_the_east_asian_section() { + let mut app = app(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "가나 다라"})).unwrap()["id"].as_u64().unwrap(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + assert!(!paras(&app, id)[0].korean_line_break_anywhere); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().fields["korean_line_break_anywhere"].is_null()); + set(&mut app, "section", json!("eastAsian")); + set(&mut app, "korean_line_break_anywhere", json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body").get("korean_line_break_anywhere"), Some(&json!(true))); + assert!(paras(&app, id)[0].korean_line_break_anywhere, "the style's paragraphs take it"); +} + /// The rows of the `tabs` field of the open dialog. fn tab_rows(app: &VectorcraftApp) -> Vec { app.ui.dialog.as_ref().unwrap().fields["tabs"].as_array().cloned().unwrap_or_default() @@ -513,6 +533,7 @@ fn every_section_draws_its_fields() { ("composition", "Roman Hanging Punctuation"), ("hyphenation", "Hyphenation Zone:"), ("justification", "Glyph Scaling:"), + ("eastAsian", "Korean Line Break Anywhere"), ] { set(&mut app, "section", json!(section)); let mut texts = String::new(); diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index b0cac0c011..c7a306887e 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -1,12 +1,12 @@ //! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate, -//! and Extrude & Bevel (Classic), whose own controls are in `three_d_classic`. The object and the -//! rotation and light gizmos live on the artboard. All document changes run through the effect +//! and Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), whose own controls are in +//! `three_d_classic`. The object and the rotation and light gizmos live on the artboard. All document changes run through the effect //! dialog's existing preview/commit commands. use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; use crate::state::Dialog; use crate::theme::Tokens; @@ -145,11 +145,12 @@ pub(super) fn c3(s: &'static str) -> &'static str { crate::i18n::tr_ctx(crate::i18n::current(), "3d", s) } -/// Extrude & Bevel, Inflate and Extrude & Bevel (Classic): Object, Rotation and Lighting tabs -/// (Rotate has no Object tab). The rotation gizmo shows on the first two, the light gizmo on -/// Lighting. +/// Extrude & Bevel, Inflate, Extrude & Bevel (Classic) and Revolve (Classic): Object, Rotation and +/// Lighting tabs (Rotate and Rotate (Classic) have no Object tab). The rotation gizmo shows on the +/// first two, the light gizmo on Lighting. fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) { - let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE; + let classic = vectorcraft_effects::is_classic_three_d(id); + let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE || id == CLASSIC_REVOLVE; let mut tabs = vec![]; if object { tabs.push(tl!("Object")); @@ -169,7 +170,7 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & } ui.add_space(8.0); if super::revolve_gizmo::lighting(d) { - if id == CLASSIC_EXTRUDE { + if classic { super::three_d_classic::surface_controls(ui, d); } else { lighting_controls(ui, d); @@ -178,6 +179,8 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & let unit = app.session.general_unit(); if id == CLASSIC_EXTRUDE { super::three_d_classic::object_controls(ui, d, unit); + } else if id == CLASSIC_REVOLVE { + super::three_d_classic::revolve_controls(ui, d, unit); } else if id == EXTRUDE { extrude_controls(ui, d, unit); } else { diff --git a/crates/ui-egui/src/dialogs/revolve/tests.rs b/crates/ui-egui/src/dialogs/revolve/tests.rs index ba11d8666c..fd802f83f0 100644 --- a/crates/ui-egui/src/dialogs/revolve/tests.rs +++ b/crates/ui-egui/src/dialogs/revolve/tests.rs @@ -379,6 +379,87 @@ fn classic_extrude_bevel_shows_its_options_and_applies_as_one_step() { assert_eq!(h.app.session.doc().unwrap().doc, before); } +/// Revolve (Classic): its angle, cap, offset and axis side on the Object tab, Position on the +/// Rotation tab, and the classic surface on the Lighting tab, without Draw Hidden Faces; OK keeps +/// the edit as one undo step. +#[test] +fn classic_revolve_shows_its_options_and_applies_as_one_step() { + use vectorcraft_effects::CLASSIC_REVOLVE; + let mut h = Harness::with("M100 40 L130 60 L130 140 L100 160", CLASSIC_REVOLVE, vec2(1400.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + for text in ["Angle", "Cap", "Offset", "Axis side", "Left"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Segments" || t == "Revolve angle" || t == "Depth"), "the other effects' options stay out"); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__tab".into(), json!("rotation")); + h.settle(); + for text in ["Presets", "Rotation X", "Perspective", "Off-Axis Front"] { + h.rect(text); + } + h.click(h.rect("Lighting").center()); + h.settle(); + for text in ["Surface", "Plastic", "Light Intensity", "Ambient Light", "Highlight Size", "Blend Steps", "Shading Color"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Draw Hidden Faces"), "every face is drawn"); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°."); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("edge".into(), json!("right")); + d.fields.insert("offset".into(), json!(12.0)); + d.fields.insert("surface".into(), json!("wireframe")); + h.settle(); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type."); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0]; + assert_eq!(fx.id, CLASSIC_REVOLVE); + assert_eq!( + (fx.params["edge"].clone(), fx.params["offset"].as_f64(), fx.params["surface"].clone()), + (json!("right"), Some(12.0), json!("wireframe")) + ); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + +/// Rotate (Classic): Position on the Rotation tab, with no Object tab, and No Shading or Diffuse +/// on the Lighting tab, without the highlight or Draw Hidden Faces; OK keeps the edit as one undo +/// step. +#[test] +fn classic_rotate_shows_its_options_and_applies_as_one_step() { + use vectorcraft_effects::CLASSIC_ROTATE; + let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", CLASSIC_ROTATE, vec2(1400.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + for text in ["Rotation", "Presets", "Rotation X", "Perspective", "Off-Axis Front"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Object" || t == "Depth" || t == "Angle"), "no Object tab"); + h.click(h.rect("Lighting").center()); + h.settle(); + for text in ["Surface", "Diffuse", "Light Intensity", "Ambient Light", "Blend Steps", "Shading Color"] { + h.rect(text); + } + assert!( + !h.texts.iter().any(|(t, _)| t == "Highlight Intensity" || t == "Highlight Size" || t == "Draw Hidden Faces"), + "no highlight and no hidden faces" + ); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°."); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("surface".into(), json!("none")); + d.fields.insert("rotationY".into(), json!(40.0)); + h.settle(); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type."); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0]; + assert_eq!(fx.id, CLASSIC_ROTATE); + assert_eq!((fx.params["surface"].clone(), fx.params["rotationY"].as_f64()), (json!("none"), Some(40.0))); + assert!(fx.params.get("highlightSize").is_none() && fx.params.get("drawHiddenFaces").is_none()); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + #[test] fn extrude_bevel_options_enable_with_a_bevel_shape() { let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0)); diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 6ef51d38ed..5016eacd06 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -1,9 +1,9 @@ //! Artboard rotation and light controls for the active 3D effect dialog (Extrude & Bevel, Revolve, -//! Inflate, Rotate). Gestures edit fields; +//! Inflate, Rotate and the classic 3D effects). Gestures edit fields; //! the existing effect preview applies commands and owns OK, Cancel and undo. use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2}; use serde_json::json; -use vectorcraft_effects::REVOLVE; +use vectorcraft_effects::{CLASSIC_REVOLVE, REVOLVE}; use vectorcraft_geom::Point; use vectorcraft_three_d::Revolve; @@ -20,14 +20,11 @@ pub(crate) fn lighting(d: &Dialog) -> bool { d.str("__gizmo") == "light" } -/// Does the effect take light: Shading on, or for Extrude & Bevel (Classic) a Diffuse or Plastic +/// Does the effect take light: Shading on, or for the classic 3D effects a Diffuse or Plastic /// surface? fn lit(d: &Dialog) -> bool { - if d.str("__effect") == vectorcraft_effects::CLASSIC_EXTRUDE { - matches!(d.str("surface").as_str(), "diffuse" | "plastic") - } else { - d.bool("shade") - } + let id = d.str("__effect"); + if vectorcraft_effects::is_classic_three_d(&id) { matches!(d.str("surface").as_str(), "diffuse" | "plastic") } else { d.bool("shade") } } pub(crate) fn hint(d: &Dialog) -> &'static str { @@ -97,8 +94,9 @@ impl Geometry { let bounds = node.geometric_bounds()?; let xf = Xf::new(app.canvas_rect?, app.view()?); let options = super::revolve::light_options(d); - // Revolve turns about its axis; the others about the centre of the art's bounds. - let origin = if d.str("__effect") == REVOLVE { options.rotation_origin(bounds) } else { bounds.center() }; + // Both Revolves turn about their axis; the others about the centre of the art's bounds. + let id = d.str("__effect"); + let origin = if id == REVOLVE || id == CLASSIC_REVOLVE { options.rotation_origin(bounds) } else { bounds.center() }; let center = xf.to_screen(origin); if !center.x.is_finite() || !center.y.is_finite() || !xf.rect.contains(center) { return None; diff --git a/crates/ui-egui/src/dialogs/tests_hpgl.rs b/crates/ui-egui/src/dialogs/tests_hpgl.rs new file mode 100644 index 0000000000..238a254cfb --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_hpgl.rs @@ -0,0 +1,28 @@ +//! Export As → HP-GL, drawn headlessly: listed with the other formats, written straight away (no +//! options dialog), one plot per artboard with Use Artboards. + +use serde_json::json; + +use super::tests_export::{app, frame, kind, names, set}; +use super::*; + +#[test] +fn export_as_lists_hpgl_and_writes_plots() { + let (mut app, written) = app(2); + app.run("file.exportAs", json!({"format": "hpgl"})).unwrap(); + assert_eq!(kind(&app), Some("exportAs")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "hpgl"); + frame(&mut app); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "no options dialog follows"); + assert_eq!(r["path"], "/out/Untitled-1.plt"); + assert!(written.borrow()[0].1.starts_with(b"IN;")); + assert!(written.borrow()[0].1.ends_with(b"PU;SP0;")); + // One plot per artboard. + app.run("file.exportAs", json!({"format": "hpgl"})).unwrap(); + set(&mut app, "useArtboards", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + let n = names(&written); + assert_eq!(&n[1..], ["/out/Untitled-1-Artboard-1.plt", "/out/Untitled-1-Artboard-2.plt"]); +} diff --git a/crates/ui-egui/src/dialogs/tests_warp.rs b/crates/ui-egui/src/dialogs/tests_warp.rs index e7cbfcc5b7..21d3e8e1a4 100644 --- a/crates/ui-egui/src/dialogs/tests_warp.rs +++ b/crates/ui-egui/src/dialogs/tests_warp.rs @@ -616,3 +616,33 @@ fn warp_options_style_keyboard_enter_does_not_accept_dialog() { assert_eq!(app.session.doc().unwrap().doc, moved); } } + +/// Warp Options editing the Warps of two objects, one new and one saved without a map version, +/// changes the Bend of both and keeps each one's own `version`. +#[test] +fn warp_options_edit_keeps_each_targets_map_version() { + let (mut app, first) = app(); + app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap(); + let second = NodeId(app.run("shape.rectangle", json!({"x": 230, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap()); + app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap(); + // As a file saved before the version existed gives it. + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + if let Some(Value::Object(p)) = doc.node_mut(second).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) { + p.remove("version"); + } + app.run("select.set", json!({"ids": [first.0, second.0]})).unwrap(); + app.run("effect.dialog", json!({"effect": "warp.arc"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, effect::EXISTS); + let ctx = context(); + settle(&mut app, &ctx); + click_text(&mut app, &ctx, "Edit"); + settle(&mut app, &ctx); + make_warp_edit(&mut app, &ctx, "bend"); + click_text(&mut app, &ctx, "OK"); + assert!(app.ui.dialog.is_none()); + let doc = app.session.doc().unwrap().doc.clone(); + let params = |id: NodeId| doc.node(id).unwrap().appearance.effects[0].params.clone(); + assert_eq!((¶ms(first)["bend"], ¶ms(first)["version"]), (&json!(25.0), &json!(vectorcraft_doc::live::WARP_NET))); + assert_eq!(params(second)["bend"], json!(25.0)); + assert!(params(second).get("version").is_none(), "{:?}", params(second)); +} diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs index 06d034fce4..a3857c914a 100644 --- a/crates/ui-egui/src/dialogs/three_d_classic.rs +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -1,11 +1,12 @@ -//! Extrude & Bevel (Classic)'s own controls in the compact 3D dialog: the extrude and bevel options -//! on the Object tab, and the surface with its lights, highlight, blend steps and shading color on -//! the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D effect -//! shares. +//! The own controls of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) in the +//! compact 3D dialog: the extrude and bevel options, or the revolve options, on the Object tab +//! (Rotate (Classic) has none), and the surface with its lights, highlight, blend steps and shading +//! color on the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D +//! effect shares. use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::CLASSIC_EXTRUDE; +use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CLASSIC_ROTATE}; use super::revolve::{c3, choice, heading, label, light_list, light_record, number, reset_dot, selected_light, store_selected, toggle}; use crate::state::Dialog; @@ -25,10 +26,28 @@ pub(super) fn object_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { }); } +/// Revolve (Classic)'s angle, cap and offset, and the edge of the profile's bounds the axis is at. +pub(super) fn revolve_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + heading(ui, tl!("Object")); + number(ui, d, "angle", tl!("Angle"), (0.0, 360.0), "°", 1, None); + choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]); + number(ui, d, "offset", tl!("Offset"), (0.0, 1000.0), "", 1, Some(unit)); + let sides: Vec<(Value, &str)> = super::effect::own_choices(CLASSIC_REVOLVE, "edge", false) + .unwrap_or_default() + .into_iter() + .map(|(label, value)| (json!(value), label)) + .collect(); + choice(ui, d, "edge", tl!("Axis side"), &sides); +} + /// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic -/// only), Blend Steps and Shading Color; Draw Hidden Faces last. +/// only, and not for Rotate (Classic), which has no Plastic surface), Blend Steps and Shading Color; +/// Draw Hidden Faces last, for Extrude & Bevel (Classic) (Revolve (Classic) draws every face, and +/// Rotate (Classic)'s plane is seen from either side). pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { - choice(ui, d, "surface", tl!("Surface"), &options("surface")); + let id = d.str("__effect"); + let effect = if id == CLASSIC_ROTATE { CLASSIC_ROTATE } else { CLASSIC_EXTRUDE }; + choice(ui, d, "surface", tl!("Surface"), &options_of(effect, "surface")); let surface = d.str("surface"); ui.add_enabled_ui(surface == "diffuse" || surface == "plastic", |ui| { light_list(ui, d); @@ -42,22 +61,31 @@ pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { store_selected(d, &selected); } number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 100.0), "%", 1, None); - ui.add_enabled_ui(surface == "plastic", |ui| { - number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None); - number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None); - }); + if id != CLASSIC_ROTATE { + ui.add_enabled_ui(surface == "plastic", |ui| { + number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None); + }); + } number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None); - choice(ui, d, "shadingColor", tl!("Shading Color"), &options("shadingColor")); + choice(ui, d, "shadingColor", tl!("Shading Color"), &options_of(effect, "shadingColor")); if d.str("shadingColor") == "custom" { shading_custom(ui, d); } }); - toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); + if id == CLASSIC_EXTRUDE { + toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); + } } /// The values of Extrude & Bevel (Classic)'s `key` as the dropdowns list them, (value, label). fn options(key: &str) -> Vec<(Value, &'static str)> { - super::effect::own_choices(CLASSIC_EXTRUDE, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect() + options_of(CLASSIC_EXTRUDE, key) +} + +/// The values of classic 3D effect `effect`'s `key` as the dropdowns list them, (value, label). +fn options_of(effect: &str, key: &str) -> Vec<(Value, &'static str)> { + super::effect::own_choices(effect, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect() } /// The custom shading color: a color button, its hex value and its reset dot. diff --git a/crates/ui-egui/src/dialogs/warp_effect.rs b/crates/ui-egui/src/dialogs/warp_effect.rs index ff530fbdf2..e8ef2bb6b7 100644 --- a/crates/ui-egui/src/dialogs/warp_effect.rs +++ b/crates/ui-egui/src/dialogs/warp_effect.rs @@ -1,7 +1,7 @@ //! Effect > Warp controls. The style remains an effect ID; Envelope Warp has a different schema. use egui::{Pos2, Rect, Stroke, vec2}; use serde_json::{Map, Value, json}; -use vectorcraft_effects::{WARP_STYLES, WarpStyle, warp_point}; +use vectorcraft_effects::{WARP_STYLES, WarpNet, WarpStyle}; use super::form; use crate::state::Dialog; @@ -38,6 +38,8 @@ fn shown(label: &str) -> String { fn style_preview(ui: &mut egui::Ui, suffix: &str) { let (rect, _) = ui.allocate_exact_size(vec2(30.0, 22.0), egui::Sense::hover()); let Some(style) = WarpStyle::from_id(suffix) else { return }; + // The map new warps take, on a box 0.55 times as high as it is wide. + let net = WarpNet::new(style, 0.45, 0.0, 0.0, 2.0, 1.1, false); let mut lines = vec![]; let mut bounds = Rect::NOTHING; for vertical in [false, true] { @@ -46,8 +48,8 @@ fn style_preview(ui: &mut egui::Ui, suffix: &str) { .map(|i| { let along = -1.0 + f64::from(i) / 8.0; let (x, y) = if vertical { (fixed, along) } else { (along, fixed) }; - let (x, y) = warp_point(style, 0.45, 0.0, 0.0, 0.55, x, y); - let p = Pos2::new(x as f32, y as f32); + let q = net.at((x + 1.0) / 2.0, (y + 1.0) / 2.0); + let p = Pos2::new(q.x as f32, q.y as f32); bounds.extend_with(p); p }) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97e5b335d3..5f0723f4da 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1119,6 +1119,8 @@ font-technology Variable Променливи Extrude & Bevel… Избутване и фасетка… 3D (Classic) 3D (класически) Extrude & Bevel (Classic)… Избутване и фасетка (класически)… + Revolve (Classic)… Завъртане (класическо)… + Rotate (Classic)… Ротация (класическа)… Surface Повърхност Wireframe Телена рамка No Shading Без засенчване @@ -2291,6 +2293,7 @@ font-technology Variable Променливи Options: Опции: Options… Опции… Organic Fades Органични преливания + Holographic Холографски or more spaces with a tab или повече интервала с табулация Order: Ред: Ordinals Поредни номера @@ -2839,6 +2842,9 @@ digits Malayalam Малаялам Rise… Издигане… Roman Baseline Латинска основна линия Roman Hanging Punctuation Латинска висяща пунктуация + Korean Line Break Anywhere Пренасяне на корейски текст навсякъде + East Asian Composition Settings Настройки за източноазиатска композиция + Korean Composition Settings Настройки за корейска композиция Rotate Завъртане Rotate 90° CW Завъртане на 90° по часовниковата стрелка Rotate Tool Инструмент „Завъртане“ @@ -3628,6 +3634,8 @@ digits Malayalam Малаялам Type on a Path Текст по пътя Type on a Path Options Опции за текст по пътя Type on a Path Options… Опции за текст по път… + Center on Top Центриране горе + Center on Bottom Центриране долу Type on a Path Tool Инструмент „Текст по път“ Type Orientation Ориентация на текста Type stays text, and every stroke is a line with its lineweight and dashes. Текстът остава текст, а всеки щрих е линия с дебелина и чертички. @@ -4407,6 +4415,7 @@ axis Both И двете @msg not a valid plug-in module: {0} не е валиден модул за плъгин: {0} @msg not an EMF or WMF file не е EMF или WMF файл @msg not an HP-GL file не е HP-GL файл +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` трябва да бъде масив от 1 до 255 цвята във формат #rrggbb @msg not found не е открито @msg not in pattern editing mode не е в режим на редактиране на мостра @msg finish editing the pattern first първо завършете редактирането на мострата @@ -6052,6 +6061,17 @@ axis Y Axis Y ос Charge Wave Вълна на заряд Spark Trail Следа от искри Calligraphic Nibs Калиграфски писци + Bristle Brushes Четки с косъм + Fine Liner Тънък контур + Pointed Flat Заострена плоска + Stubby Round Къса кръгла + Dry Scrub Сухо търкане + Soft Dome Мек купол + Filbert Плоска овална + Angled Round Скосена кръгла + Angled Flat Скосена плоска + Fan Blender Ветрило за смесване + Grass Fan Ветрило за трева Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури Save Brush Library… Запази библиотека с четки… @@ -6397,6 +6417,10 @@ breakText Glyphs Глифи Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Щрихи използват една или повече от тези четки. Да се премахнат ли четките от тези щрихи и да се изтрият четките? Remove Strokes Премахване на щрихи Place Brush Art Поставяне на графика на четка + Replace Brush Art Замяна на графика на четка + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Щрихи използват тази четка. Да се приложи ли новата графика към тези щрихи, или те да останат както са и да се добави копие на четката с новата графика? + Apply to Strokes Прилагане към щрихите + Leave Strokes Оставяне на щрихите @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` няма графика (имат я само разпръснатите и артистичните четки и четките с мостра) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменти за подреждане @@ -6632,6 +6656,26 @@ breakText Glyphs Глифи @plural {n} earlier line dropped|{n} earlier lines dropped {n} по-ранен ред премахнат|{n} по-ранни реда премахнати @plural {n} line cut|{n} lines cut {n} ред съкратен|{n} реда съкратени +# Learn panel + Learn Обучение + Explore tutorials Разгледайте уроците + For you За вас + Skills Умения + Get started Първи стъпки + Quick skill guide Бързо ръководство за умения + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Кратки практически уроци за основните инструменти, групирани по умения. Изберете умение, за да видите уроците му. + List Tutorials Извеждане на уроци + Show Tutorial Показване на урок +@msg missing `tutorial` (see learn.list) липсва `tutorial` (вижте learn.list) +@msg unknown tutorial `{id}` (see learn.list) непознат урок `{id}` (вижте learn.list) + # Deferred plug-in effects Refresh Deferred Effects Обновяване на отложените ефекти Deferred Effects Отложени ефекти + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Инструменти за рязане + Stencil Bridges… Мостчета на шаблона… + Stencil Bridges Мостчета на шаблона + Shortest Crossing Най-късо пресичане + Direction Посока diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 75353321ff..8caa306eed 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -398,6 +398,8 @@ Stair Step Stepenice Gravity Gravitacija Type on a Path Options… Opcije teksta na putanji… + Center on Top Centriraj na vrhu + Center on Bottom Centriraj na dnu Threaded Text Povezani tekst Create Kreiraj Release Selection Oslobodi odabir @@ -487,6 +489,8 @@ Extrude & Bevel… Istiskivanje i zakošenje… 3D (Classic) 3D (klasično) Extrude & Bevel (Classic)… Istiskivanje i zakošenje (klasično)… + Revolve (Classic)… Rotaciono tijelo (klasično)… + Rotate (Classic)… Rotiraj (klasično)… Surface Površina Wireframe Žičani model No Shading Bez sjenčenja @@ -840,6 +844,7 @@ Sky Gradients Gradijenti neba Neutral Gradients Neutralni gradijenti Organic Fades Organski prelazi + Holographic Holografski Duotone Gradients Duoton gradijenti Radial Glows Radijalni sjajevi Save Swatch Library… Sačuvaj biblioteku uzoraka boja… @@ -1987,6 +1992,9 @@ digits Malayalam Malajalam Right Indent Desna uvlaka Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo Roman Hanging Punctuation Latinična viseća interpunkcija + Korean Line Break Anywhere Prelom korejskog reda bilo gdje + East Asian Composition Settings Postavke istočnoazijske kompozicije + Korean Composition Settings Postavke korejske kompozicije Select a text object to edit its paragraph attributes. Odaberite tekstualni objekat da uredite njegove atribute pasusa. Single-line Composer Slagač jednog reda Toggle Line Composer Uključi/isključi Line Composer @@ -4798,6 +4806,7 @@ imageTrace Outline Obris @msg not a valid plug-in module: {0} nije važeći modul dodatka: {0} @msg not an EMF or WMF file nije EMF ni WMF datoteka @msg not an HP-GL file nije HP-GL datoteka +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` mora biti niz od 1 do 255 boja #rrggbb @msg not an export presets file (format `{_1}`) nije datoteka predložaka izvoza (format `{_1}`) @msg not an export presets file: {e} nije datoteka predložaka izvoza: {e} @msg not found nije pronađeno @@ -6103,6 +6112,17 @@ axis Y Axis Y osa Charge Wave Talas naboja Spark Trail Trag iskri Calligraphic Nibs Kaligrafski vrhovi + Bristle Brushes Četkice s čekinjama + Fine Liner Tanka linija + Pointed Flat Šiljata ravna + Stubby Round Kratka okrugla + Dry Scrub Suho trljanje + Soft Dome Meka kupola + Filbert Ovalna ravna + Angled Round Kosa okrugla + Angled Flat Kosa ravna + Fan Blender Lepeza za stapanje + Grass Fan Lepeza za travu Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici Save Brush Library… Sačuvaj biblioteku četkica… @@ -6448,6 +6468,10 @@ shape Open Otvoren Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Potezi koriste jednu ili više ovih četkica. Ukloniti četkice s tih poteza i izbrisati četkice? Remove Strokes Ukloni poteze Place Brush Art Postavi crtež četkice + Replace Brush Art Zamijeni crtež četkice + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Potezi koriste ovu četkicu. Primijeniti novi crtež na te poteze ili ih ostaviti kakvi jesu i dodati kopiju četkice s novim crtežom? + Apply to Strokes Primijeni na poteze + Leave Strokes Ostavi poteze @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nema crtež (imaju ga samo raspršene i umjetničke četkice i četkice uzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alati za raspoređivanje @@ -6684,6 +6708,26 @@ shape Open Otvoren @plural {n} earlier line dropped|{n} earlier lines dropped {n} raniji red uklonjen|{n} ranija reda uklonjena|{n} ranijih redova uklonjeno @plural {n} line cut|{n} lines cut {n} red skraćen|{n} reda skraćena|{n} redova skraćeno +# Learn panel + Learn Učenje + Explore tutorials Istražite tutorijale + For you Za vas + Skills Vještine + Get started Prvi koraci + Quick skill guide Brzi vodič kroz vještine + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Kratki praktični tutorijali za osnovne alate, grupisani po vještinama. Odaberite vještinu da vidite njene tutorijale. + List Tutorials Izlistaj tutorijale + Show Tutorial Prikaži tutorijal +@msg missing `tutorial` (see learn.list) nedostaje `tutorial` (vidi learn.list) +@msg unknown tutorial `{id}` (see learn.list) nepoznat tutorijal `{id}` (vidi learn.list) + # Deferred plug-in effects Refresh Deferred Effects Osvježi odgođene efekte Deferred Effects Odgođeni efekti + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Alati za rezanje + Stencil Bridges… Mostići šablona… + Stencil Bridges Mostići šablona + Shortest Crossing Najkraći prijelaz + Direction Smjer diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 75e0b9ea20..5926045556 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -608,6 +608,17 @@ antiAlias Sharp Nítid Calligraphic Brush Pinzell cal·ligràfic Calligraphic Brush Options Opcions del pinzell cal·ligràfic Calligraphic Nibs Plomes cal·ligràfiques + Bristle Brushes Pinzells de cerres + Fine Liner Traç fi + Pointed Flat Pla punxegut + Stubby Round Rodó curt + Dry Scrub Fregat sec + Soft Dome Cúpula suau + Filbert Llengua de gat + Angled Round Rodó bisellat + Angled Flat Pla bisellat + Fan Blender Ventall difuminador + Grass Fan Ventall d'herba Can't export “{name}”. No es pot exportar “{name}”. Can't open “{name}”. No es pot obrir “{name}”. Can't print these settings: see the Summary No es poden imprimir aquests paràmetres: consulta el resum @@ -1573,6 +1584,8 @@ antiAlias Sharp Nítid Extrude & Bevel… Extrudeix i bisella… 3D (Classic) 3D (clàssic) Extrude & Bevel (Classic)… Extrudeix i bisella (clàssic)… + Revolve (Classic)… Revoluciona (clàssic)… + Rotate (Classic)… Gira (clàssic)… Surface Superfície Wireframe Filferro No Shading Sense ombra @@ -2920,6 +2933,10 @@ digits Malayalam Malaiàlam Arch Volta @msg select point type or type on a path seleccioneu text de punt o text sobre un traçat @msg type on a path in a thread cannot use Dynamic Text: {frames} el text sobre un traçat d’un text enllaçat no pot fer servir el text dinàmic: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} el text sobre un traçat obert o compost no es pot centrar a dalt o a baix: {open} +@msg type on a path with no length or no height cannot be centered: {flat} el text sobre un traçat sense longitud o sense alçada no es pot centrar: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el text sobre un traçat d’un text enllaçat no es pot centrar a dalt o a baix: {frames} +@msg select type on a closed path seleccioneu text sobre un traçat tancat Optimize for fast web view Optimitza per a la visualització web ràpida Optimized Optimitzat Options Opcions @@ -2927,6 +2944,7 @@ digits Malayalam Malaiàlam Options: Opcions: Options… Opcions… Organic Fades Esvaïments orgànics + Holographic Hologràfics or more spaces with a tab o més espais amb un tabulador Orange Taronja Order: Ordre: @@ -3630,6 +3648,9 @@ digits Malayalam Malaiàlam Roman Romà Roman Baseline Línia de base romana Roman Hanging Punctuation Puntuació penjant romana + Korean Line Break Anywhere Salt de línia coreà a qualsevol lloc + East Asian Composition Settings Configuració de composició de l’Àsia oriental + Korean Composition Settings Configuració de composició coreana Rotate Gira Rotate 90° CW Gira 90° en sentit horari Rotate Tool Eina de gir @@ -4694,6 +4715,8 @@ digits Malayalam Malaiàlam Type on a Path Text sobre un traçat Type on a Path Options Opcions de text sobre un traçat Type on a Path Options… Opcions de text sobre un traçat… + Center on Top Centra a dalt + Center on Bottom Centra a baix Type on a Path Tool Eina de text sobre un traçat Type Optimized Optimitzat per al text Type Orientation Orientació del text @@ -5651,6 +5674,7 @@ digits Malayalam Malaiàlam @msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`) @msg not an EMF or WMF file no és un fitxer EMF o WMF @msg not an HP-GL file no és un fitxer HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb @msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf @msg not found no s’ha trobat @msg not in pattern editing mode no esteu en mode d’edició de motius @@ -6667,6 +6691,10 @@ breakText Glyphs Glifs Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Hi ha traços que fan servir un o més d’aquests pinzells. Voleu treure els pinzells d’aquests traços i suprimir els pinzells? Remove Strokes Elimina els traços Place Brush Art Col·loca l’art del pinzell + Replace Brush Art Substitueix l’art del pinzell + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Hi ha traços que fan servir aquest pinzell. Voleu aplicar l’art nou a aquests traços, o deixar-los com estan i afegir una còpia del pinzell amb l’art nou? + Apply to Strokes Aplica als traços + Leave Strokes Deixa els traços @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no té art (només en tenen els pinzells de dispersió, artístics i de motiu) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Eines d’organització @@ -6920,8 +6948,33 @@ breakText Glyphs Glifs @plural {n} line cut|{n} lines cut {n} línia tallada|{n} línies tallades @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` han de ser números de línies que la consola conserva, `first` abans de `last` +# Learn panel + Learn Aprèn + Explore tutorials Explora els tutorials + For you Per a vós + Skills Habilitats + Get started Primers passos + Quick skill guide Guia ràpida d’habilitats + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutorials pràctics breus de les eines essencials, agrupats per habilitat. Trieu una habilitat per veure’n els tutorials. + List Tutorials Llista els tutorials + Show Tutorial Mostra el tutorial +@msg missing `tutorial` (see learn.list) falta `tutorial` (vegeu learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial desconegut `{id}` (vegeu learn.list) + # Deferred plug-in effects Refresh Deferred Effects Actualitza els efectes ajornats Deferred Effects Efectes ajornats @msg no plug-in effect is deferred no hi ha cap efecte de complement ajornat @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. S’ha obert amb {n} efecte de complement ajornat. Efecte › Actualitza els efectes ajornats l’executa.|S’ha obert amb {n} efectes de complement ajornats. Efecte › Actualitza els efectes ajornats els executa. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Eines de tall + Stencil Bridges… Ponts de plantilla… + Stencil Bridges Ponts de plantilla + Shortest Crossing Travessia més curta + Direction Direcció +@msg The selected art has no islands to bridge La il·lustració seleccionada no té illes per unir +@msg No bridge of this width fits across the selected art Cap pont d’aquesta amplada no cap a la il·lustració seleccionada +@msg Some islands could not be joined to the area around the art Algunes illes no s’han pogut unir a l’àrea que envolta la il·lustració +@msg `width` must be from {lo} to {hi} pt `width` ha d’estar entre {lo} i {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` ha de ser un nombre enter d’1 a {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5d37ee136e..4f751b0a80 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -405,6 +405,8 @@ Stair Step Schody Gravity Gravitace Type on a Path Options… Možnosti textu na cestě… + Center on Top Vystředit nahoře + Center on Bottom Vystředit dole Threaded Text Zřetězený text Create Vytvořit Release Selection Uvolnit výběr @@ -425,6 +427,10 @@ Arch Klenba @msg select point type or type on a path vyberte bodový text nebo text na cestě @msg type on a path in a thread cannot use Dynamic Text: {frames} text na cestě ve zřetězeném textu nemůže používat dynamický text: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} text na otevřené nebo složené cestě nelze vystředit nahoře ani dole: {open} +@msg type on a path with no length or no height cannot be centered: {flat} text na cestě bez délky nebo bez výšky nelze vystředit: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text na cestě ve zřetězeném textu nelze vystředit nahoře ani dole: {frames} +@msg select type on a closed path vyberte text na uzavřené cestě Fill with Placeholder Text Vyplnit zástupným textem Insert Inline Symbol Vložit symbol do textu Show Hidden Characters Zobrazit skryté znaky @@ -496,6 +502,8 @@ Extrude & Bevel… Vysunutí a zkosení… 3D (Classic) 3D (klasické) Extrude & Bevel (Classic)… Vysunutí a zkosení (klasické)… + Revolve (Classic)… Rotační těleso (klasické)… + Rotate (Classic)… Otočit (klasické)… Surface Povrch Wireframe Drátový model No Shading Bez stínování @@ -849,6 +857,7 @@ Sky Gradients Přechody oblohy Neutral Gradients Neutrální přechody Organic Fades Organické přechody + Holographic Holografické Duotone Gradients Dvoutónové přechody Radial Glows Kruhové záře Save Swatch Library… Uložit knihovnu vzorníku… @@ -2014,6 +2023,9 @@ digits Malayalam Malajálamské Right Indent Odsazení zprava Right-to-Left Paragraph Direction Směr odstavce zprava doleva Roman Hanging Punctuation Visící latinková interpunkce + Korean Line Break Anywhere Zalomení korejského řádku kdekoli + East Asian Composition Settings Nastavení východoasijské sazby + Korean Composition Settings Nastavení korejské sazby Select a text object to edit its paragraph attributes. Vyberte textový objekt, abyste mohli upravit atributy jeho odstavce. Single-line Composer Sazba jednoho řádku Toggle Line Composer Přepnout sazbu řádků @@ -4884,6 +4896,7 @@ imageTrace Outline Obrys @msg not a valid plug-in module: {0} neplatný modul zásuvného modulu: {0} @msg not an EMF or WMF file nejde o soubor EMF ani WMF @msg not an HP-GL file nejde o soubor HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musí být pole 1 až 255 barev #rrggbb @msg not found nenalezeno @msg not in pattern editing mode není aktivní režim úprav vzorku @msg finish editing the pattern first nejprve dokončete úpravy vzorku @@ -6311,6 +6324,17 @@ axis Y Axis osa Y Charge Wave Nabíjecí vlna Spark Trail Jiskrová stopa Calligraphic Nibs Kaligrafická pera + Bristle Brushes Štětinové štětce + Fine Liner Jemná linka + Pointed Flat Špičatý plochý + Stubby Round Krátký kulatý + Dry Scrub Suché drhnutí + Soft Dome Měkká kopule + Filbert Plochý oválný + Angled Round Šikmý kulatý + Angled Flat Šikmý plochý + Fan Blender Vějíř na prolínání + Grass Fan Vějíř na trávu Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary Save Brush Library… Uložit knihovnu štětců… @@ -6686,6 +6710,10 @@ breakText Glyphs Glyfy Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Tahy používají jeden nebo více z těchto štětců. Odebrat štětce z těchto tahů a odstranit štětce? Remove Strokes Odebrat tahy Place Brush Art Umístit kresbu štětce + Replace Brush Art Nahradit kresbu štětce + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Tahy používají tento štětec. Použít novou kresbu na tyto tahy, nebo je ponechat beze změny a přidat kopii štětce s novou kresbou? + Apply to Strokes Použít na tahy + Leave Strokes Ponechat tahy @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nemá kresbu (mají ji jen rozptylové, obrazové a vzorkové štětce) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Nástroje uspořádání @@ -6939,8 +6967,33 @@ breakText Glyphs Glyfy @plural {n} line cut|{n} lines cut {n} řádek zkrácen|{n} řádky zkráceny|{n} řádků zkráceno @msg `first` and `last` must be numbers of lines the console holds, first before last `first` a `last` musí být čísla řádků, které konzole uchovává, `first` před `last` +# Learn panel + Learn Výuka + Explore tutorials Prozkoumat výukové kurzy + For you Pro vás + Skills Dovednosti + Get started Začínáme + Quick skill guide Rychlý průvodce dovednostmi + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Krátké praktické výukové kurzy k základním nástrojům, seřazené podle dovedností. Vyberte dovednost a zobrazí se její kurzy. + List Tutorials Vypsat výukové kurzy + Show Tutorial Zobrazit výukový kurz +@msg missing `tutorial` (see learn.list) chybí `tutorial` (viz learn.list) +@msg unknown tutorial `{id}` (see learn.list) neznámý výukový kurz `{id}` (viz learn.list) + # Deferred plug-in effects Refresh Deferred Effects Obnovit odložené efekty Deferred Effects Odložené efekty @msg no plug-in effect is deferred žádný efekt zásuvného modulu není odložen @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otevřeno s {n} odloženým efektem zásuvného modulu. Efekt › Obnovit odložené efekty jej spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí.|Otevřeno s {n} odloženými efekty zásuvných modulů. Efekt › Obnovit odložené efekty je spustí. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Nástroje pro řezání + Stencil Bridges… Můstky šablony… + Stencil Bridges Můstky šablony + Shortest Crossing Nejkratší přechod + Direction Směr +@msg The selected art has no islands to bridge Vybraná kresba nemá žádné ostrůvky k přemostění +@msg No bridge of this width fits across the selected art Přes vybranou kresbu se nevejde žádný můstek této šířky +@msg Some islands could not be joined to the area around the art Některé ostrůvky nebylo možné připojit k oblasti kolem kresby +@msg `width` must be from {lo} to {hi} pt `width` musí být od {lo} do {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musí být celé číslo od 1 do {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8167f80138..b5beaf4805 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -366,6 +366,8 @@ Stair Step Trappetrin Gravity Tyngdekraft Type on a Path Options… Indstillinger for tekst på kurve… + Center on Top Centrer øverst + Center on Bottom Centrer nederst Threaded Text Sammenkædet tekst Create Opret Release Selection Frigør markering @@ -454,6 +456,8 @@ Extrude & Bevel… Ekstrudér og facet… 3D (Classic) 3D (klassisk) Extrude & Bevel (Classic)… Ekstrudér og facet (klassisk)… + Revolve (Classic)… Omdrej (klassisk)… + Rotate (Classic)… Rotér (klassisk)… Surface Overflade Wireframe Trådmodel No Shading Ingen skyggelægning @@ -786,6 +790,7 @@ Sky Gradients Himmelgradienter Neutral Gradients Neutrale gradienter Organic Fades Organiske toninger + Holographic Holografiske Duotone Gradients Duotonegradienter Radial Glows Radiale gløder Save Swatch Library… Gem bibliotek med farveprøver… @@ -1820,6 +1825,9 @@ digits Malayalam Malayalam Right Indent Højre indrykning Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre Roman Hanging Punctuation Romersk hængende tegnsætning + Korean Line Break Anywhere Koreansk linjeskift hvor som helst + East Asian Composition Settings Indstillinger for østasiatisk komposition + Korean Composition Settings Indstillinger for koreansk komposition Select a text object to edit its paragraph attributes. Markér et tekstobjekt for at redigere dets afsnitsattributter. Single-line Composer Enlinjesætter Toggle Line Composer Skift linjesætter @@ -4508,6 +4516,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} ikke et gyldigt plug-in-modul: {0} @msg not an EMF or WMF file ikke en EMF- eller WMF-fil @msg not an HP-GL file ikke en HP-GL-fil +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` skal være et array af 1 til 255 #rrggbb-farver @msg not found ikke fundet @msg not in pattern editing mode ikke i mønsterredigeringstilstand @msg finish editing the pattern first afslut først redigeringen af mønsteret @@ -6067,6 +6076,17 @@ axis Y Axis Y-akse Charge Wave Opladningsbølge Spark Trail Gnistsvær Calligraphic Nibs Kalligrafiske penne + Bristle Brushes Børstepensler + Fine Liner Fin streg + Pointed Flat Spids flad + Stubby Round Kort rund + Dry Scrub Tør skrubning + Soft Dome Blød kuppel + Filbert Oval flad + Angled Round Skrå rund + Angled Flat Skrå flad + Fan Blender Blendevifte + Grass Fan Græsvifte Pattern Borders Mønsterborter Scatter Shapes Spredte former Save Brush Library… Gem penselbibliotek… @@ -6411,6 +6431,10 @@ breakText Glyphs Glyffer Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Streger bruger en eller flere af disse pensler. Fjern penslerne fra stregerne, og slet penslerne? Remove Strokes Fjern streger Place Brush Art Placér penselgrafik + Replace Brush Art Erstat penselgrafik + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Streger bruger denne pensel. Anvend den nye grafik på stregerne, eller lad dem være, som de er, og tilføj en kopi af penslen med den nye grafik? + Apply to Strokes Anvend på streger + Leave Strokes Bevar streger @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (det har kun sprednings-, grafik- og mønsterpensler) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Arrangeringsværktøjer @@ -6646,6 +6670,26 @@ breakText Glyphs Glyffer @plural {n} earlier line dropped|{n} earlier lines dropped {n} tidligere linje fjernet|{n} tidligere linjer fjernet @plural {n} line cut|{n} lines cut {n} linje afkortet|{n} linjer afkortet +# Learn panel + Learn Lær + Explore tutorials Udforsk vejledninger + For you Til dig + Skills Færdigheder + Get started Kom i gang + Quick skill guide Hurtig færdighedsguide + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korte praktiske vejledninger til de vigtigste værktøjer, ordnet efter færdighed. Vælg en færdighed for at se dens vejledninger. + List Tutorials Vis vejledninger + Show Tutorial Vis vejledning +@msg missing `tutorial` (see learn.list) `tutorial` mangler (se learn.list) +@msg unknown tutorial `{id}` (see learn.list) ukendt vejledning `{id}` (se learn.list) + # Deferred plug-in effects Refresh Deferred Effects Opdatér udskudte effekter Deferred Effects Udskudte effekter + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Skæreværktøjer + Stencil Bridges… Stencilbroer… + Stencil Bridges Stencilbroer + Shortest Crossing Korteste krydsning + Direction Retning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bd4bb38b43..52455507a5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1132,6 +1132,8 @@ font-technology Variable Variabel Extrude & Bevel… Extrudieren und abgeflachte Kante… 3D (Classic) 3D (klassisch) Extrude & Bevel (Classic)… Extrudieren und abgeflachte Kante (klassisch)… + Revolve (Classic)… Kreiseln (klassisch)… + Rotate (Classic)… Drehen (klassisch)… Surface Oberfläche Wireframe Drahtmodell No Shading Keine Schattierung @@ -2320,6 +2322,10 @@ file-filter Templates Vorlagen Arch Rundbogen @msg select point type or type on a path wählen Sie Punkttext oder Pfadtext aus @msg type on a path in a thread cannot use Dynamic Text: {frames} Pfadtext in verkettetem Text kann keinen dynamischen Text verwenden: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} Pfadtext auf einem offenen oder zusammengesetzten Pfad lässt sich nicht oben oder unten zentrieren: {open} +@msg type on a path with no length or no height cannot be centered: {flat} Pfadtext auf einem Pfad ohne Länge oder ohne Höhe lässt sich nicht zentrieren: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} Pfadtext in verkettetem Text lässt sich nicht oben oder unten zentrieren: {frames} +@msg select type on a closed path wählen Sie Pfadtext auf einem geschlossenen Pfad aus Optimize for fast web view Für schnelle Webanzeige optimieren Optimized Optimiert Options Optionen @@ -2327,6 +2333,7 @@ file-filter Templates Vorlagen Options: Optionen: Options… Optionen… Organic Fades Organische Verläufe + Holographic Holografisch or more spaces with a tab oder mehr Leerzeichen durch einen Tabulator Order: Reihenfolge: Ordinals Ordinalzahlen @@ -2881,6 +2888,9 @@ digits Malayalam Malayalam Rise… Ansteigend… Roman Baseline Lateinische Grundlinie Roman Hanging Punctuation Hängende Interpunktion (lateinisch) + Korean Line Break Anywhere Koreanischer Zeilenumbruch überall + East Asian Composition Settings Einstellungen für ostasiatischen Satz + Korean Composition Settings Einstellungen für koreanischen Satz Rotate Drehen Rotate 90° CW Um 90° im Uhrzeigersinn drehen Rotate Tool Drehen-Werkzeug @@ -3671,6 +3681,8 @@ effect Points Punkte Type on a Path Pfadtext Type on a Path Options Pfadtextoptionen Type on a Path Options… Pfadtextoptionen… + Center on Top Oben zentrieren + Center on Bottom Unten zentrieren Type on a Path Tool Pfadtext-Werkzeug Type Orientation Textrichtung Type stays text, and every stroke is a line with its lineweight and dashes. Text bleibt Text, und jede Kontur ist eine Linie mit ihrer Linienstärke und ihren Strichen. @@ -4501,6 +4513,7 @@ theme Appearance Erscheinungsbild @msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0} @msg not an EMF or WMF file keine EMF- oder WMF-Datei @msg not an HP-GL file keine HP-GL-Datei +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` muss ein Array von 1 bis 255 Farben der Form #rrggbb sein @msg not found nicht gefunden @msg not in pattern editing mode nicht im Musterbearbeitungsmodus @msg finish editing the pattern first beenden Sie zuerst die Bearbeitung des Musters @@ -5902,6 +5915,17 @@ axis Y Axis Y-Achse Charge Wave Ladewelle Spark Trail Funkenspur Calligraphic Nibs Kalligrafie-Federn + Bristle Brushes Borstenpinsel-Set + Fine Liner Feiner Liner + Pointed Flat Spitzer Flachpinsel + Stubby Round Kurzer Rundpinsel + Dry Scrub Trockenes Schrubben + Soft Dome Weiche Kuppel + Filbert Katzenzunge + Angled Round Schräger Rundpinsel + Angled Flat Schräger Flachpinsel + Fan Blender Fächer zum Verblenden + Grass Fan Grasfächer Pattern Borders Musterbordüren Scatter Shapes Streuformen Save Brush Library… Pinsel-Bibliothek speichern… @@ -6281,6 +6305,10 @@ breakText Glyphs Glyphen Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturen verwenden einen oder mehrere dieser Pinsel. Pinsel von diesen Konturen entfernen und die Pinsel löschen? Remove Strokes Konturen entfernen Place Brush Art Pinselgrafik platzieren + Replace Brush Art Pinselgrafik ersetzen + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Konturen verwenden diesen Pinsel. Die neue Grafik auf diese Konturen anwenden oder sie unverändert lassen und eine Kopie des Pinsels mit der neuen Grafik hinzufügen? + Apply to Strokes Auf Konturen anwenden + Leave Strokes Konturen beibehalten @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` hat keine Grafik (nur Spezial-, Bild- und Musterpinsel haben eine) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Anordnungswerkzeuge @@ -6534,8 +6562,33 @@ breakText Glyphs Glyphen @plural {n} line cut|{n} lines cut {n} Zeile gekürzt|{n} Zeilen gekürzt @msg `first` and `last` must be numbers of lines the console holds, first before last `first` und `last` müssen Nummern von Zeilen sein, die die Konsole enthält, `first` vor `last` +# Learn panel + Learn Lernen + Explore tutorials Lernprogramme entdecken + For you Für Sie + Skills Fertigkeiten + Get started Erste Schritte + Quick skill guide Kurzanleitung zu Fertigkeiten + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Kurze praktische Lernprogramme zu den wichtigsten Werkzeugen, nach Fertigkeiten geordnet. Wählen Sie eine Fertigkeit, um ihre Lernprogramme zu sehen. + List Tutorials Lernprogramme auflisten + Show Tutorial Lernprogramm anzeigen +@msg missing `tutorial` (see learn.list) `tutorial` fehlt (siehe learn.list) +@msg unknown tutorial `{id}` (see learn.list) unbekanntes Lernprogramm `{id}` (siehe learn.list) + # Deferred plug-in effects Refresh Deferred Effects Zurückgestellte Effekte aktualisieren Deferred Effects Zurückgestellte Effekte @msg no plug-in effect is deferred kein Plug-in-Effekt ist zurückgestellt @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Mit {n} zurückgestelltem Plug-in-Effekt geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt ihn aus.|Mit {n} zurückgestellten Plug-in-Effekten geöffnet. Effekt › Zurückgestellte Effekte aktualisieren führt sie aus. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Schneidwerkzeuge + Stencil Bridges… Schablonenstege… + Stencil Bridges Schablonenstege + Shortest Crossing Kürzeste Querung + Direction Richtung +@msg The selected art has no islands to bridge Das ausgewählte Bildmaterial hat keine Inseln zum Verbinden +@msg No bridge of this width fits across the selected art Kein Steg dieser Breite passt über das ausgewählte Bildmaterial +@msg Some islands could not be joined to the area around the art Einige Inseln konnten nicht mit der Fläche um das Bildmaterial verbunden werden +@msg `width` must be from {lo} to {hi} pt `width` muss zwischen {lo} und {hi} pt liegen +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` muss eine ganze Zahl von 1 bis {MAX_BRIDGES_PER_ISLAND} sein diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9860f8ad50..dfb942176b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1097,6 +1097,8 @@ font-technology Variable Μεταβλητές Extrude & Bevel… Εξώθηση και λοξότμηση… 3D (Classic) 3D (κλασικό) Extrude & Bevel (Classic)… Εξώθηση και λοξότμηση (κλασικό)… + Revolve (Classic)… Περιστροφή γύρω από άξονα (κλασικό)… + Rotate (Classic)… Περιστροφή (κλασικό)… Surface Επιφάνεια Wireframe Συρματόπλεγμα No Shading Χωρίς σκίαση @@ -1850,6 +1852,7 @@ font-technology Variable Μεταβλητές Options… Επιλογές… Ordinals Τακτικοί αριθμοί Organic Fades Οργανικές διαβαθμίσεις + Holographic Ολογραφικά Orientation Προσανατολισμός Orientation: Προσανατολισμός: Original Πρωτότυπο @@ -1961,6 +1964,9 @@ font-technology Variable Μεταβλητές Right: Δεξιά: Rise… Ανύψωση… Roman Hanging Punctuation Κρεμαστή στίξη όρθιας γραφής + Korean Line Break Anywhere Αλλαγή γραμμής κορεατικών οπουδήποτε + East Asian Composition Settings Ρυθμίσεις σύνθεσης ανατολικής Ασίας + Korean Composition Settings Ρυθμίσεις κορεατικής σύνθεσης Rotate Περιστροφή Rotate View Tool Εργαλείο περιστροφής προβολής (Rotate View Tool) Trackpad Gesture to Rotate View Περιστροφή προβολής με χειρονομία στην επιφάνεια αφής @@ -3089,6 +3095,10 @@ theme Appearance Εμφάνιση Arch Αψίδα @msg select point type or type on a path επιλέξτε κείμενο σημείου ή κείμενο σε διαδρομή @msg type on a path in a thread cannot use Dynamic Text: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να χρησιμοποιήσει δυναμικό κείμενο: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} κείμενο σε ανοιχτή ή σύνθετη διαδρομή δεν μπορεί να κεντραριστεί επάνω ή κάτω: {open} +@msg type on a path with no length or no height cannot be centered: {flat} κείμενο σε διαδρομή χωρίς μήκος ή χωρίς ύψος δεν μπορεί να κεντραριστεί: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να κεντραριστεί επάνω ή κάτω: {frames} +@msg select type on a closed path επιλέξτε κείμενο σε κλειστή διαδρομή Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό Options for Selection… Επιλογές για την επιλογή… or more spaces with a tab ή περισσότερα διαστήματα με στηλοθέτη @@ -4061,6 +4071,8 @@ digits Malayalam Μαλαγιάλαμ Type on a Path Κείμενο σε διαδρομή Type on a Path Options Επιλογές κειμένου σε διαδρομή Type on a Path Options… Επιλογές κειμένου σε διαδρομή… + Center on Top Κεντράρισμα επάνω + Center on Bottom Κεντράρισμα κάτω Type on a Path Tool Κείμενο σε διαδρομή (Type on a Path Tool) Type Orientation Προσανατολισμός κειμένου Type stays text, and every stroke is a line with its lineweight and dashes. Το κείμενο διατηρείται ως κείμενο και κάθε περίγραμμα γίνεται γραμμή με το πάχος και τις παύλες του. @@ -4531,6 +4543,7 @@ digits Malayalam Μαλαγιάλαμ @msg not a valid plug-in module: {0} δεν είναι έγκυρη μονάδα προσθέτου: {0} @msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF @msg not an HP-GL file δεν είναι αρχείο HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors το `pens` πρέπει να είναι πίνακας 1 έως 255 χρωμάτων #rrggbb @msg not found δεν βρέθηκε @msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου @msg finish editing the pattern first ολοκληρώστε πρώτα την επεξεργασία του μοτίβου @@ -6262,6 +6275,17 @@ axis Y Axis Άξονας Y Charge Wave Κύμα φόρτισης Spark Trail Ίχνος σπινθήρων Calligraphic Nibs Καλλιγραφικές πένες + Bristle Brushes Πινέλα με τρίχες + Fine Liner Λεπτή γραμμή + Pointed Flat Μυτερό πλακέ + Stubby Round Κοντό στρογγυλό + Dry Scrub Ξηρό τρίψιμο + Soft Dome Μαλακός θόλος + Filbert Γλώσσα γάτας + Angled Round Λοξό στρογγυλό + Angled Flat Λοξό πλακέ + Fan Blender Βεντάλια ανάμειξης + Grass Fan Βεντάλια για γρασίδι Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… @@ -6638,6 +6662,10 @@ breakText Glyphs Γλύφοι Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Περιγράμματα χρησιμοποιούν ένα ή περισσότερα από αυτά τα πινέλα. Να αφαιρεθούν τα πινέλα από αυτά τα περιγράμματα και να διαγραφούν τα πινέλα; Remove Strokes Αφαίρεση περιγραμμάτων Place Brush Art Τοποθέτηση σχεδίου πινέλου + Replace Brush Art Αντικατάσταση σχεδίου πινέλου + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Περιγράμματα χρησιμοποιούν αυτό το πινέλο. Να εφαρμοστεί το νέο σχέδιο σε αυτά τα περιγράμματα ή να μείνουν ως έχουν και να προστεθεί αντίγραφο του πινέλου με το νέο σχέδιο; + Apply to Strokes Εφαρμογή στα περιγράμματα + Leave Strokes Διατήρηση περιγραμμάτων @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` δεν έχει σχέδιο (έχουν μόνο τα πινέλα διασποράς, σχεδίου και μοτίβου) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Εργαλεία διάταξης @@ -6891,8 +6919,33 @@ breakText Glyphs Γλύφοι @plural {n} line cut|{n} lines cut {n} γραμμή περικόπηκε|{n} γραμμές περικόπηκαν @msg `first` and `last` must be numbers of lines the console holds, first before last Τα `first` και `last` πρέπει να είναι αριθμοί γραμμών που κρατά η κονσόλα, το `first` πριν από το `last` +# Learn panel + Learn Εκμάθηση + Explore tutorials Εξερεύνηση οδηγών εκμάθησης + For you Για εσάς + Skills Δεξιότητες + Get started Πρώτα βήματα + Quick skill guide Γρήγορος οδηγός δεξιοτήτων + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Σύντομοι πρακτικοί οδηγοί για τα βασικά εργαλεία, ομαδοποιημένοι ανά δεξιότητα. Επιλέξτε μια δεξιότητα για να δείτε τους οδηγούς της. + List Tutorials Λίστα οδηγών εκμάθησης + Show Tutorial Εμφάνιση οδηγού εκμάθησης +@msg missing `tutorial` (see learn.list) λείπει το `tutorial` (δείτε learn.list) +@msg unknown tutorial `{id}` (see learn.list) άγνωστος οδηγός εκμάθησης `{id}` (δείτε learn.list) + # Deferred plug-in effects Refresh Deferred Effects Ανανέωση εφέ σε αναμονή Deferred Effects Εφέ σε αναμονή @msg no plug-in effect is deferred κανένα εφέ προσθέτου δεν είναι σε αναμονή @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Άνοιξε με {n} εφέ προσθέτου σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή το εκτελεί.|Άνοιξε με {n} εφέ προσθέτων σε αναμονή. Το Εφέ › Ανανέωση εφέ σε αναμονή τα εκτελεί. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Εργαλεία κοπής + Stencil Bridges… Γέφυρες στένσιλ… + Stencil Bridges Γέφυρες στένσιλ + Shortest Crossing Συντομότερη διέλευση + Direction Κατεύθυνση +@msg The selected art has no islands to bridge Το επιλεγμένο γραφικό δεν έχει νησίδες για γεφύρωση +@msg No bridge of this width fits across the selected art Καμία γέφυρα αυτού του πλάτους δεν χωράει στο επιλεγμένο γραφικό +@msg Some islands could not be joined to the area around the art Ορισμένες νησίδες δεν ήταν δυνατό να συνδεθούν με την περιοχή γύρω από το γραφικό +@msg `width` must be from {lo} to {hi} pt Το `width` πρέπει να είναι από {lo} έως {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} Το `count` πρέπει να είναι ακέραιος από 1 έως {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 10c0c0bf87..7ee7980085 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1153,6 +1153,8 @@ font-technology Variable Variables Extrude & Bevel… Extrusión y biselado… 3D (Classic) 3D (clásico) Extrude & Bevel (Classic)… Extrusión y biselado (clásico)… + Revolve (Classic)… Revolución (clásico)… + Rotate (Classic)… Rotar (clásico)… Surface Superficie Wireframe Malla No Shading Sin sombreado @@ -2358,6 +2360,10 @@ font-technology Variable Variables Arch Bóveda @msg select point type or type on a path seleccione texto de punto o texto en trazado @msg type on a path in a thread cannot use Dynamic Text: {frames} el texto en trazado de un texto enlazado no puede usar texto dinámico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} el texto en un trazado abierto o compuesto no se puede centrar arriba o abajo: {open} +@msg type on a path with no length or no height cannot be centered: {flat} el texto en un trazado sin longitud o sin altura no se puede centrar: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el texto en trazado de un texto enlazado no se puede centrar arriba o abajo: {frames} +@msg select type on a closed path seleccione texto en un trazado cerrado Optimize for fast web view Optimizar para vista web rápida Optimized Optimizado Options Opciones @@ -2365,6 +2371,7 @@ font-technology Variable Variables Options: Opciones: Options… Opciones… Organic Fades Desvanecidos orgánicos + Holographic Holográficos or more spaces with a tab o más espacios por una tabulación Order: Orden: Ordinals Ordinales @@ -2914,6 +2921,9 @@ digits Malayalam Malayálam Rise… Ascenso… Roman Baseline Línea de base latina Roman Hanging Punctuation Puntuación colgante romana + Korean Line Break Anywhere Salto de línea coreano en cualquier lugar + East Asian Composition Settings Ajustes de composición de Asia oriental + Korean Composition Settings Ajustes de composición coreana Rotate Rotar Rotate 90° CW Rotar 90° en sentido horario Rotate Tool Herramienta Rotar @@ -3717,6 +3727,8 @@ digits Malayalam Malayálam Type on a Path Texto en trazado Type on a Path Options Opciones de texto en trazado Type on a Path Options… Opciones de texto en trazado… + Center on Top Centrar arriba + Center on Bottom Centrar abajo Type on a Path Tool Herramienta Texto en trazado Type Orientation Orientación del texto Type stays text, and every stroke is a line with its lineweight and dashes. El texto sigue siendo texto, y cada trazo es una línea con su grosor y guiones. @@ -4576,6 +4588,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0} @msg not an EMF or WMF file no es un archivo EMF ni WMF @msg not an HP-GL file no es un archivo HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` debe ser una lista de 1 a 255 colores #rrggbb @msg not found no se encontró @msg not in pattern editing mode no está en el modo de edición de motivos @msg finish editing the pattern first termina primero la edición del motivo @@ -6253,6 +6266,17 @@ axis Y Axis Eje Y Charge Wave Onda de carga Spark Trail Estela de chispas Calligraphic Nibs Plumillas caligráficas + Bristle Brushes Pinceles de cerdas + Fine Liner Delineador fino + Pointed Flat Plano puntiagudo + Stubby Round Redondo corto + Dry Scrub Frotado seco + Soft Dome Cúpula suave + Filbert Lengua de gato + Angled Round Redondo biselado + Angled Flat Plano biselado + Fan Blender Abanico difuminador + Grass Fan Abanico de hierba Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión Save Brush Library… Guardar biblioteca de pinceles… @@ -6629,6 +6653,10 @@ breakText Glyphs Glifos Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Algunos trazos usan uno o más de estos pinceles. ¿Quitar los pinceles de esos trazos y eliminar los pinceles? Remove Strokes Quitar trazos Place Brush Art Colocar ilustración del pincel + Replace Brush Art Reemplazar ilustración del pincel + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Algunos trazos usan este pincel. ¿Aplicar la nueva ilustración a esos trazos, o dejarlos como están y añadir una copia del pincel con la nueva ilustración? + Apply to Strokes Aplicar a los trazos + Leave Strokes Dejar los trazos @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no tiene ilustración (solo la tienen los pinceles de dispersión, de arte y de motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Herramientas de organización @@ -6882,8 +6910,33 @@ breakText Glyphs Glifos @plural {n} line cut|{n} lines cut {n} línea recortada|{n} líneas recortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` y `last` deben ser números de líneas que la consola conserva, `first` antes que `last` +# Learn panel + Learn Aprender + Explore tutorials Explorar tutoriales + For you Para ti + Skills Habilidades + Get started Primeros pasos + Quick skill guide Guía rápida de habilidades + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutoriales prácticos breves sobre las herramientas esenciales, agrupados por habilidad. Elige una habilidad para ver sus tutoriales. + List Tutorials Enumerar tutoriales + Show Tutorial Mostrar tutorial +@msg missing `tutorial` (see learn.list) falta `tutorial` (consulta learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial desconocido `{id}` (consulta learn.list) + # Deferred plug-in effects Refresh Deferred Effects Actualizar efectos aplazados Deferred Effects Efectos aplazados @msg no plug-in effect is deferred no hay ningún efecto de plugin aplazado @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Se abrió con {n} efecto de plugin aplazado. Efecto › Actualizar efectos aplazados lo ejecuta.|Se abrió con {n} efectos de plugin aplazados. Efecto › Actualizar efectos aplazados los ejecuta. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Herramientas de corte + Stencil Bridges… Puentes de plantilla… + Stencil Bridges Puentes de plantilla + Shortest Crossing Cruce más corto + Direction Dirección +@msg The selected art has no islands to bridge La ilustración seleccionada no tiene islas que unir +@msg No bridge of this width fits across the selected art Ningún puente de este ancho cabe en la ilustración seleccionada +@msg Some islands could not be joined to the area around the art Algunas islas no se pudieron unir al área que rodea la ilustración +@msg `width` must be from {lo} to {hi} pt `width` debe estar entre {lo} y {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` debe ser un número entero entre 1 y {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7845235f65..faa24de7e1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -402,6 +402,8 @@ Stair Step Porras Gravity Painovoima Type on a Path Options… Teksti polulla -asetukset… + Center on Top Keskitä ylös + Center on Bottom Keskitä alas Threaded Text Ketjutettu teksti Create Luo Release Selection Vapauta valinta @@ -422,6 +424,10 @@ Arch Holvi @msg select point type or type on a path valitse pisteteksti tai polkuteksti @msg type on a path in a thread cannot use Dynamic Text: {frames} ketjutetun tekstin polkuteksti ei voi käyttää dynaamista tekstiä: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} avoimella polulla tai yhdistelmäpolulla olevaa tekstiä ei voi keskittää ylös tai alas: {open} +@msg type on a path with no length or no height cannot be centered: {flat} polkutekstiä ei voi keskittää polulle, jolla ei ole pituutta tai korkeutta: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} ketjutetun tekstin polkutekstiä ei voi keskittää ylös tai alas: {frames} +@msg select type on a closed path valitse suljetulla polulla oleva teksti Fill with Placeholder Text Täytä paikkamerkkitekstillä Insert Inline Symbol Lisää tekstinsisäinen symboli Show Hidden Characters Näytä piilotetut merkit @@ -493,6 +499,8 @@ Extrude & Bevel… Pursota ja viistä… 3D (Classic) 3D (perinteinen) Extrude & Bevel (Classic)… Pursota ja viistä (perinteinen)… + Revolve (Classic)… Pyöräytä (perinteinen)… + Rotate (Classic)… Kierrä (perinteinen)… Surface Pinta Wireframe Rautalanka No Shading Ei varjostusta @@ -840,6 +848,7 @@ Sky Gradients Taivasliukuvärit Neutral Gradients Neutraalit liukuvärit Organic Fades Orgaaniset häivytykset + Holographic Holografiset Duotone Gradients Kaksisävyliukuvärit Radial Glows Säteittäiset hehkut Save Swatch Library… Tallenna värimallikirjasto… @@ -1975,6 +1984,9 @@ digits Malayalam Malajalam Right Indent Oikea sisennys Right-to-Left Paragraph Direction Kappaleen suunta oikealta vasemmalle Roman Hanging Punctuation Latinalaiset riippuvat välimerkit + Korean Line Break Anywhere Korean rivinvaihto missä tahansa + East Asian Composition Settings Itäaasialaisen ladonnan asetukset + Korean Composition Settings Korean ladonnan asetukset Select a text object to edit its paragraph attributes. Valitse tekstiobjekti, jonka kappalemääritteitä haluat muokata. Single-line Composer Yhden rivin asettelu Toggle Line Composer Vaihda rivien asettelu @@ -4792,6 +4804,7 @@ imageTrace Outline Ääriviiva @msg not a valid plug-in module: {0} ei kelvollinen laajennusmoduuli: {0} @msg not an EMF or WMF file ei EMF- tai WMF-tiedosto @msg not an HP-GL file ei HP-GL-tiedosto +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` on oltava 1–255 #rrggbb-värin taulukko @msg not found ei löytynyt @msg not in pattern editing mode ei kuvion muokkaustilassa @msg finish editing the pattern first lopeta ensin kuvion muokkaus @@ -6341,6 +6354,17 @@ axis Y Axis Y-akseli Charge Wave Latausaalto Spark Trail Kipinävanne Calligraphic Nibs Kalligrafiakärjet + Bristle Brushes Harjassiveltimet + Fine Liner Ohut viiva + Pointed Flat Suippo litteä + Stubby Round Lyhyt pyöreä + Dry Scrub Kuiva hankaus + Soft Dome Pehmeä kupu + Filbert Kissankieli + Angled Round Vino pyöreä + Angled Flat Vino litteä + Fan Blender Sekoitusviuhka + Grass Fan Ruohoviuhka Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot Save Brush Library… Tallenna sivellinkirjasto… @@ -6716,6 +6740,10 @@ breakText Glyphs Merkit Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Viivat käyttävät yhtä tai useampaa näistä siveltimistä. Irrotetaanko siveltimet näistä viivoista ja poistetaanko siveltimet? Remove Strokes Poista viivat Place Brush Art Sijoita siveltimen kuvitus + Replace Brush Art Korvaa siveltimen kuvitus + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Viivat käyttävät tätä sivellintä. Käytetäänkö uutta kuvitusta näissä viivoissa, vai jätetäänkö ne ennalleen ja lisätään siveltimestä kopio, jossa on uusi kuvitus? + Apply to Strokes Käytä viivoihin + Leave Strokes Jätä viivat ennalleen @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ei sisällä kuvitusta (kuvitus on vain sirote-, taide- ja kuviosiveltimissä) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Järjestelytyökalut @@ -6974,8 +7002,33 @@ breakText Glyphs Merkit @plural {n} line cut|{n} lines cut {n} rivi katkaistu|{n} riviä katkaistu @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ja `last` on oltava konsolin säilyttämien rivien numeroita, `first` ennen arvoa `last` +# Learn panel + Learn Opi + Explore tutorials Tutustu opetusohjelmiin + For you Sinulle + Skills Taidot + Get started Aloitus + Quick skill guide Pikaopas taitoihin + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Lyhyitä käytännön opetusohjelmia tärkeimmistä työkaluista taidoittain ryhmiteltyinä. Valitse taito nähdäksesi sen opetusohjelmat. + List Tutorials Luettele opetusohjelmat + Show Tutorial Näytä opetusohjelma +@msg missing `tutorial` (see learn.list) `tutorial` puuttuu (ks. learn.list) +@msg unknown tutorial `{id}` (see learn.list) tuntematon opetusohjelma `{id}` (ks. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Päivitä lykätyt tehosteet Deferred Effects Lykätyt tehosteet @msg no plug-in effect is deferred yhtään laajennustehostetta ei ole lykätty @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Avattiin, ja {n} laajennustehoste lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa sen.|Avattiin, ja {n} laajennustehostetta lykättiin. Tehoste › Päivitä lykätyt tehosteet suorittaa ne. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Leikkaustyökalut + Stencil Bridges… Sapluunan sillat… + Stencil Bridges Sapluunan sillat + Shortest Crossing Lyhin ylitys + Direction Suunta +@msg The selected art has no islands to bridge Valitussa grafiikassa ei ole yhdistettäviä saarekkeita +@msg No bridge of this width fits across the selected art Näin leveä silta ei mahdu valitun grafiikan poikki +@msg Some islands could not be joined to the area around the art Joitakin saarekkeita ei voitu yhdistää grafiikkaa ympäröivään alueeseen +@msg `width` must be from {lo} to {hi} pt `width`-arvon on oltava {lo}–{hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` on oltava kokonaisluku 1–{MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e2895f9c2..0b834ee258 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1148,6 +1148,8 @@ font-technology Variable Variables Extrude & Bevel… Extrusion et biseautage… 3D (Classic) 3D (classique) Extrude & Bevel (Classic)… Extrusion et biseautage (classique)… + Revolve (Classic)… Révolution (classique)… + Rotate (Classic)… Rotation (classique)… Surface Surface Wireframe Fil de fer No Shading Sans ombrage @@ -2350,6 +2352,10 @@ font-technology Variable Variables Arch Voûte @msg select point type or type on a path sélectionnez du texte de point ou du texte curviligne @msg type on a path in a thread cannot use Dynamic Text: {frames} le texte curviligne d’un texte lié ne peut pas utiliser le texte dynamique : {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} un texte curviligne sur un tracé ouvert ou un tracé transparent ne peut pas être centré en haut ou en bas : {open} +@msg type on a path with no length or no height cannot be centered: {flat} un texte curviligne sur un tracé sans longueur ou sans hauteur ne peut pas être centré : {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} le texte curviligne d’un texte lié ne peut pas être centré en haut ou en bas : {frames} +@msg select type on a closed path sélectionnez un texte curviligne sur un tracé fermé Optimize for fast web view Optimiser pour un affichage web rapide Optimized Optimisé Options Options @@ -2357,6 +2363,7 @@ font-technology Variable Variables Options: Options : Options… Options… Organic Fades Fondus organiques + Holographic Holographiques or more spaces with a tab espaces ou plus par une tabulation Order: Ordre : Ordinals Ordinaux @@ -2906,6 +2913,9 @@ digits Malayalam Malayalam Rise… Élévation… Roman Baseline Ligne de base romaine Roman Hanging Punctuation Ponctuation romaine hors justification + Korean Line Break Anywhere Saut de ligne coréen n’importe où + East Asian Composition Settings Paramètres de composition d’Asie orientale + Korean Composition Settings Paramètres de composition coréenne Rotate Rotation Rotate 90° CW Rotation de 90° horaire Rotate Tool Outil Rotation @@ -3708,6 +3718,8 @@ digits Malayalam Malayalam Type on a Path Texte curviligne Type on a Path Options Options de texte curviligne Type on a Path Options… Options de texte curviligne… + Center on Top Centrer en haut + Center on Bottom Centrer en bas Type on a Path Tool Outil Texte curviligne Type Orientation Orientation du texte Type stays text, and every stroke is a line with its lineweight and dashes. Le texte reste du texte, et chaque contour devient une ligne avec son épaisseur et ses tirets. @@ -4565,6 +4577,7 @@ axis Both Les deux @msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0} @msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF @msg not an HP-GL file ce n’est pas un fichier HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` doit être une liste de 1 à 255 couleurs #rrggbb @msg not found introuvable @msg not in pattern editing mode pas en mode d’édition de motif @msg finish editing the pattern first terminez d’abord la modification du motif @@ -6306,6 +6319,17 @@ axis Y Axis Axe Y Charge Wave Vague de charge Spark Trail Traînée d'étincelles Calligraphic Nibs Plumes calligraphiques + Bristle Brushes Pinceaux à soies + Fine Liner Traceur fin + Pointed Flat Plat pointu + Stubby Round Rond court + Dry Scrub Frottis sec + Soft Dome Dôme doux + Filbert Langue de chat + Angled Round Rond biseauté + Angled Flat Plat biseauté + Fan Blender Éventail estompeur + Grass Fan Éventail à herbe Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion Save Brush Library… Enregistrer la bibliothèque de formes… @@ -6681,6 +6705,10 @@ breakText Glyphs Glyphes Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Des contours utilisent une ou plusieurs de ces formes. Retirer les formes de ces contours et supprimer les formes ? Remove Strokes Supprimer les contours Place Brush Art Placer l’illustration de la forme + Replace Brush Art Remplacer l’illustration de la forme + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Des contours utilisent cette forme. Appliquer la nouvelle illustration à ces contours, ou les laisser tels quels et ajouter une copie de la forme avec la nouvelle illustration ? + Apply to Strokes Appliquer aux contours + Leave Strokes Conserver les contours @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` n’a pas d’illustration (seules les formes diffuses, artistiques et de motif en ont) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Outils de disposition @@ -6934,8 +6962,33 @@ breakText Glyphs Glyphes @plural {n} line cut|{n} lines cut {n} ligne coupée|{n} lignes coupées @msg `first` and `last` must be numbers of lines the console holds, first before last `first` et `last` doivent être des numéros de lignes que la console contient, `first` avant `last` +# Learn panel + Learn Apprendre + Explore tutorials Découvrir les tutoriels + For you Pour vous + Skills Compétences + Get started Prise en main + Quick skill guide Guide rapide des compétences + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Courts tutoriels pratiques sur les outils essentiels, regroupés par compétence. Choisissez une compétence pour voir ses tutoriels. + List Tutorials Lister les tutoriels + Show Tutorial Afficher le tutoriel +@msg missing `tutorial` (see learn.list) `tutorial` manquant (voir learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutoriel inconnu `{id}` (voir learn.list) + # Deferred plug-in effects Refresh Deferred Effects Actualiser les effets différés Deferred Effects Effets différés @msg no plug-in effect is deferred aucun effet de plug-in n’est différé @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Ouvert avec {n} effet de plug-in différé. Effet › Actualiser les effets différés l’exécute.|Ouvert avec {n} effets de plug-in différés. Effet › Actualiser les effets différés les exécute. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Outils de découpe + Stencil Bridges… Ponts de pochoir… + Stencil Bridges Ponts de pochoir + Shortest Crossing Traversée la plus courte + Direction Direction +@msg The selected art has no islands to bridge L’illustration sélectionnée n’a aucun îlot à relier +@msg No bridge of this width fits across the selected art Aucun pont de cette largeur ne tient en travers de l’illustration sélectionnée +@msg Some islands could not be joined to the area around the art Certains îlots n’ont pas pu être reliés à la zone autour de l’illustration +@msg `width` must be from {lo} to {hi} pt `width` doit être compris entre {lo} et {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` doit être un nombre entier compris entre 1 et {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index aca21d7eec..8f66a1b354 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -691,6 +691,17 @@ antiAlias Sharp शार्प Calligraphic Brush कैलिग्राफिक ब्रश Calligraphic Brush Options कैलिग्राफिक ब्रश ऑप्शन्स Calligraphic Nibs कैलिग्राफिक निब्स + Bristle Brushes ब्रिसल ब्रश संग्रह + Fine Liner बारीक लाइनर + Pointed Flat नुकीला फ्लैट + Stubby Round छोटा गोल + Dry Scrub सूखी रगड़ + Soft Dome मुलायम गुंबद + Filbert फिल्बर्ट + Angled Round तिरछा गोल + Angled Flat तिरछा फ्लैट + Fan Blender मिश्रण पंखा + Grass Fan घास पंखा Can't export “{name}”. "{name}" निर्यात नहीं कर सकते। Can't open “{name}”. "{name}" नहीं खोल सकते हैं। Can't print these settings: see the Summary इन सेटिंग्स को मुद्रित नहीं कर सकते: सारांश देखें @@ -1745,6 +1756,8 @@ antiAlias Sharp शार्प Extrude & Bevel… एक्सट्रूडेड और बेवेल… 3D (Classic) 3D (क्लासिक) Extrude & Bevel (Classic)… एक्सट्रूडेड और बेवेल (क्लासिक)… + Revolve (Classic)… रिवॉल्व (क्लासिक)… + Rotate (Classic)… घुमाव (क्लासिक)… Surface सतह Wireframe वायरफ़्रेम No Shading कोई शेडिंग नहीं @@ -3176,6 +3189,10 @@ antiAlias Sharp शार्प Arch मेहराब @msg select point type or type on a path पॉइंट टाइप या पाथ पर टाइप चुनें @msg type on a path in a thread cannot use Dynamic Text: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप डायनैमिक टेक्स्ट का उपयोग नहीं कर सकता: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} खुले पाथ या कंपाउंड पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {open} +@msg type on a path with no length or no height cannot be centered: {flat} बिना लंबाई या बिना ऊँचाई वाले पाथ पर टाइप केंद्रित नहीं किया जा सकता: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {frames} +@msg select type on a closed path बंद पाथ पर टाइप चुनें Optimize for fast web view तेजी से वेब दृश्य के लिए ऑप्टिमाइज़ Optimized ऑप्टिमाइज़्ड Options ऑप्शन्स @@ -3183,6 +3200,7 @@ antiAlias Sharp शार्प Options: ऑप्शन्स: Options… ऑप्शन्स… Organic Fades ऑर्गेनिक फ़ेड + Holographic होलोग्राफ़िक or more spaces with a tab या एक टैब के साथ अधिक रिक्त स्थान Orange नारंगी Order: आदेश: @@ -3780,6 +3798,10 @@ antiAlias Sharp शार्प Remove Selected Entry सिलेक्टेड प्रविष्टि निकालें Remove Strokes स्ट्रोक हटाएँ Place Brush Art ब्रश आर्टवर्क रखें + Replace Brush Art ब्रश आर्टवर्क बदलें + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? स्ट्रोक्स इस ब्रश का उपयोग कर रहे हैं। नया आर्टवर्क उन स्ट्रोक्स पर लागू करें, या उन्हें जैसा है वैसा छोड़कर नए आर्टवर्क वाले ब्रश की एक कॉपी जोड़ें? + Apply to Strokes स्ट्रोक्स पर लागू करें + Leave Strokes स्ट्रोक्स जैसे हैं वैसे छोड़ें @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` में कोई आर्टवर्क नहीं है (आर्टवर्क केवल स्कैटर, आर्ट और पैटर्न ब्रश में होता है) Remove the shortcut शॉर्टकट निकालें Remove Threading थ्रेडिंग निकालें @@ -3958,6 +3980,9 @@ antiAlias Sharp शार्प Roman रोमन Roman Baseline रोमन बेसलाइन Roman Hanging Punctuation रोमन हैंगिंग विराम चिह्न + Korean Line Break Anywhere कोरियाई लाइन ब्रेक कहीं भी + East Asian Composition Settings पूर्वी एशियाई संरचना सेटिंग्स + Korean Composition Settings कोरियाई संरचना सेटिंग्स Rotate घुमाएँ Rotate 90° CW 90° सीडब्ल्यू Rotate Tool रोटेट टूल @@ -5085,6 +5110,8 @@ antiAlias Sharp शार्प Type on a Path पाथ पर टाइप Type on a Path Options पाथ पर टाइप ऑप्शन्स Type on a Path Options… पाथ पर टाइप ऑप्शन्स… + Center on Top ऊपर केंद्रित करें + Center on Bottom नीचे केंद्रित करें Type on a Path Tool पाथ पर टाइप टूल Type Optimized टाइप ऑप्टिमाइज़ Type Orientation प्रकार अभिविन्यास @@ -7137,6 +7164,7 @@ antiAlias Sharp शार्प @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an HP-GL file HP-GL फ़ाइल नहीं है +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` को 1 से 255 #rrggbb रंगों की ऐरे होना चाहिए @msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`) @msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e} @msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें) @@ -8372,8 +8400,33 @@ theme Appearance अपीयरेंस @plural {n} line cut|{n} lines cut {n} पंक्ति काटी गई|{n} पंक्तियाँ काटी गईं @msg `first` and `last` must be numbers of lines the console holds, first before last `first` और `last` कंसोल में रखी पंक्तियों की संख्याएँ होनी चाहिए, `first` पहले और `last` बाद में +# Learn panel + Learn सीखें + Explore tutorials ट्यूटोरियल देखें + For you आपके लिए + Skills कौशल + Get started शुरू करें + Quick skill guide त्वरित कौशल गाइड + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. आवश्यक टूल के लिए छोटे व्यावहारिक ट्यूटोरियल, कौशल के अनुसार समूहित। किसी कौशल के ट्यूटोरियल देखने के लिए उसे चुनें। + List Tutorials ट्यूटोरियल की सूची + Show Tutorial ट्यूटोरियल दिखाएँ +@msg missing `tutorial` (see learn.list) लापता `tutorial` (learn.list देखें) +@msg unknown tutorial `{id}` (see learn.list) अज्ञात ट्यूटोरियल `{id}` (learn.list देखें) + # Deferred plug-in effects Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें Deferred Effects स्थगित इफ़ेक्ट्स @msg no plug-in effect is deferred कोई प्लग-इन इफ़ेक्ट स्थगित नहीं है @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} प्लग-इन इफ़ेक्ट स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इसे चलाता है।|{n} प्लग-इन इफ़ेक्ट्स स्थगित रखकर खोला गया। इफ़ेक्ट › स्थगित इफ़ेक्ट्स रिफ्रेश करें इन्हें चलाता है। + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools कटिंग टूल्स + Stencil Bridges… स्टेंसिल ब्रिज… + Stencil Bridges स्टेंसिल ब्रिज + Shortest Crossing सबसे छोटा क्रॉसिंग + Direction दिशा +@msg The selected art has no islands to bridge चुने गए आर्टवर्क में ब्रिज से जोड़ने के लिए कोई आइलैंड नहीं है +@msg No bridge of this width fits across the selected art इस चौड़ाई का कोई ब्रिज चुने गए आर्टवर्क के पार नहीं आता +@msg Some islands could not be joined to the area around the art कुछ आइलैंड आर्टवर्क के आसपास के क्षेत्र से नहीं जोड़े जा सके +@msg `width` must be from {lo} to {hi} pt `width` {lo} से {hi} pt तक होना चाहिए +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 से {MAX_BRIDGES_PER_ISLAND} तक पूरी संख्या होना चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 45d19b0ca9..73b65e1ca5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1444,6 +1444,8 @@ font-technology Variable Változó Extrude & Bevel… Kihúzás és élletörés… 3D (Classic) 3D (klasszikus) Extrude & Bevel (Classic)… Kihúzás és élletörés (klasszikus)… + Revolve (Classic)… Forgatás tengely körül (klasszikus)… + Rotate (Classic)… Elforgatás (klasszikus)… Surface Felület Wireframe Drótváz No Shading Nincs árnyékolás @@ -2852,6 +2854,10 @@ file-filter Templates Sablonok Arch Boltív @msg select point type or type on a path jelöljön ki pontszöveget vagy útvonalszöveget @msg type on a path in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő útvonalszöveg nem használhat dinamikus szöveget: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} nyitott vagy összetett útvonalon lévő útvonalszöveg nem igazítható középre felül vagy alul: {open} +@msg type on a path with no length or no height cannot be centered: {flat} a hossz vagy magasság nélküli útvonalon lévő útvonalszöveg nem igazítható középre: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} a láncolt szövegben lévő útvonalszöveg nem igazítható középre felül vagy alul: {frames} +@msg select type on a closed path jelöljön ki zárt útvonalon lévő útvonalszöveget Optimize for fast web view Optimalizálás gyors webes megtekintéshez Optimized Optimalizált Options Beállítások @@ -3183,6 +3189,7 @@ mark-style Japanese Japán Radial Blur… Körkörös elmosás… Radial Glows Körkörös ragyogások Organic Fades Organikus áttűnések + Holographic Holografikus Radial Gradient Körkörös színátmenet Conic Gradient Kúpos színátmenet Diamond Gradient Rombusz színátmenet @@ -3489,6 +3496,9 @@ mark-style Japanese Japán Roman Latin Roman Baseline Latin alapvonal Roman Hanging Punctuation Latin függő írásjelek + Korean Line Break Anywhere Koreai sortörés bárhol + East Asian Composition Settings Kelet-ázsiai szedési beállítások + Korean Composition Settings Koreai szedési beállítások Rotate Elforgatás Rotate 90° CW Forgatás 90°-kal jobbra Rotate Tool Elforgatás @@ -4434,6 +4444,8 @@ mark-style Japanese Japán Type on a Path Útvonalszöveg Type on a Path Options Útvonalszöveg beállításai Type on a Path Options… Útvonalszöveg beállításai… + Center on Top Középre igazítás felül + Center on Bottom Középre igazítás alul Type on a Path Tool Útvonalszöveg Type Optimized Szövegre optimalizált Type Orientation Szöveg iránya @@ -5261,6 +5273,7 @@ imageTrace Outline Körvonal @msg not a blend nem összemosás @msg not an EMF or WMF file nem EMF- vagy WMF-fájl @msg not an HP-GL file nem HP-GL-fájl +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`: 1–255 #rrggbb szín tömbje kell @msg not found nem található @msg not in pattern editing mode nem mintázatszerkesztő módban vagy @msg finish editing the pattern first előbb fejezd be a mintázat szerkesztését @@ -6294,6 +6307,17 @@ axis Y Axis Y tengely Charge Wave Töltéshullám Spark Trail Szikracsík Calligraphic Nibs Kalligrafikus tollhegyek + Bristle Brushes Sörteecsetek + Fine Liner Finom kontúr + Pointed Flat Hegyes lapos + Stubby Round Rövid kerek + Dry Scrub Száraz dörzsölés + Soft Dome Lágy kupola + Filbert Macskanyelv + Angled Round Ferde kerek + Angled Flat Ferde lapos + Fan Blender Elmosó legyező + Grass Fan Fűlegyező Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok Save Brush Library… Ecsetkönyvtár mentése… @@ -6670,6 +6694,10 @@ breakText Glyphs Glifák Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Körvonalak használnak egyet vagy többet ezek közül az ecsetek közül. Leveszed az ecseteket ezekről a körvonalakról, és törlöd az ecseteket? Remove Strokes Körvonalak eltávolítása Place Brush Art Ecsetgrafika elhelyezése + Replace Brush Art Ecsetgrafika cseréje + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Körvonalak használják ezt az ecsetet. Alkalmazod az új grafikát ezekre a körvonalakra, vagy változatlanul hagyod őket, és hozzáadod az ecset egy másolatát az új grafikával? + Apply to Strokes Alkalmazás a körvonalakra + Leave Strokes Körvonalak megtartása @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ecsetnek nincs grafikája (csak a szóró-, a grafikus és a mintázatecseteknek van) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Elrendezőeszközök @@ -6923,8 +6951,33 @@ breakText Glyphs Glifák @plural {n} line cut|{n} lines cut {n} sor levágva @msg `first` and `last` must be numbers of lines the console holds, first before last A `first` és a `last` a konzolban tárolt sorok számai legyenek, a `first` a `last` előtt +# Learn panel + Learn Tanulás + Explore tutorials Oktatóanyagok felfedezése + For you Önnek + Skills Készségek + Get started Első lépések + Quick skill guide Gyors készségkalauz + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Rövid gyakorlati oktatóanyagok az alapvető eszközökhöz, készségek szerint csoportosítva. Válasszon egy készséget az oktatóanyagai megtekintéséhez. + List Tutorials Oktatóanyagok listázása + Show Tutorial Oktatóanyag megjelenítése +@msg missing `tutorial` (see learn.list) hiányzik: `tutorial` (lásd: learn.list) +@msg unknown tutorial `{id}` (see learn.list) ismeretlen oktatóanyag: `{id}` (lásd: learn.list) + # Deferred plug-in effects Refresh Deferred Effects Elhalasztott effektusok frissítése Deferred Effects Elhalasztott effektusok @msg no plug-in effect is deferred nincs elhalasztott bővítményeffektus @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Megnyitva, elhalasztott bővítményeffektusok: {n}. Az Effektus › Elhalasztott effektusok frissítése futtatja őket. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Vágóeszközök + Stencil Bridges… Sablonhidak… + Stencil Bridges Sablonhidak + Shortest Crossing Legrövidebb átkelés + Direction Irány +@msg The selected art has no islands to bridge A kijelölt grafikán nincsenek összekötendő szigetek +@msg No bridge of this width fits across the selected art Ilyen széles híd nem fér el a kijelölt grafikán +@msg Some islands could not be joined to the area around the art Egyes szigeteket nem sikerült összekötni a grafikát körülvevő területtel +@msg `width` must be from {lo} to {hi} pt A `width` értéke {lo} és {hi} pt között lehet +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`: 1 és {MAX_BRIDGES_PER_ISLAND} közötti egész szám kell diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8e2b383e2d..59761c7fcf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -366,6 +366,8 @@ Stair Step Undakan tangga Gravity Gravitasi Type on a Path Options… Opsi teks pada jalur… + Center on Top Pusatkan di atas + Center on Bottom Pusatkan di bawah Threaded Text Teks beruntun Create Buat Release Selection Lepaskan seleksi @@ -454,6 +456,8 @@ Extrude & Bevel… Ekstrusi & bevel… 3D (Classic) 3D (klasik) Extrude & Bevel (Classic)… Ekstrusi & bevel (klasik)… + Revolve (Classic)… Revolusi (klasik)… + Rotate (Classic)… Putar (klasik)… Surface Permukaan Wireframe Kerangka kawat No Shading Tanpa pengarsiran @@ -786,6 +790,7 @@ Sky Gradients Gradien langit Neutral Gradients Gradien netral Organic Fades Pudar organik + Holographic Holografik Duotone Gradients Gradien duotone Radial Glows Pendar radial Save Swatch Library… Simpan pustaka sampel warna… @@ -1820,6 +1825,9 @@ digits Malayalam Malayalam Right Indent Indentasi kanan Right-to-Left Paragraph Direction Arah paragraf kanan ke kiri Roman Hanging Punctuation Tanda baca gantung Romawi + Korean Line Break Anywhere Putus baris Korea di mana saja + East Asian Composition Settings Pengaturan komposisi Asia Timur + Korean Composition Settings Pengaturan komposisi Korea Select a text object to edit its paragraph attributes. Pilih objek teks untuk mengedit atribut paragrafnya. Single-line Composer Komposer satu baris Toggle Line Composer Alihkan komposer baris @@ -4508,6 +4516,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} bukan modul plug-in yang valid: {0} @msg not an EMF or WMF file bukan berkas EMF atau WMF @msg not an HP-GL file bukan berkas HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` harus berupa array berisi 1 sampai 255 warna #rrggbb @msg not found tidak ditemukan @msg not in pattern editing mode tidak dalam mode pengeditan pola @msg finish editing the pattern first selesaikan pengeditan pola terlebih dahulu @@ -6067,6 +6076,17 @@ axis Y Axis Sumbu Y Charge Wave Gelombang Muatan Spark Trail Jejak Percikan Calligraphic Nibs Pena kaligrafi + Bristle Brushes Kuas-kuas bristle + Fine Liner Garis halus + Pointed Flat Pipih runcing + Stubby Round Bulat pendek + Dry Scrub Gosok kering + Soft Dome Kubah lembut + Filbert Pipih oval + Angled Round Bulat miring + Angled Flat Pipih miring + Fan Blender Kipas pembaur + Grass Fan Kipas rumput Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar Save Brush Library… Simpan pustaka kuas… @@ -6411,6 +6431,10 @@ breakText Glyphs Glif Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Stroke menggunakan satu atau beberapa kuas ini. Lepaskan kuas dari stroke tersebut dan hapus kuasnya? Remove Strokes Hapus Stroke Place Brush Art Tempatkan karya seni kuas + Replace Brush Art Ganti karya seni kuas + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Stroke menggunakan kuas ini. Terapkan karya seni baru ke stroke tersebut, atau biarkan apa adanya dan tambahkan salinan kuas dengan karya seni baru? + Apply to Strokes Terapkan ke Stroke + Leave Strokes Biarkan Stroke @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` tidak memiliki karya seni (hanya kuas sebar, kuas seni, dan kuas pola yang memilikinya) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alat penyusunan @@ -6646,6 +6670,26 @@ breakText Glyphs Glif @plural {n} earlier line dropped|{n} earlier lines dropped {n} baris lama dibuang @plural {n} line cut|{n} lines cut {n} baris dipotong +# Learn panel + Learn Belajar + Explore tutorials Jelajahi tutorial + For you Untuk Anda + Skills Keterampilan + Get started Memulai + Quick skill guide Panduan cepat keterampilan + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutorial praktik singkat untuk alat-alat utama, dikelompokkan menurut keterampilan. Pilih keterampilan untuk melihat tutorialnya. + List Tutorials Daftar tutorial + Show Tutorial Tampilkan tutorial +@msg missing `tutorial` (see learn.list) `tutorial` tidak ada (lihat learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial `{id}` tidak dikenal (lihat learn.list) + # Deferred plug-in effects Refresh Deferred Effects Segarkan efek yang ditunda Deferred Effects Efek yang ditunda + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Alat pemotongan + Stencil Bridges… Jembatan Stensil… + Stencil Bridges Jembatan Stensil + Shortest Crossing Persilangan terpendek + Direction Arah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 26e1f29651..8ad07b5fbf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1162,6 +1162,8 @@ font-technology Variable Variabili Extrude & Bevel… Estrusione e smusso… 3D (Classic) 3D (classico) Extrude & Bevel (Classic)… Estrusione e smusso (classico)… + Revolve (Classic)… Rivoluzione (classico)… + Rotate (Classic)… Ruota (classico)… Surface Superficie Wireframe Reticolo No Shading Nessuna ombreggiatura @@ -2396,6 +2398,10 @@ file-filter Templates Modelli Arch Volta @msg select point type or type on a path seleziona testo per punti o testo su tracciato @msg type on a path in a thread cannot use Dynamic Text: {frames} il testo su tracciato in un testo concatenato non può usare il testo dinamico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} il testo su un tracciato aperto o composto non può essere centrato in alto o in basso: {open} +@msg type on a path with no length or no height cannot be centered: {flat} il testo su un tracciato senza lunghezza o senza altezza non può essere centrato: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} il testo su tracciato in un testo concatenato non può essere centrato in alto o in basso: {frames} +@msg select type on a closed path seleziona del testo su un tracciato chiuso Optimize for fast web view Ottimizza per visualizzazione web veloce Optimized Ottimizzato Options Opzioni @@ -2403,6 +2409,7 @@ file-filter Templates Modelli Options: Opzioni: Options… Opzioni… Organic Fades Dissolvenze organiche + Holographic Olografici or more spaces with a tab o più spazi con una tabulazione Order: Ordine: Ordinals Ordinali @@ -2957,6 +2964,9 @@ digits Malayalam Malayalam Rise… Ascesa… Roman Baseline Linea di base romana Roman Hanging Punctuation Punteggiatura sporgente romana + Korean Line Break Anywhere Interruzione di riga coreana ovunque + East Asian Composition Settings Impostazioni di composizione dell’Asia orientale + Korean Composition Settings Impostazioni di composizione coreana Rotate Ruota Rotate 90° CW Ruota di 90° in senso orario Rotate Tool Strumento Ruota @@ -3763,6 +3773,8 @@ digits Malayalam Malayalam Type on a Path Testo su tracciato Type on a Path Options Opzioni testo su tracciato Type on a Path Options… Opzioni testo su tracciato… + Center on Top Centra in alto + Center on Bottom Centra in basso Type on a Path Tool Strumento Testo su tracciato Type Orientation Orientamento testo Type stays text, and every stroke is a line with its lineweight and dashes. Il testo resta testo e ogni traccia diventa una linea con il suo spessore e i suoi tratteggi. @@ -4627,6 +4639,7 @@ axis Both Entrambi @msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0} @msg not an EMF or WMF file non è un file EMF o WMF @msg not an HP-GL file non è un file HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve essere un elenco da 1 a 255 colori #rrggbb @msg not found non trovato @msg not in pattern editing mode non sei in modalità modifica motivo @msg finish editing the pattern first termina prima la modifica del motivo @@ -5936,6 +5949,17 @@ axis Y Axis Asse Y Charge Wave Onda di carica Spark Trail Scia di scintille Calligraphic Nibs Pennini calligrafici + Bristle Brushes Pennelli a setole + Fine Liner Tratto fine + Pointed Flat Piatto appuntito + Stubby Round Tondo corto + Dry Scrub Strofinato secco + Soft Dome Cupola morbida + Filbert Lingua di gatto + Angled Round Tondo obliquo + Angled Flat Piatto obliquo + Fan Blender Ventaglio sfumino + Grass Fan Ventaglio per erba Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione Save Brush Library… Salva libreria pennelli… @@ -6315,6 +6339,10 @@ breakText Glyphs Glifi Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Alcune tracce usano uno o più di questi pennelli. Rimuovere i pennelli da queste tracce ed eliminare i pennelli? Remove Strokes Rimuovi tracce Place Brush Art Inserisci grafica del pennello + Replace Brush Art Sostituisci grafica del pennello + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Alcune tracce usano questo pennello. Applicare la nuova grafica a queste tracce, o lasciarle come sono e aggiungere una copia del pennello con la nuova grafica? + Apply to Strokes Applica alle tracce + Leave Strokes Lascia le tracce @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` non ha una grafica (ce l’hanno solo i pennelli a dispersione, artistici e motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Strumenti di disposizione @@ -6568,8 +6596,33 @@ breakText Glyphs Glifi @plural {n} line cut|{n} lines cut {n} riga tagliata|{n} righe tagliate @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devono essere numeri di righe presenti nella console, `first` prima di `last` +# Learn panel + Learn Impara + Explore tutorials Esplora i tutorial + For you Per te + Skills Competenze + Get started Per iniziare + Quick skill guide Guida rapida alle competenze + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Brevi tutorial pratici sugli strumenti essenziali, raggruppati per competenza. Scegli una competenza per vederne i tutorial. + List Tutorials Elenca tutorial + Show Tutorial Mostra tutorial +@msg missing `tutorial` (see learn.list) manca `tutorial` (vedi learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial sconosciuto `{id}` (vedi learn.list) + # Deferred plug-in effects Refresh Deferred Effects Aggiorna effetti rimandati Deferred Effects Effetti rimandati @msg no plug-in effect is deferred nessun effetto plug-in è rimandato @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aperto con {n} effetto plug-in rimandato. Effetto › Aggiorna effetti rimandati lo esegue.|Aperto con {n} effetti plug-in rimandati. Effetto › Aggiorna effetti rimandati li esegue. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Strumenti di taglio + Stencil Bridges… Ponti per stencil… + Stencil Bridges Ponti per stencil + Shortest Crossing Attraversamento più breve + Direction Direzione +@msg The selected art has no islands to bridge La grafica selezionata non ha isole da collegare +@msg No bridge of this width fits across the selected art Nessun ponte di questa larghezza entra nella grafica selezionata +@msg Some islands could not be joined to the area around the art Non è stato possibile collegare alcune isole all’area intorno alla grafica +@msg `width` must be from {lo} to {hi} pt `width` deve essere compreso tra {lo} e {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve essere un numero intero compreso tra 1 e {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 30873028e8..b658080d59 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -423,6 +423,8 @@ digits Malayalam マラヤーラム数字 Stair Step 階段 Gravity 引力 Type on a Path Options… パス上文字オプション… + Center on Top 上端に中央揃え + Center on Bottom 下端に中央揃え Threaded Text スレッドテキストオプション Create 作成 Release Selection 選択部分をリリース @@ -453,6 +455,10 @@ digits Malayalam マラヤーラム数字 Arch アーチ @msg select point type or type on a path ポイント文字またはパス上文字を選択してください @msg type on a path in a thread cannot use Dynamic Text: {frames} スレッドテキスト内のパス上文字にはダイナミックテキストを使用できません: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} オープンパスや複合パス上のパス上文字は上端または下端に中央揃えできません: {open} +@msg type on a path with no length or no height cannot be centered: {flat} 長さや高さのないパスのパス上文字は中央揃えできません: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} スレッドテキスト内のパス上文字は上端または下端に中央揃えできません: {frames} +@msg select type on a closed path クローズパス上のパス上文字を選択してください Fill with Placeholder Text サンプルテキストの割り付け Insert Inline Symbol インラインシンボルを挿入 Show Hidden Characters 制御文字を表示 @@ -531,6 +537,8 @@ digits Malayalam マラヤーラム数字 Extrude & Bevel… 押し出しとベベル… 3D (Classic) 3D(クラシック) Extrude & Bevel (Classic)… 押し出しとベベル(クラシック)… + Revolve (Classic)… 回転体(クラシック)… + Rotate (Classic)… 回転(クラシック)… Surface 表面 Wireframe ワイヤーフレーム No Shading 陰影なし @@ -872,6 +880,7 @@ digits Malayalam マラヤーラム数字 Sky Gradients 空のグラデーション Neutral Gradients ニュートラルグラデーション Organic Fades オーガニックフェード + Holographic ホログラフィック Duotone Gradients ダブルトーングラデーション Radial Glows 円形グロー Save Swatch Library… スウォッチライブラリを保存… @@ -2943,6 +2952,9 @@ mark-style Japanese 日本式 Right Plane 右面 Right: 右: Roman Hanging Punctuation 欧文ぶら下がり + Korean Line Break Anywhere 韓国語の任意位置での改行 + East Asian Composition Settings 東アジア言語の組版設定 + Korean Composition Settings 韓国語の組版設定 Rotate 回転 Rotate Tool 回転ツール Rotate View Tool 回転ビューツール @@ -4697,6 +4709,7 @@ swatch Bright Violet ビビッドバイオレット @msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません @msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません @msg not an HP-GL file HP-GL ファイルではありません +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` は 1〜255 個の #rrggbb 形式のカラーの配列にしてください @msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません @msg the EMF header is damaged EMF ヘッダーが破損しています @msg damaged 破損しています @@ -6001,6 +6014,17 @@ axis Y Axis Y 軸 Charge Wave チャージウェーブ Spark Trail スパークトレイル Calligraphic Nibs カリグラフィペン先 + Bristle Brushes 剛毛ブラシ集 + Fine Liner 細線ライナー + Pointed Flat 先細平筆 + Stubby Round 短丸筆 + Dry Scrub ドライスクラブ + Soft Dome ソフトドーム + Filbert フィルバート + Angled Round 斜め丸筆 + Angled Flat 斜め平筆 + Fan Blender ぼかし扇筆 + Grass Fan 草描き扇筆 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ Save Brush Library… ブラシライブラリを保存… @@ -6380,6 +6404,10 @@ breakText Glyphs 字形 Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? これらのブラシの 1 つ以上が線で使われています。それらの線からブラシを外して、ブラシを削除しますか? Remove Strokes 線を削除 Place Brush Art ブラシのアートを配置 + Replace Brush Art ブラシのアートを置き換え + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? このブラシは線で使われています。新しいアートをそれらの線に適用しますか?それとも線をそのままにして、新しいアートを持つブラシのコピーを追加しますか? + Apply to Strokes 線に適用 + Leave Strokes 線を保持 @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` にはアートがありません (アートを持つのは散布ブラシ、アートブラシ、パターンブラシだけです) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 配置ツール @@ -6633,8 +6661,33 @@ breakText Glyphs 字形 @plural {n} line cut|{n} lines cut {n} 行を切り詰め @msg `first` and `last` must be numbers of lines the console holds, first before last `first` と `last` には、コンソールが保持している行の番号を、`first` が `last` より前になるよう指定してください +# Learn panel + Learn 学ぶ + Explore tutorials チュートリアルを見る + For you おすすめ + Skills スキル + Get started はじめに + Quick skill guide クイックスキルガイド + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 基本ツールを学ぶ短い実践チュートリアルを、スキルごとにまとめています。スキルを選ぶとチュートリアルが表示されます。 + List Tutorials チュートリアルの一覧 + Show Tutorial チュートリアルを表示 +@msg missing `tutorial` (see learn.list) `tutorial` が指定されていません (learn.list を参照) +@msg unknown tutorial `{id}` (see learn.list) `{id}` は不明なチュートリアルです (learn.list を参照) + # Deferred plug-in effects Refresh Deferred Effects 保留中の効果を更新 Deferred Effects 保留中の効果 @msg no plug-in effect is deferred 保留中のプラグイン効果はありません @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. プラグイン効果を {n} 件保留して開きました。「効果 › 保留中の効果を更新」で実行します。 + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools カッティングツール + Stencil Bridges… ステンシルブリッジ… + Stencil Bridges ステンシルブリッジ + Shortest Crossing 最短の横断 + Direction 方向 +@msg The selected art has no islands to bridge 選択したアートワークにはブリッジでつなぐアイランドがありません +@msg No bridge of this width fits across the selected art この幅のブリッジは選択したアートワークに収まりません +@msg Some islands could not be joined to the area around the art 一部のアイランドをアートワークの周囲の領域につなげられませんでした +@msg `width` must be from {lo} to {hi} pt `width` は {lo}~{hi} pt の範囲で指定してください +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` には 1 から {MAX_BRIDGES_PER_ISLAND} の整数を指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8e9d9d049d..516d9720ab 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -331,6 +331,8 @@ Stair Step საფეხურები Gravity გრავიტაცია Type on a Path Options… ტრაექტორიაზე ტექსტის პარამეტრები… + Center on Top ზემოთ ცენტრში მოთავსება + Center on Bottom ქვემოთ ცენტრში მოთავსება Threaded Text დაკავშირებული ტექსტი Create შექმნა Release Selection მონიშნულის მოხსნა @@ -351,6 +353,10 @@ Arch თაღი @msg select point type or type on a path აირჩიეთ წერტილოვანი ტექსტი ან ტრაექტორიაზე ტექსტი @msg type on a path in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} ღია ან შედგენილ ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {open} +@msg type on a path with no length or no height cannot be centered: {flat} ტექსტი ვერ მოთავსდება ცენტრში ტრაექტორიაზე, რომელსაც არ აქვს სიგრძე ან სიმაღლე: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {frames} +@msg select type on a closed path მონიშნეთ დახურულ ტრაექტორიაზე ტექსტი Fill with Placeholder Text შემავსებელი ტექსტით შევსება Insert Inline Symbol ჩადგმული სიმბოლოს ჩასმა Show Hidden Characters დამალული სიმბოლოების ჩვენება @@ -422,6 +428,8 @@ Extrude & Bevel… ამოწევა და წაკვეთა… 3D (Classic) 3D (კლასიკური) Extrude & Bevel (Classic)… ამოწევა და წაკვეთა (კლასიკური)… + Revolve (Classic)… ბრუნვა (კლასიკური)… + Rotate (Classic)… შემობრუნება (კლასიკური)… Surface ზედაპირი Wireframe მავთულის ჩარჩო No Shading დაჩრდილვის გარეშე @@ -746,6 +754,7 @@ Sky Gradients ცის გრადიენტები Neutral Gradients ნეიტრალური გრადიენტები Organic Fades ორგანული გაქრობები + Holographic ჰოლოგრაფიული Duotone Gradients ორტონიანი გრადიენტები Radial Glows რადიალური ნათებები Save Swatch Library… ფერის ნიმუშების ბიბლიოთეკის შენახვა… @@ -1746,6 +1755,9 @@ digits Malayalam მალაიალამური Right Indent მარჯვენა შეწევა Right-to-Left Paragraph Direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ Roman Hanging Punctuation ლათინური გამოწეული პუნქტუაცია + Korean Line Break Anywhere კორეული სტრიქონის გადატანა ნებისმიერ ადგილას + East Asian Composition Settings აღმოსავლეთ აზიური აწყობის პარამეტრები + Korean Composition Settings კორეული აწყობის პარამეტრები Select a text object to edit its paragraph attributes. აბზაცის ატრიბუტების რედაქტირებისთვის მონიშნეთ ტექსტური ობიექტი. Single-line Composer სტრიქონის მიხედვით აწყობა Toggle Line Composer აწყობის რეჟიმის გადართვა @@ -4336,6 +4348,7 @@ font-technology Variable ცვლადი @msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0} @msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი @msg not an HP-GL file ეს არ არის HP-GL ფაილი +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` უნდა იყოს 1-დან 255-მდე #rrggbb ფერის მასივი @msg not found ვერ მოიძებნა @msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული @msg finish editing the pattern first ჯერ დაასრულეთ პატერნის რედაქტირება @@ -6262,6 +6275,17 @@ axis Y Axis Y ღერძი Charge Wave მუხტის ტალღა Spark Trail ნაპერწკლის კვალი Calligraphic Nibs კალიგრაფიული წვერები + Bristle Brushes ჯაგრის ფუნჯები + Fine Liner წვრილი ხაზი + Pointed Flat წაწვეტებული ბრტყელი + Stubby Round მოკლე მრგვალი + Dry Scrub მშრალი ხეხვა + Soft Dome რბილი გუმბათი + Filbert ოვალური ბრტყელი + Angled Round დახრილი მრგვალი + Angled Flat დახრილი ბრტყელი + Fan Blender შერევის მარაო + Grass Fan ბალახის მარაო Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… @@ -6712,6 +6736,10 @@ breakText Glyphs გლიფები Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? კონტურები იყენებს ამ ფუნჯებიდან ერთს ან მეტს. მოიხსნას ფუნჯები ამ კონტურებიდან და წაიშალოს ფუნჯები? Remove Strokes კონტურების მოხსნა Place Brush Art ფუნჯის ნახატის განთავსება + Replace Brush Art ფუნჯის ნახატის ჩანაცვლება + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? კონტურები იყენებს ამ ფუნჯს. გამოყენებულ იქნას ახალი ნახატი ამ კონტურებზე, თუ დარჩეს ისინი როგორც არის და დაემატოს ფუნჯის ასლი ახალი ნახატით? + Apply to Strokes კონტურებზე გამოყენება + Leave Strokes კონტურების დატოვება @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ნახატი არ აქვს (ნახატი მხოლოდ გაფანტვის, მხატვრულ და პატერნის ფუნჯებს აქვს) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools განლაგების ხელსაწყოები @@ -7053,8 +7081,33 @@ font-script Japanese იაპონური @plural {n} line cut|{n} lines cut {n} სტრიქონი შემოკლდა @msg `first` and `last` must be numbers of lines the console holds, first before last `first` და `last` უნდა იყოს კონსოლში შენახული სტრიქონების ნომრები და `first` უნდა იყოს `last`-ზე წინ +# Learn panel + Learn სწავლა + Explore tutorials გაკვეთილების დათვალიერება + For you თქვენთვის + Skills უნარები + Get started დაწყება + Quick skill guide უნარების სწრაფი გზამკვლევი + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. მოკლე პრაქტიკული გაკვეთილები ძირითადი ხელსაწყოებისთვის, დაჯგუფებული უნარების მიხედვით. აირჩიეთ უნარი მისი გაკვეთილების სანახავად. + List Tutorials გაკვეთილების სია + Show Tutorial გაკვეთილის ჩვენება +@msg missing `tutorial` (see learn.list) აკლია `tutorial` (იხ. learn.list) +@msg unknown tutorial `{id}` (see learn.list) უცნობი გაკვეთილი `{id}` (იხ. learn.list) + # Deferred plug-in effects Refresh Deferred Effects გადადებული ეფექტების განახლება Deferred Effects გადადებული ეფექტები @msg no plug-in effect is deferred პლაგინის ეფექტი არ არის გადადებული @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. გაიხსნა, გადადებული პლაგინის ეფექტები: {n}. ეფექტი › გადადებული ეფექტების განახლება მათ გაუშვებს. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools ჭრის ხელსაწყოები + Stencil Bridges… ტრაფარეტის ხიდები… + Stencil Bridges ტრაფარეტის ხიდები + Shortest Crossing უმოკლესი გადაკვეთა + Direction მიმართულება +@msg The selected art has no islands to bridge მონიშნულ ნახატს არ აქვს დასაკავშირებელი კუნძულები +@msg No bridge of this width fits across the selected art ამ სიგანის ხიდი მონიშნულ ნახატზე არ ეტევა +@msg Some islands could not be joined to the area around the art ზოგიერთი კუნძული ვერ დაუკავშირდა ნახატის გარშემო არეს +@msg `width` must be from {lo} to {hi} pt `width` უნდა იყოს {lo}-დან {hi} pt-მდე +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` უნდა იყოს მთელი რიცხვი 1-დან {MAX_BRIDGES_PER_ISLAND}-მდე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f00e6cdeba..8c333d6634 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -416,6 +416,8 @@ digits Malayalam 말라얄람어 Stair Step 계단 Gravity 중력 Type on a Path Options… 패스 위 문자 옵션… + Center on Top 위쪽에 가운데 맞춤 + Center on Bottom 아래쪽에 가운데 맞춤 Threaded Text 연결된 문자 Create 만들기 Release Selection 선택 해제 @@ -446,6 +448,10 @@ digits Malayalam 말라얄람어 Arch 아치 @msg select point type or type on a path 포인트 문자 또는 패스 위 문자를 선택하십시오 @msg type on a path in a thread cannot use Dynamic Text: {frames} 연결된 문자의 패스 위 문자에는 동적 텍스트를 사용할 수 없습니다: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 열린 패스나 컴파운드 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {open} +@msg type on a path with no length or no height cannot be centered: {flat} 길이나 높이가 없는 패스 위 문자는 가운데 맞춤할 수 없습니다: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 연결된 문자의 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {frames} +@msg select type on a closed path 닫힌 패스 위 문자를 선택하세요 Fill with Placeholder Text 자리 표시 문자로 채우기 Insert Inline Symbol 인라인 심볼 삽입 Show Hidden Characters 숨겨진 문자 표시 @@ -524,6 +530,8 @@ digits Malayalam 말라얄람어 Extrude & Bevel… 돌출 및 경사… 3D (Classic) 3D(클래식) Extrude & Bevel (Classic)… 돌출 및 경사(클래식)… + Revolve (Classic)… 회전체(클래식)… + Rotate (Classic)… 회전(클래식)… Surface 표면 Wireframe 와이어프레임 No Shading 셰이딩 없음 @@ -865,6 +873,7 @@ digits Malayalam 말라얄람어 Sky Gradients 하늘 그레이디언트 Neutral Gradients 중간색 그레이디언트 Organic Fades 유기적 페이드 + Holographic 홀로그래픽 Duotone Gradients 듀오톤 그레이디언트 Radial Glows 방사형 광선 Save Swatch Library… 견본 라이브러리 저장… @@ -2932,6 +2941,9 @@ mark-style Japanese 일본식 Right Plane 오른쪽 평면 Right: 오른쪽: Roman Hanging Punctuation 로마자 걸림 문장 부호 + Korean Line Break Anywhere 한글 어디서나 줄 바꿈 + East Asian Composition Settings 동아시아 조판 설정 + Korean Composition Settings 한글 조판 설정 Rotate 회전 Rotate Tool 회전 도구 Rotate View Tool 보기 회전 도구 @@ -4624,6 +4636,7 @@ theme Appearance 외관 @msg {bpp}-bit bitmaps can't be read {bpp}비트 비트맵은 읽을 수 없습니다 @msg not an EMF or WMF file EMF 또는 WMF 파일이 아닙니다 @msg not an HP-GL file HP-GL 파일이 아닙니다 +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`는 #rrggbb 색상 1~255개의 배열이어야 합니다 @msg its layers use {_1}, which aren't read yet 레이어에 {_1}이(가) 있으며, 아직 읽을 수 없습니다 @msg the EMF header is damaged EMF 헤더가 손상되었습니다 @msg damaged 손상됨 @@ -5897,6 +5910,17 @@ axis Y Axis Y축 Charge Wave 차지 웨이브 Spark Trail 스파크 트레일 Calligraphic Nibs 서예 펜촉 + Bristle Brushes 강모 브러시 모음 + Fine Liner 가는 라이너 + Pointed Flat 뾰족한 평붓 + Stubby Round 짧은 둥근 붓 + Dry Scrub 마른 문지르기 + Soft Dome 부드러운 돔 + Filbert 필버트 + Angled Round 사선 둥근 붓 + Angled Flat 사선 평붓 + Fan Blender 블렌딩 부채붓 + Grass Fan 잔디 부채붓 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 Save Brush Library… 브러시 라이브러리 저장… @@ -6276,6 +6300,10 @@ breakText Glyphs 글리프 Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 선에서 이 브러시 중 하나 이상을 사용합니다. 해당 선에서 브러시를 제거하고 브러시를 삭제하시겠습니까? Remove Strokes 선 제거 Place Brush Art 브러시 아트 배치 + Replace Brush Art 브러시 아트 바꾸기 + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? 선에서 이 브러시를 사용합니다. 새 아트를 해당 선에 적용하시겠습니까, 아니면 선을 그대로 두고 새 아트로 브러시 사본을 추가하시겠습니까? + Apply to Strokes 선에 적용 + Leave Strokes 선 유지 @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}`에는 아트가 없습니다 (흩뿌리기, 아트, 패턴 브러시에만 아트가 있습니다) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 배치 도구 @@ -6529,8 +6557,33 @@ breakText Glyphs 글리프 @plural {n} line cut|{n} lines cut 줄 {n}개 잘림 @msg `first` and `last` must be numbers of lines the console holds, first before last `first`와 `last`는 콘솔에 있는 줄 번호여야 하며 `first`가 `last`보다 앞서야 합니다 +# Learn panel + Learn 학습 + Explore tutorials 튜토리얼 살펴보기 + For you 추천 + Skills 기술 + Get started 시작하기 + Quick skill guide 빠른 기술 가이드 + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 기본 도구를 익히는 짧은 실습 튜토리얼을 기술별로 모았습니다. 기술을 선택하면 해당 튜토리얼이 표시됩니다. + List Tutorials 튜토리얼 목록 + Show Tutorial 튜토리얼 표시 +@msg missing `tutorial` (see learn.list) `tutorial`이 없습니다(learn.list 참조) +@msg unknown tutorial `{id}` (see learn.list) 알 수 없는 튜토리얼 `{id}`(learn.list 참조) + # Deferred plug-in effects Refresh Deferred Effects 보류된 효과 새로 고침 Deferred Effects 보류된 효과 @msg no plug-in effect is deferred 보류된 플러그인 효과가 없습니다 @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 플러그인 효과 {n}개를 보류하고 열었습니다. 효과 › 보류된 효과 새로 고침으로 실행합니다. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools 커팅 도구 + Stencil Bridges… 스텐실 브리지… + Stencil Bridges 스텐실 브리지 + Shortest Crossing 최단 가로지르기 + Direction 방향 +@msg The selected art has no islands to bridge 선택한 아트워크에 연결할 섬이 없습니다 +@msg No bridge of this width fits across the selected art 이 너비의 브리지가 선택한 아트워크에 들어가지 않습니다 +@msg Some islands could not be joined to the area around the art 일부 섬을 아트워크 주변 영역에 연결할 수 없습니다 +@msg `width` must be from {lo} to {hi} pt `width`는 {lo}에서 {hi}pt 사이여야 합니다 +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`는 1에서 {MAX_BRIDGES_PER_ISLAND} 사이의 정수여야 합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bc00b76698..7376a8edf4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -380,6 +380,8 @@ Stair Step Trap Gravity Zwaartekracht Type on a Path Options… Opties voor tekst op pad… + Center on Top Boven centreren + Center on Bottom Onder centreren Threaded Text Gekoppelde tekst Create Maken Release Selection Selectie vrijgeven @@ -468,6 +470,8 @@ Extrude & Bevel… Extruderen en afschuinen… 3D (Classic) 3D (klassiek) Extrude & Bevel (Classic)… Extruderen en afschuinen (klassiek)… + Revolve (Classic)… Draaien (klassiek)… + Rotate (Classic)… Roteren (klassiek)… Surface Oppervlak Wireframe Draadmodel No Shading Geen arcering @@ -800,6 +804,7 @@ Sky Gradients Luchtverlopen Neutral Gradients Neutrale verlopen Organic Fades Organische vervagingen + Holographic Holografisch Duotone Gradients Duotoonverlopen Radial Glows Radiale gloed Save Swatch Library… Staalenbibliotheek opslaan… @@ -1834,6 +1839,9 @@ digits Malayalam Malayalam Right Indent Rechts inspringen Right-to-Left Paragraph Direction Alinearichting van rechts naar links Roman Hanging Punctuation Romeinse hangende leestekens + Korean Line Break Anywhere Koreaans regeleinde overal + East Asian Composition Settings Instellingen voor Oost-Aziatische zetting + Korean Composition Settings Instellingen voor Koreaanse zetting Select a text object to edit its paragraph attributes. Selecteer een tekstobject om de alineakenmerken ervan te bewerken. Single-line Composer Compositie per regel Toggle Line Composer Compositiemethode wisselen @@ -4520,6 +4528,7 @@ imageTrace Outline Contour @msg not a valid plug-in module: {0} geen geldige plug-inmodule: {0} @msg not an EMF or WMF file geen EMF- of WMF-bestand @msg not an HP-GL file geen HP-GL-bestand +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` moet een array van 1 tot 255 #rrggbb-kleuren zijn @msg not found niet gevonden @msg not in pattern editing mode niet in patroonbewerkingsmodus @msg finish editing the pattern first rond eerst het bewerken van het patroon af @@ -6118,6 +6127,17 @@ axis Y Axis Y-as Charge Wave Laadgolf Spark Trail Vonkenspoor Calligraphic Nibs Kalligrafische penpunten + Bristle Brushes Borstelpenselen + Fine Liner Fijne liner + Pointed Flat Spitse platte + Stubby Round Korte ronde + Dry Scrub Droog schrobben + Soft Dome Zachte koepel + Filbert Kattentong + Angled Round Schuine ronde + Angled Flat Schuine platte + Fan Blender Vervloeiwaaier + Grass Fan Graswaaier Pattern Borders Patroonranden Scatter Shapes Strooivormen Save Brush Library… Penseelbibliotheek opslaan… @@ -6462,6 +6482,10 @@ breakText Glyphs Glyphs Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Contouren gebruiken een of meer van deze kwasten. De kwasten van deze contouren halen en de kwasten verwijderen? Remove Strokes Contouren verwijderen Place Brush Art Penseelillustratie plaatsen + Replace Brush Art Penseelillustratie vervangen + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Contouren gebruiken dit penseel. De nieuwe illustratie op deze contouren toepassen, of ze laten zoals ze zijn en een kopie van het penseel met de nieuwe illustratie toevoegen? + Apply to Strokes Op contouren toepassen + Leave Strokes Contouren behouden @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` heeft geen illustratie (alleen verspreidings-, art- en patroonpenselen hebben er een) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Schikgereedschappen @@ -6697,6 +6721,26 @@ breakText Glyphs Glyphs @plural {n} earlier line dropped|{n} earlier lines dropped {n} eerdere regel verwijderd|{n} eerdere regels verwijderd @plural {n} line cut|{n} lines cut {n} regel ingekort|{n} regels ingekort +# Learn panel + Learn Leren + Explore tutorials Tutorials verkennen + For you Voor jou + Skills Vaardigheden + Get started Aan de slag + Quick skill guide Snelgids voor vaardigheden + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korte praktische tutorials voor de belangrijkste gereedschappen, gegroepeerd per vaardigheid. Kies een vaardigheid om de tutorials ervan te zien. + List Tutorials Tutorials weergeven + Show Tutorial Tutorial tonen +@msg missing `tutorial` (see learn.list) `tutorial` ontbreekt (zie learn.list) +@msg unknown tutorial `{id}` (see learn.list) onbekende tutorial `{id}` (zie learn.list) + # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen Deferred Effects Uitgestelde effecten + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Snijgereedschap + Stencil Bridges… Sjabloonbruggen… + Stencil Bridges Sjabloonbruggen + Shortest Crossing Kortste oversteek + Direction Richting diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 23940fb80f..21543430d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -364,6 +364,8 @@ Stair Step Schodki Gravity Grawitacja Type on a Path Options… Opcje tekstu na ścieżce… + Center on Top Wyśrodkuj na górze + Center on Bottom Wyśrodkuj na dole Threaded Text Tekst wątkowy Create Utwórz Release Selection Uwolnij zaznaczenie @@ -384,6 +386,10 @@ Arch Sklepienie @msg select point type or type on a path zaznacz tekst punktowy lub tekst na ścieżce @msg type on a path in a thread cannot use Dynamic Text: {frames} tekst na ścieżce w tekście wątkowym nie może używać tekstu dynamicznego: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} nie można wyśrodkować na górze ani na dole tekstu na ścieżce otwartej lub złożonej: {open} +@msg type on a path with no length or no height cannot be centered: {flat} nie można wyśrodkować tekstu na ścieżce bez długości lub bez wysokości: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} nie można wyśrodkować na górze ani na dole tekstu na ścieżce w tekście wątkowym: {frames} +@msg select type on a closed path zaznacz tekst na zamkniętej ścieżce Fill with Placeholder Text Wypełnij tekstem zastępczym Insert Inline Symbol Wstaw symbol w tekście Show Hidden Characters Pokaż ukryte znaki @@ -455,6 +461,8 @@ Extrude & Bevel… Wyciągnięcie i fazowanie… 3D (Classic) 3D (klasyczne) Extrude & Bevel (Classic)… Wyciągnięcie i fazowanie (klasyczne)… + Revolve (Classic)… Obrót (klasyczny)… + Rotate (Classic)… Obróć (klasyczny)… Surface Powierzchnia Wireframe Szkielet No Shading Bez cieniowania @@ -796,6 +804,7 @@ Sky Gradients Gradienty nieba Neutral Gradients Gradienty neutralne Organic Fades Organiczne zanikania + Holographic Holograficzne Duotone Gradients Gradienty dwutonowe Radial Glows Blaski promieniste Save Swatch Library… Zapisz bibliotekę próbek… @@ -1932,6 +1941,9 @@ digits Malayalam Malajalam Right Indent Wcięcie z prawej Right-to-Left Paragraph Direction Kierunek akapitu od prawej do lewej Roman Hanging Punctuation Wysunięta interpunkcja łacińska + Korean Line Break Anywhere Koreański podział wiersza w dowolnym miejscu + East Asian Composition Settings Ustawienia składu wschodnioazjatyckiego + Korean Composition Settings Ustawienia składu koreańskiego Select a text object to edit its paragraph attributes. Zaznacz obiekt tekstowy, aby edytować atrybuty akapitu. Single-line Composer Kompozytor jednowierszowy Toggle Line Composer Przełącz kompozytor wierszy @@ -4709,6 +4721,7 @@ imageTrace Outline Kontur @msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0} @msg not an EMF or WMF file to nie jest plik EMF ani WMF @msg not an HP-GL file to nie jest plik HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musi być tablicą od 1 do 255 kolorów #rrggbb @msg not found nie znaleziono @msg not in pattern editing mode nie jest w trybie edycji wzorka @msg finish editing the pattern first najpierw zakończ edycję wzorka @@ -6165,6 +6178,17 @@ axis Y Axis Oś Y Charge Wave Fala ładunku Spark Trail Ślad iskier Calligraphic Nibs Stalówki kaligraficzne + Bristle Brushes Pędzle włosiankowe + Fine Liner Cienka linia + Pointed Flat Szpiczasty płaski + Stubby Round Krótki okrągły + Dry Scrub Suche szorowanie + Soft Dome Miękka kopuła + Filbert Koci język + Angled Round Skośny okrągły + Angled Flat Skośny płaski + Fan Blender Wachlarz do mieszania + Grass Fan Wachlarz do trawy Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone Save Brush Library… Zapisz bibliotekę pędzli… @@ -6542,6 +6566,10 @@ breakText Glyphs Glify Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Obrysy używają co najmniej jednego z tych pędzli. Zdjąć pędzle z tych obrysów i usunąć pędzle? Remove Strokes Usuń obrysy Place Brush Art Umieść kompozycję pędzla + Replace Brush Art Zastąp kompozycję pędzla + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Obrysy używają tego pędzla. Zastosować nową kompozycję do tych obrysów, czy pozostawić je bez zmian i dodać kopię pędzla z nową kompozycją? + Apply to Strokes Zastosuj do obrysów + Leave Strokes Pozostaw obrysy @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nie ma kompozycji (mają ją tylko pędzle rozproszone, artystyczne i wzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Narzędzia układania @@ -6795,8 +6823,33 @@ breakText Glyphs Glify @plural {n} line cut|{n} lines cut {n} skrócony wiersz|{n} skrócone wiersze|{n} skróconych wierszy @msg `first` and `last` must be numbers of lines the console holds, first before last `first` i `last` muszą być numerami wierszy przechowywanych w konsoli, `first` przed `last` +# Learn panel + Learn Nauka + Explore tutorials Przeglądaj samouczki + For you Dla Ciebie + Skills Umiejętności + Get started Pierwsze kroki + Quick skill guide Krótki przewodnik po umiejętnościach + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Krótkie praktyczne samouczki dotyczące podstawowych narzędzi, pogrupowane według umiejętności. Wybierz umiejętność, aby zobaczyć jej samouczki. + List Tutorials Wyświetl samouczki + Show Tutorial Pokaż samouczek +@msg missing `tutorial` (see learn.list) brak `tutorial` (patrz learn.list) +@msg unknown tutorial `{id}` (see learn.list) nieznany samouczek `{id}` (patrz learn.list) + # Deferred plug-in effects Refresh Deferred Effects Odśwież odroczone efekty Deferred Effects Odroczone efekty @msg no plug-in effect is deferred żaden efekt wtyczki nie jest odroczony @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Otwarto z {n} odroczonym efektem wtyczki. Efekt › Odśwież odroczone efekty go uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia.|Otwarto z {n} odroczonymi efektami wtyczek. Efekt › Odśwież odroczone efekty je uruchamia. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Narzędzia do cięcia + Stencil Bridges… Mostki szablonu… + Stencil Bridges Mostki szablonu + Shortest Crossing Najkrótsze przejście + Direction Kierunek +@msg The selected art has no islands to bridge Zaznaczona grafika nie ma wysp do połączenia +@msg No bridge of this width fits across the selected art Żaden mostek o tej szerokości nie mieści się w zaznaczonej grafice +@msg Some islands could not be joined to the area around the art Niektórych wysp nie udało się połączyć z obszarem wokół grafiki +@msg `width` must be from {lo} to {hi} pt `width` musi mieścić się w zakresie od {lo} do {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musi być liczbą całkowitą od 1 do {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 32c4b4bcc0..906d4f1aee 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1046,6 +1046,8 @@ antiAlias Sharp Nítido Extrude & Bevel… Extrusão e Bisel… 3D (Classic) 3D (Clássico) Extrude & Bevel (Classic)… Extrusão e Bisel (Clássico)… + Revolve (Classic)… Revolver (Clássico)… + Rotate (Classic)… Girar (Clássico)… Surface Superfície Wireframe Aramado No Shading Sem Sombreamento @@ -1954,6 +1956,10 @@ antiAlias Sharp Nítido Arch Abóbada @msg select point type or type on a path selecione texto de ponto ou texto em um caminho @msg type on a path in a thread cannot use Dynamic Text: {frames} o texto em um caminho de um texto encadeado não pode usar Texto Dinâmico: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} o texto em um caminho aberto ou composto não pode ser centralizado em cima ou embaixo: {open} +@msg type on a path with no length or no height cannot be centered: {flat} o texto em um caminho sem comprimento ou sem altura não pode ser centralizado: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} o texto em um caminho de um texto encadeado não pode ser centralizado em cima ou embaixo: {frames} +@msg select type on a closed path selecione texto em um caminho fechado Optimize for fast web view Otimizar para visualização rápida na web Optimized Otimizado Options Opções @@ -1962,6 +1968,7 @@ antiAlias Sharp Nítido Order: Ordem: Ordinals Ordinais Organic Fades Esmaecimentos Orgânicos + Holographic Holográficos Orientation Orientação Orientation: Orientação: Original Original @@ -2444,6 +2451,9 @@ digits Malayalam Malaiala Rise… Rise… Roman Baseline Linha de Base Romana Roman Hanging Punctuation Pontuação Pendurada Romana + Korean Line Break Anywhere Quebra de Linha Coreana em Qualquer Lugar + East Asian Composition Settings Configurações de Composição do Leste Asiático + Korean Composition Settings Configurações de Composição Coreana Rotate Girar Rotate Tool Ferramenta Girar Rotate View Tool Ferramenta Girar Visualização @@ -3062,6 +3072,8 @@ digits Malayalam Malaiala Type becomes outlines, and strokes a CAD line can't draw become filled shapes. O texto vira contornos, e os traços que uma linha CAD não pode desenhar viram formas preenchidas. Type on a Path Texto em um Caminho Type on a Path Options… Opções de Texto em um Caminho… + Center on Top Centralizar em Cima + Center on Bottom Centralizar em Baixo Type on a Path Tool Ferramenta Texto em um Caminho Type stays text, and every stroke is a line with its lineweight and dashes. O texto permanece texto, e todo traço é uma linha com sua espessura e tracejados. Type: Tipo: @@ -4114,6 +4126,7 @@ axis Both Ambos @msg not a valid plug-in module: {0} não é um módulo de plug-in válido: {0} @msg not an EMF or WMF file não é um arquivo EMF ou WMF @msg not an HP-GL file não é um arquivo HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve ser uma matriz de 1 a 255 cores #rrggbb @msg not found não encontrado @msg not in pattern editing mode não está no modo de edição de padrão @msg finish editing the pattern first termine de editar o padrão primeiro @@ -6226,6 +6239,17 @@ axis Y Axis Eixo Y Charge Wave Onda de carga Spark Trail Rastro de faíscas Calligraphic Nibs Penas Caligráficas + Bristle Brushes Pincéis de Cerdas + Fine Liner Traço Fino + Pointed Flat Chato Pontudo + Stubby Round Redondo Curto + Dry Scrub Esfregado Seco + Soft Dome Cúpula Suave + Filbert Língua de Gato + Angled Round Redondo Chanfrado + Angled Flat Chato Chanfrado + Fan Blender Leque Esfumador + Grass Fan Leque de Grama Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão Save Brush Library… Salvar Biblioteca de Pincéis… @@ -6604,6 +6628,10 @@ breakText Glyphs Glifos Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Traçados usam um ou mais destes pincéis. Remover os pincéis desses traçados e excluir os pincéis? Remove Strokes Remover Traçados Place Brush Art Colocar Arte do Pincel + Replace Brush Art Substituir Arte do Pincel + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Traçados usam este pincel. Aplicar a nova arte a esses traçados, ou deixá-los como estão e adicionar uma cópia do pincel com a nova arte? + Apply to Strokes Aplicar aos Traçados + Leave Strokes Manter Traçados @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` não tem arte (só os pincéis de dispersão, de arte e de padrão têm) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ferramentas de Organização @@ -7012,8 +7040,33 @@ swatch Yellow Green Verde-amarelado @plural {n} line cut|{n} lines cut {n} linha cortada|{n} linhas cortadas @msg `first` and `last` must be numbers of lines the console holds, first before last `first` e `last` devem ser números de linhas que o console mantém, `first` antes de `last` +# Learn panel + Learn Aprender + Explore tutorials Explorar tutoriais + For you Para você + Skills Habilidades + Get started Primeiros passos + Quick skill guide Guia rápido de habilidades + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Tutoriais práticos curtos das ferramentas essenciais, agrupados por habilidade. Escolha uma habilidade para ver os tutoriais dela. + List Tutorials Listar Tutoriais + Show Tutorial Mostrar Tutorial +@msg missing `tutorial` (see learn.list) falta `tutorial` (veja learn.list) +@msg unknown tutorial `{id}` (see learn.list) tutorial desconhecido `{id}` (veja learn.list) + # Deferred plug-in effects Refresh Deferred Effects Atualizar Efeitos Adiados Deferred Effects Efeitos Adiados @msg no plug-in effect is deferred nenhum efeito de plug-in está adiado @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Aberto com {n} efeito de plug-in adiado. Efeito › Atualizar Efeitos Adiados o executa.|Aberto com {n} efeitos de plug-in adiados. Efeito › Atualizar Efeitos Adiados os executa. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Ferramentas de Corte + Stencil Bridges… Pontes de Estêncil… + Stencil Bridges Pontes de Estêncil + Shortest Crossing Travessia mais curta + Direction Direção +@msg The selected art has no islands to bridge A arte selecionada não tem ilhas para unir +@msg No bridge of this width fits across the selected art Nenhuma ponte desta largura cabe na arte selecionada +@msg Some islands could not be joined to the area around the art Algumas ilhas não puderam ser unidas à área ao redor da arte +@msg `width` must be from {lo} to {hi} pt `width` deve estar entre {lo} e {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve ser um número inteiro de 1 a {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 15d3c3a380..c147cf76df 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1151,6 +1151,8 @@ font-technology Variable Вариативные Extrude & Bevel… Выдавливание и фаска… 3D (Classic) 3D (классические) Extrude & Bevel (Classic)… Выдавливание и фаска (классическое)… + Revolve (Classic)… Вращение (классическое)… + Rotate (Classic)… Поворот (классический)… Surface Поверхность Wireframe Каркас No Shading Без затенения @@ -2383,6 +2385,10 @@ file-filter Templates Шаблоны Arch Арка @msg select point type or type on a path выберите точечный текст или текст по контуру @msg type on a path in a thread cannot use Dynamic Text: {frames} текст по контуру в связанном тексте не может использовать динамический текст: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} нельзя центрировать вверху или внизу текст по незамкнутому или составному контуру: {open} +@msg type on a path with no length or no height cannot be centered: {flat} нельзя центрировать текст по контуру без длины или без высоты: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} нельзя центрировать вверху или внизу текст по контуру в связанном тексте: {frames} +@msg select type on a closed path выделите текст по замкнутому контуру Optimize for fast web view Оптимизировать для быстрого просмотра в web Optimized Оптимизировано Options Параметры @@ -2390,6 +2396,7 @@ file-filter Templates Шаблоны Options: Параметры: Options… Параметры… Organic Fades Органические затухания + Holographic Голографические or more spaces with a tab или больше пробелов на табуляцию Order: Порядок: Ordinals Порядковые числительные @@ -2944,6 +2951,9 @@ digits Malayalam Малаялам Rise… Подъём… Roman Baseline Римская базовая линия Roman Hanging Punctuation Римская висячая пунктуация + Korean Line Break Anywhere Перенос корейского текста в любом месте + East Asian Composition Settings Параметры восточноазиатского набора + Korean Composition Settings Параметры корейского набора Rotate Повернуть Rotate 90° CW Повернуть на 90° по часовой стрелке Rotate Tool Инструмент «Поворот» @@ -3749,6 +3759,8 @@ digits Malayalam Малаялам Type on a Path Текст по контуру Type on a Path Options Параметры текста по контуру Type on a Path Options… Параметры текста по контуру… + Center on Top Центрировать вверху + Center on Bottom Центрировать внизу Type on a Path Tool Инструмент «Текст по контуру» Type Orientation Ориентация текста Type stays text, and every stroke is a line with its lineweight and dashes. Текст остаётся текстом, и каждая обводка — линия с её толщиной и штрихами. @@ -4613,6 +4625,7 @@ axis Both Оба @msg not a valid plug-in module: {0} не является допустимым модулем: {0} @msg not an EMF or WMF file не является файлом EMF или WMF @msg not an HP-GL file не является файлом HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` должен быть массивом из 1–255 цветов #rrggbb @msg not found не найдено @msg not in pattern editing mode не в режиме редактирования узора @msg finish editing the pattern first сначала завершите редактирование узора @@ -5922,6 +5935,17 @@ axis Y Axis Ось Y Charge Wave Волна заряда Spark Trail Искра-след Calligraphic Nibs Каллиграфические перья + Bristle Brushes Щетинные кисти + Fine Liner Тонкий лайнер + Pointed Flat Заострённая плоская + Stubby Round Короткая круглая + Dry Scrub Сухое растирание + Soft Dome Мягкий купол + Filbert Кошачий язык + Angled Round Скошенная круглая + Angled Flat Скошенная плоская + Fan Blender Веер для растушёвки + Grass Fan Веер для травы Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры Save Brush Library… Сохранить библиотеку кистей… @@ -6663,6 +6687,10 @@ breakText Glyphs Глифы Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обводки используют одну или несколько из этих кистей. Убрать кисти с этих обводок и удалить кисти? Remove Strokes Убрать обводки Place Brush Art Поместить рисунок кисти + Replace Brush Art Заменить рисунок кисти + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Обводки используют эту кисть. Применить новый рисунок к этим обводкам или оставить их как есть и добавить копию кисти с новым рисунком? + Apply to Strokes Применить к обводкам + Leave Strokes Оставить обводки @msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` нет рисунка (он есть только у кистей-распылений, художественных и узорных кистей) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменты размещения @@ -7028,8 +7056,33 @@ shape Open Открытый @plural {n} line cut|{n} lines cut {n} строка обрезана|{n} строки обрезаны|{n} строк обрезано @msg `first` and `last` must be numbers of lines the console holds, first before last `first` и `last` должны быть номерами строк, которые хранит консоль, причём `first` не позже `last` +# Learn panel + Learn Обучение + Explore tutorials Обзор уроков + For you Для вас + Skills Навыки + Get started Начало работы + Quick skill guide Краткое руководство по навыкам + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Короткие практические уроки по основным инструментам, сгруппированные по навыкам. Выберите навык, чтобы увидеть его уроки. + List Tutorials Перечислить уроки + Show Tutorial Показать урок +@msg missing `tutorial` (see learn.list) отсутствует `tutorial` (см. learn.list) +@msg unknown tutorial `{id}` (see learn.list) неизвестный урок `{id}` (см. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Обновить отложенные эффекты Deferred Effects Отложенные эффекты @msg no plug-in effect is deferred нет отложенных эффектов модулей @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Открыто с {n} отложенным эффектом модуля. Эффект › Обновить отложенные эффекты выполнит его.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их.|Открыто с {n} отложенными эффектами модулей. Эффект › Обновить отложенные эффекты выполнит их. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Инструменты резки + Stencil Bridges… Мостики трафарета… + Stencil Bridges Мостики трафарета + Shortest Crossing Кратчайшее пересечение + Direction Направление +@msg The selected art has no islands to bridge В выделенной графике нет островков для соединения +@msg No bridge of this width fits across the selected art Мостик такой ширины не помещается поперёк выделенной графики +@msg Some islands could not be joined to the area around the art Некоторые островки не удалось соединить с областью вокруг графики +@msg `width` must be from {lo} to {hi} pt `width` должно быть от {lo} до {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` должно быть целым числом от 1 до {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5ffc963fad..48d1cced76 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1132,6 +1132,8 @@ font-technology Variable Variabla Extrude & Bevel… Extrudera och fasa… 3D (Classic) 3D (klassisk) Extrude & Bevel (Classic)… Extrudera och fasa (klassisk)… + Revolve (Classic)… Rotera runt axel (klassisk)… + Rotate (Classic)… Rotera (klassisk)… Surface Yta Wireframe Trådram No Shading Ingen skuggning @@ -2297,6 +2299,10 @@ font-technology Variable Variabla Arch Valv @msg select point type or type on a path markera punkttext eller text på bana @msg type on a path in a thread cannot use Dynamic Text: {frames} text på bana i länkad text kan inte använda dynamisk text: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} text på en öppen eller sammansatt bana kan inte centreras överst eller nederst: {open} +@msg type on a path with no length or no height cannot be centered: {flat} text på en bana utan längd eller utan höjd kan inte centreras: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text på bana i länkad text kan inte centreras överst eller nederst: {frames} +@msg select type on a closed path markera text på en sluten bana Optimize for fast web view Optimera för snabb webbvisning Optimized Optimerad Options Alternativ @@ -2304,6 +2310,7 @@ font-technology Variable Variabla Options: Alternativ: Options… Alternativ… Organic Fades Organiska toningar + Holographic Holografiska or more spaces with a tab eller fler blanksteg med en tabb Order: Ordning: Ordinals Ordningstal @@ -2850,6 +2857,9 @@ digits Malayalam Malayalam Rise… Stigning… Roman Baseline Baslinje (roman) Roman Hanging Punctuation Hängande interpunktion (roman) + Korean Line Break Anywhere Koreansk radbrytning var som helst + East Asian Composition Settings Inställningar för östasiatisk sättning + Korean Composition Settings Inställningar för koreansk sättning Rotate Rotera Rotate 90° CW Rotera 90° medurs Rotate Tool Rotationsverktyg @@ -3638,6 +3648,8 @@ digits Malayalam Malayalam Type on a Path Text på bana Type on a Path Options Alternativ för text på bana Type on a Path Options… Alternativ för text på bana… + Center on Top Centrera överst + Center on Bottom Centrera nederst Type on a Path Tool Verktyg för text på bana Type Orientation Textorientering Type stays text, and every stroke is a line with its lineweight and dashes. Text förblir text, och varje kontur blir en linje med sin linjetjocklek och sina streck. @@ -4479,6 +4491,7 @@ axis Both Båda @msg not a valid plug-in module: {0} inte ett giltigt plugin: {0} @msg not an EMF or WMF file inte en EMF- eller WMF-fil @msg not an HP-GL file inte en HP-GL-fil +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` måste vara en array med 1 till 255 färger i formatet #rrggbb @msg not found hittades inte @msg not in pattern editing mode inte i läget för mönsterredigering @msg finish editing the pattern first slutför redigeringen av mönstret först @@ -6328,6 +6341,17 @@ axis Y Axis Y-axel Charge Wave Laddningsvåg Spark Trail Gnistsvår Calligraphic Nibs Kalligrafiska pennspetsar + Bristle Brushes Borstpenslar + Fine Liner Fin linje + Pointed Flat Spetsig platt + Stubby Round Kort rund + Dry Scrub Torr skrubbning + Soft Dome Mjuk kupol + Filbert Oval platt + Angled Round Sned rund + Angled Flat Sned platt + Fan Blender Blandande solfjäder + Grass Fan Grässolfjäder Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer Save Brush Library… Spara penselbibliotek… @@ -6731,6 +6755,10 @@ breakText Glyphs Glyfer Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturer använder en eller flera av de här penslarna. Ta bort penslarna från konturerna och radera penslarna? Remove Strokes Ta bort konturer Place Brush Art Placera penselgrafik + Replace Brush Art Ersätt penselgrafik + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Konturer använder den här penseln. Använd den nya grafiken på konturerna, eller låt dem vara som de är och lägg till en kopia av penseln med den nya grafiken? + Apply to Strokes Använd på konturer + Leave Strokes Behåll konturer @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (bara spridnings-, konst- och mönsterpenslar har grafik) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ordningsverktyg @@ -8417,8 +8445,33 @@ digits Hindi Hindi @plural {n} line cut|{n} lines cut {n} rad avkortad|{n} rader avkortade @msg `first` and `last` must be numbers of lines the console holds, first before last `first` och `last` måste vara nummer på rader som konsolen har kvar, `first` före `last` +# Learn panel + Learn Lär dig + Explore tutorials Utforska självstudier + For you För dig + Skills Färdigheter + Get started Kom igång + Quick skill guide Snabbguide till färdigheter + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Korta praktiska självstudier för de viktigaste verktygen, ordnade efter färdighet. Välj en färdighet för att se dess självstudier. + List Tutorials Lista självstudier + Show Tutorial Visa självstudie +@msg missing `tutorial` (see learn.list) `tutorial` saknas (se learn.list) +@msg unknown tutorial `{id}` (see learn.list) okänd självstudie `{id}` (se learn.list) + # Deferred plug-in effects Refresh Deferred Effects Uppdatera uppskjutna effekter Deferred Effects Uppskjutna effekter @msg no plug-in effect is deferred ingen plugineffekt är uppskjuten @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Öppnades med {n} uppskjuten plugineffekt. Effekt › Uppdatera uppskjutna effekter kör den.|Öppnades med {n} uppskjutna plugineffekter. Effekt › Uppdatera uppskjutna effekter kör dem. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Skärverktyg + Stencil Bridges… Schablonbryggor… + Stencil Bridges Schablonbryggor + Shortest Crossing Kortaste korsning + Direction Riktning +@msg The selected art has no islands to bridge Den markerade teckningen har inga öar att förbinda +@msg No bridge of this width fits across the selected art Ingen brygga med den här bredden får plats över den markerade teckningen +@msg Some islands could not be joined to the area around the art Vissa öar kunde inte förbindas med området runt teckningen +@msg `width` must be from {lo} to {hi} pt `width` måste vara från {lo} till {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` måste vara ett heltal från 1 till {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 86356d74ba..82fcc9364c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1124,6 +1124,8 @@ font-technology Variable Değişken Extrude & Bevel… Ekstrüzyon ve Eğim… 3D (Classic) 3D (Klasik) Extrude & Bevel (Classic)… Ekstrüzyon ve Eğim (Klasik)… + Revolve (Classic)… Döndür (Klasik)… + Rotate (Classic)… Çevir (Klasik)… Surface Yüzey Wireframe Tel Kafes No Shading Gölgeleme Yok @@ -2169,6 +2171,10 @@ shape Open Açık Arch Kemer @msg select point type or type on a path nokta metni veya yol üzerindeki yazıyı seçin @msg type on a path in a thread cannot use Dynamic Text: {frames} zincirli metindeki yol üzerindeki yazı dinamik metin kullanamaz: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} açık veya bileşik bir yol üzerindeki yazı üstte veya altta ortalanamaz: {open} +@msg type on a path with no length or no height cannot be centered: {flat} uzunluğu ya da yüksekliği olmayan yol üzerindeki yazı ortalanamaz: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} zincirli metindeki yol üzerindeki yazı üstte veya altta ortalanamaz: {frames} +@msg select type on a closed path kapalı bir yol üzerindeki yazıyı seçin Optimize for fast web view Hızlı web görünümü için optimize edin Optimized Optimize edilmiş Options Seçenekler @@ -2176,6 +2182,7 @@ shape Open Açık Options: Seçenekler: Options… Seçenekler… Organic Fades Organik Solmalar + Holographic Holografik or more spaces with a tab veya sekmeli daha fazla alan Order: Sipariş: Ordinals Sıra Sayıları @@ -2717,6 +2724,9 @@ digits Malayalam Malayalam Rise… Yüksel… Roman Baseline Roma Taban Çizgisi Roman Hanging Punctuation Roma Asılı Noktalama İşaretleri + Korean Line Break Anywhere Korece Satırı Her Yerde Kes + East Asian Composition Settings Doğu Asya Dizgi Ayarları + Korean Composition Settings Korece Dizgi Ayarları Rotate Döndür Rotate 90° CW 90° CW döndür Rotate Tool Döndürme Aracı @@ -3526,6 +3536,8 @@ digits Malayalam Malayalam Type on a Path Bir Yola Yazın Type on a Path Options Yol Seçenekleri Üzerine Yazma Type on a Path Options… Yol Seçenekleri Üzerine Yazma… + Center on Top Üstte ortala + Center on Bottom Altta ortala Type on a Path Tool Yola Yazma Aracı Type Orientation Tip Yönü Type stays text, and every stroke is a line with its lineweight and dashes. Yazı metin olarak kalır ve her kontur, çizgi kalınlığı ve tireleriyle birlikte bir çizgidir. @@ -4334,6 +4346,7 @@ axis Both Her İkisi @msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0} @msg not an EMF or WMF file EMF veya WMF dosyası değil @msg not an HP-GL file HP-GL dosyası değil +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır @msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor @msg not found bulunamadı @msg not in pattern editing mode desen düzenleme modunda değil @@ -6267,6 +6280,17 @@ axis Y Axis Y ekseni Charge Wave Şarj Dalgası Spark Trail Kıvılcım İzi Calligraphic Nibs Kaligrafi Uçları + Bristle Brushes Kıl Fırçalar + Fine Liner İnce Çizgi + Pointed Flat Sivri Düz + Stubby Round Kısa Yuvarlak + Dry Scrub Kuru Ovma + Soft Dome Yumuşak Kubbe + Filbert Kedi Dili + Angled Round Eğik Yuvarlak + Angled Flat Eğik Düz + Fan Blender Harmanlama Yelpazesi + Grass Fan Çim Yelpazesi Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri Save Brush Library… Fırça Kitaplığını Kaydet… @@ -6642,6 +6666,10 @@ breakText Glyphs Glifler Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturlar bu fırçalardan birini veya daha fazlasını kullanıyor. Fırçalar bu konturlardan kaldırılsın ve fırçalar silinsin mi? Remove Strokes Konturları Kaldır Place Brush Art Fırça Çizimini Yerleştir + Replace Brush Art Fırça Çizimini Değiştir + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Konturlar bu fırçayı kullanıyor. Yeni çizim bu konturlara uygulansın mı, yoksa konturlar olduğu gibi bırakılıp fırçanın yeni çizimli bir kopyası mı eklensin? + Apply to Strokes Konturlara Uygula + Leave Strokes Konturları Bırak @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` fırçasının çizimi yok (yalnızca serpme, resim ve desen fırçalarının çizimi vardır) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Düzenleme Araçları @@ -6895,8 +6923,33 @@ breakText Glyphs Glifler @plural {n} line cut|{n} lines cut {n} satır kesildi @msg `first` and `last` must be numbers of lines the console holds, first before last `first` ve `last`, konsolun tuttuğu satırların numaraları olmalı ve `first`, `last` değerinden önce gelmeli +# Learn panel + Learn Öğren + Explore tutorials Öğreticileri Keşfet + For you Sizin için + Skills Beceriler + Get started Başlarken + Quick skill guide Hızlı beceri rehberi + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Temel araçlar için becerilere göre gruplanmış kısa uygulamalı öğreticiler. Öğreticilerini görmek için bir beceri seçin. + List Tutorials Öğreticileri Listele + Show Tutorial Öğreticiyi Göster +@msg missing `tutorial` (see learn.list) `tutorial` eksik (bkz. learn.list) +@msg unknown tutorial `{id}` (see learn.list) bilinmeyen öğretici `{id}` (bkz. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Ertelenen Efektleri Yenile Deferred Effects Ertelenen Efektler @msg no plug-in effect is deferred ertelenen eklenti efekti yok @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. {n} eklenti efekti ertelenerek açıldı. Efekt › Ertelenen Efektleri Yenile bunları çalıştırır. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Kesim Araçları + Stencil Bridges… Şablon Köprüleri… + Stencil Bridges Şablon Köprüleri + Shortest Crossing En kısa geçiş + Direction Yön +@msg The selected art has no islands to bridge Seçili çizimde köprüyle bağlanacak ada yok +@msg No bridge of this width fits across the selected art Bu genişlikte bir köprü seçili çizime sığmıyor +@msg Some islands could not be joined to the area around the art Bazı adalar çizimin çevresindeki alana bağlanamadı +@msg `width` must be from {lo} to {hi} pt `width` {lo} ile {hi} pt arasında olmalıdır +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 ile {MAX_BRIDGES_PER_ISLAND} arasında bir tam sayı olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ebba0f5c19..d91ba40226 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1156,6 +1156,8 @@ font-technology Variable Варіативні Extrude & Bevel… Видавлювання та фаска… 3D (Classic) 3D (класичні) Extrude & Bevel (Classic)… Видавлювання та фаска (класичне)… + Revolve (Classic)… Обертання (класичне)… + Rotate (Classic)… Поворот (класичний)… Surface Поверхня Wireframe Каркас No Shading Без затінення @@ -2388,6 +2390,10 @@ file-filter Templates Шаблони Arch Арка @msg select point type or type on a path виберіть точковий текст або текст за контуром @msg type on a path in a thread cannot use Dynamic Text: {frames} текст за контуром у пов’язаному тексті не може використовувати динамічний текст: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} не можна центрувати вгорі чи внизу текст за незамкненим або складеним контуром: {open} +@msg type on a path with no length or no height cannot be centered: {flat} не можна центрувати текст за контуром без довжини або без висоти: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} не можна центрувати вгорі чи внизу текст за контуром у пов’язаному тексті: {frames} +@msg select type on a closed path виділіть текст за замкненим контуром Optimize for fast web view Оптимізувати для швидкого перегляду web Optimized Оптимізовано Options Параметри @@ -2395,6 +2401,7 @@ file-filter Templates Шаблони Options: Параметри: Options… Параметри… Organic Fades Органічні згасання + Holographic Голографічні or more spaces with a tab або більше прогалин на табуляцію Order: Порядок: Ordinals Порядкові числівники @@ -2949,6 +2956,9 @@ digits Malayalam Малаялам Rise… Підйом… Roman Baseline Римська базова лінія Roman Hanging Punctuation Римська висяча пунктуація + Korean Line Break Anywhere Перенесення корейського тексту будь-де + East Asian Composition Settings Параметри східноазійського набору + Korean Composition Settings Параметри корейського набору Rotate Повернути Rotate 90° CW Повернути на 90° за годинниковою стрілкою Rotate Tool Інструмент «Поворот» @@ -3756,6 +3766,8 @@ effect Points Точки Type on a Path Текст за контуром Type on a Path Options Параметри тексту за контуром Type on a Path Options… Параметри тексту за контуром… + Center on Top Центрувати вгорі + Center on Bottom Центрувати внизу Type on a Path Tool Інструмент «Текст за контуром» Type Orientation Орієнтація тексту Type stays text, and every stroke is a line with its lineweight and dashes. Текст залишається текстом, і кожне обведення — лінія з її товщиною та штрихами. @@ -4611,6 +4623,7 @@ axis Both Обидві @msg not a valid plug-in module: {0} не є допустимим модулем: {0} @msg not an EMF or WMF file не є файлом EMF або WMF @msg not an HP-GL file не є файлом HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` має бути масивом із 1–255 кольорів #rrggbb @msg not found не знайдено @msg not in pattern editing mode не в режимі редагування візерунка @msg finish editing the pattern first спочатку завершіть редагування візерунка @@ -5916,6 +5929,17 @@ axis Y Axis Вісь Y Charge Wave Хвиля заряду Spark Trail Слід іскор Calligraphic Nibs Каліграфічні пера + Bristle Brushes Щетинні пензлі + Fine Liner Тонкий лайнер + Pointed Flat Загострений плаский + Stubby Round Короткий круглий + Dry Scrub Сухе розтирання + Soft Dome М'який купол + Filbert Котячий язик + Angled Round Скошений круглий + Angled Flat Скошений плаский + Fan Blender Віяло для розтушовування + Grass Fan Віяло для трави Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури Save Brush Library… Зберегти бібліотеку пензлів… @@ -6295,6 +6319,10 @@ breakText Glyphs Гліфи Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обведення використовують один або кілька з цих пензлів. Забрати пензлі з цих обведень і видалити пензлі? Remove Strokes Забрати обведення Place Brush Art Розмістити малюнок пензля + Replace Brush Art Замінити малюнок пензля + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Обведення використовують цей пензель. Застосувати новий малюнок до цих обведень чи залишити їх як є і додати копію пензля з новим малюнком? + Apply to Strokes Застосувати до обведень + Leave Strokes Залишити обведення @msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` немає малюнка (він є лише в пензлів-розпорошень, художніх і візерункових пензлів) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Інструменти розміщення @@ -6548,8 +6576,33 @@ breakText Glyphs Гліфи @plural {n} line cut|{n} lines cut {n} рядок обрізано|{n} рядки обрізано|{n} рядків обрізано @msg `first` and `last` must be numbers of lines the console holds, first before last `first` і `last` мають бути номерами рядків, які зберігає консоль, причому `first` не пізніше `last` +# Learn panel + Learn Навчання + Explore tutorials Переглянути уроки + For you Для вас + Skills Навички + Get started Початок роботи + Quick skill guide Короткий посібник із навичок + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Короткі практичні уроки з основних інструментів, згруповані за навичками. Виберіть навичку, щоб побачити її уроки. + List Tutorials Перелічити уроки + Show Tutorial Показати урок +@msg missing `tutorial` (see learn.list) відсутній `tutorial` (див. learn.list) +@msg unknown tutorial `{id}` (see learn.list) невідомий урок `{id}` (див. learn.list) + # Deferred plug-in effects Refresh Deferred Effects Оновити відкладені ефекти Deferred Effects Відкладені ефекти @msg no plug-in effect is deferred немає відкладених ефектів модулів @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Відкрито з {n} відкладеним ефектом модуля. Ефект › Оновити відкладені ефекти виконає його.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх.|Відкрито з {n} відкладеними ефектами модулів. Ефект › Оновити відкладені ефекти виконає їх. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Інструменти різання + Stencil Bridges… Містки трафарету… + Stencil Bridges Містки трафарету + Shortest Crossing Найкоротший перетин + Direction Напрямок +@msg The selected art has no islands to bridge У виділеній графіці немає острівців для з’єднання +@msg No bridge of this width fits across the selected art Місток такої ширини не вміщується впоперек виділеної графіки +@msg Some islands could not be joined to the area around the art Деякі острівці не вдалося з’єднати з областю навколо графіки +@msg `width` must be from {lo} to {hi} pt `width` має бути від {lo} до {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` має бути цілим числом від 1 до {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0ec0fd4e40..b14c946cb8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1329,6 +1329,8 @@ font-technology Variable Biến thiên Extrude & Bevel… Kéo dài & vát… 3D (Classic) 3D (cổ điển) Extrude & Bevel (Classic)… Kéo dài & vát (cổ điển)… + Revolve (Classic)… Xoay tròn (cổ điển)… + Rotate (Classic)… Xoay (cổ điển)… Surface Bề mặt Wireframe Khung dây No Shading Không tô bóng @@ -2673,6 +2675,10 @@ digits Hindi Hindi Arch Vòm @msg select point type or type on a path chọn chữ điểm hoặc chữ trên đường dẫn @msg type on a path in a thread cannot use Dynamic Text: {frames} chữ trên đường dẫn trong văn bản chuỗi không thể dùng văn bản động: {frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn mở hoặc đường dẫn phức: {open} +@msg type on a path with no length or no height cannot be centered: {flat} không thể căn giữa chữ trên đường dẫn không có độ dài hoặc không có chiều cao: {flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn trong văn bản chuỗi: {frames} +@msg select type on a closed path hãy chọn chữ trên một đường dẫn khép kín Optimize for fast web view Tối ưu để xem web nhanh Optimized Đã tối ưu Options Tùy chọn @@ -2972,6 +2978,7 @@ mark-style Japanese Kiểu Nhật Radial Dividers: Đường chia hướng tâm: Radial Glows Ánh sáng hướng tâm Organic Fades Mờ dần tự nhiên + Holographic Ảnh ba chiều Radial Gradient Chuyển sắc hướng tâm Conic Gradient Chuyển sắc hình nón Diamond Gradient Chuyển sắc hình thoi @@ -3253,6 +3260,9 @@ mark-style Japanese Kiểu Nhật Rise… Nâng lên… Roman Baseline Đường cơ sở Roman Roman Hanging Punctuation Dấu câu treo Roman + Korean Line Break Anywhere Ngắt dòng tiếng Hàn ở bất kỳ đâu + East Asian Composition Settings Cài đặt dàn trang Đông Á + Korean Composition Settings Cài đặt dàn trang tiếng Hàn Rotate Xoay Rotate 90° CW Xoay 90° theo chiều kim đồng hồ Rotate Tool Công cụ Xoay @@ -4139,6 +4149,8 @@ mark-style Japanese Kiểu Nhật Type on a Path Chữ trên đường dẫn Type on a Path Options Tùy chọn chữ trên đường dẫn Type on a Path Options… Tùy chọn chữ trên đường dẫn… + Center on Top Căn giữa ở trên + Center on Bottom Căn giữa ở dưới Type on a Path Tool Công cụ Chữ trên đường dẫn Type Orientation Hướng chữ Type stays text, and every stroke is a line with its lineweight and dashes. Chữ vẫn là văn bản, và mỗi nét là đường thẳng với độ dày và nét đứt. @@ -4924,6 +4936,7 @@ theme Appearance Giao diện @msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được. @msg not an EMF or WMF file không phải tệp EMF hoặc WMF @msg not an HP-GL file không phải tệp HP-GL +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` phải là mảng từ 1 đến 255 màu #rrggbb @msg not found không tìm thấy @msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết @msg finish editing the pattern first hãy hoàn tất chỉnh sửa họa tiết trước @@ -5917,6 +5930,17 @@ axis Y Axis Trục Y Charge Wave Sóng nạp Spark Trail Vệt tia lửa Calligraphic Nibs Ngòi bút thư pháp + Bristle Brushes Bộ cọ lông + Fine Liner Nét mảnh + Pointed Flat Cọ dẹt nhọn + Stubby Round Cọ tròn ngắn + Dry Scrub Chà khô + Soft Dome Vòm mềm + Filbert Cọ lưỡi mèo + Angled Round Cọ tròn xiên + Angled Flat Cọ dẹt xiên + Fan Blender Cọ quạt hòa trộn + Grass Fan Cọ quạt vẽ cỏ Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán Save Brush Library… Lưu thư viện cọ… @@ -6296,6 +6320,10 @@ breakText Glyphs Glyph Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Có nét đang dùng một hoặc nhiều cọ này. Gỡ cọ khỏi các nét đó và xóa cọ? Remove Strokes Gỡ nét Place Brush Art Đặt đối tượng của cọ + Replace Brush Art Thay đối tượng của cọ + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? Có nét đang dùng cọ này. Áp dụng đối tượng mới cho các nét đó, hay giữ nguyên chúng và thêm một bản sao của cọ với đối tượng mới? + Apply to Strokes Áp dụng cho nét + Leave Strokes Giữ nguyên nét @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` không có đối tượng (chỉ cọ phân tán, cọ bản vẽ và cọ họa tiết mới có) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Công cụ sắp xếp @@ -6549,8 +6577,33 @@ breakText Glyphs Glyph @plural {n} line cut|{n} lines cut đã cắt {n} dòng @msg `first` and `last` must be numbers of lines the console holds, first before last `first` và `last` phải là số thứ tự của các dòng mà bảng điều khiển đang giữ, `first` đứng trước `last` +# Learn panel + Learn Học + Explore tutorials Khám phá hướng dẫn + For you Dành cho bạn + Skills Kỹ năng + Get started Bắt đầu + Quick skill guide Hướng dẫn nhanh về kỹ năng + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. Các hướng dẫn thực hành ngắn cho những công cụ thiết yếu, xếp theo kỹ năng. Chọn một kỹ năng để xem các hướng dẫn của nó. + List Tutorials Liệt kê hướng dẫn + Show Tutorial Hiện hướng dẫn +@msg missing `tutorial` (see learn.list) thiếu `tutorial` (xem learn.list) +@msg unknown tutorial `{id}` (see learn.list) hướng dẫn không xác định `{id}` (xem learn.list) + # Deferred plug-in effects Refresh Deferred Effects Làm mới hiệu ứng bị hoãn Deferred Effects Hiệu ứng bị hoãn @msg no plug-in effect is deferred không có hiệu ứng plug-in nào bị hoãn @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. Đã mở với {n} hiệu ứng plug-in bị hoãn. Hiệu ứng › Làm mới hiệu ứng bị hoãn sẽ chạy chúng. + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools Công cụ cắt + Stencil Bridges… Cầu nối khuôn tô… + Stencil Bridges Cầu nối khuôn tô + Shortest Crossing Đường cắt ngắn nhất + Direction Hướng +@msg The selected art has no islands to bridge Hình vẽ đã chọn không có đảo nào cần nối +@msg No bridge of this width fits across the selected art Không có cầu nối nào rộng như vậy vừa với hình vẽ đã chọn +@msg Some islands could not be joined to the area around the art Không thể nối một số đảo với vùng xung quanh hình vẽ +@msg `width` must be from {lo} to {hi} pt `width` phải từ {lo} đến {hi} pt +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` phải là số nguyên từ 1 đến {MAX_BRIDGES_PER_ISLAND} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9fc1cdc4e8..4f34af93dd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1094,6 +1094,8 @@ antiAlias Sharp 锐利 Extrude & Bevel… 突出与斜角… 3D (Classic) 3D (经典) Extrude & Bevel (Classic)… 突出与斜角 (经典)… + Revolve (Classic)… 回转 (经典)… + Rotate (Classic)… 旋转 (经典)… Surface 表面 Wireframe 线框 No Shading 无底纹 @@ -2256,12 +2258,17 @@ file-filter Templates 模板 Arch 拱形 @msg select point type or type on a path 请选择点文字或路径文字 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串接文本中的路径文字不能使用动态文本:{frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 开放路径或复合路径上的路径文字无法在顶部或底部居中:{open} +@msg type on a path with no length or no height cannot be centered: {flat} 路径没有长度或没有高度时,其路径文字无法居中:{flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串接文本中的路径文字无法在顶部或底部居中:{frames} +@msg select type on a closed path 请选择闭合路径上的路径文字 Optimize for fast web view 优化快速 Web 查看 Optimized 优化 Options 选项 Options: 选项: Options… 选项… Organic Fades 自然渐隐 + Holographic 全息 or more spaces with a tab 或多个空格加一个制表符 Order: 顺序: Ordinals 序数字 @@ -2813,6 +2820,9 @@ digits Malayalam 马拉雅拉姆数字 Rise… 上升… Roman Baseline 罗马基线 Roman Hanging Punctuation 西文悬挂标点 + Korean Line Break Anywhere 韩文任意位置换行 + East Asian Composition Settings 东亚排版设置 + Korean Composition Settings 韩文排版设置 Rotate 旋转 Rotate 90° CW 顺时针旋转 90° Rotate Tool 旋转工具 @@ -3583,6 +3593,8 @@ digits Malayalam 马拉雅拉姆数字 Type on a Path 路径文字 Type on a Path Options 路径文字选项 Type on a Path Options… 路径文字选项… + Center on Top 在顶部居中 + Center on Bottom 在底部居中 Type on a Path Tool 路径文字工具 Type Orientation 文字方向 Type stays text, and every stroke is a line with its lineweight and dashes. 文字维持为文字,每个描边都是带有线宽与虚线的线条。 @@ -4395,6 +4407,7 @@ imageTrace Outline 轮廓 @msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图 @msg not an EMF or WMF file 不是 EMF 或 WMF 文件 @msg not an HP-GL file 不是 HP-GL 文件 +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必须是由 1 到 255 个 #rrggbb 格式颜色组成的数组 @msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取 @msg the EMF header is damaged EMF 文件头已损坏 @msg damaged 已损坏 @@ -6149,6 +6162,17 @@ axis Y Axis Y 轴 Charge Wave 充能波 Spark Trail 火花尾迹 Calligraphic Nibs 书法笔尖 + Bristle Brushes 鬃毛画笔集 + Fine Liner 细勾线笔 + Pointed Flat 尖头平笔 + Stubby Round 短圆笔 + Dry Scrub 干擦 + Soft Dome 柔和圆顶 + Filbert 榛形笔 + Angled Round 斜圆笔 + Angled Flat 斜平笔 + Fan Blender 混色扇形笔 + Grass Fan 画草扇形笔 Pattern Borders 图案边框 Scatter Shapes 散布形状 Save Brush Library… 保存画笔库… @@ -6527,6 +6551,10 @@ breakText Glyphs 字形 Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有描边使用了其中一个或多个画笔。要从这些描边移除画笔并删除画笔吗? Remove Strokes 移除描边 Place Brush Art 置入画笔图稿 + Replace Brush Art 替换画笔图稿 + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? 有描边使用了此画笔。要将新图稿应用到这些描边,还是保留描边不变并添加一个使用新图稿的画笔副本? + Apply to Strokes 应用于描边 + Leave Strokes 保留描边 @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 没有图稿 (只有散布画笔、艺术画笔和图案画笔有图稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 @@ -6777,8 +6805,33 @@ breakText Glyphs 字形 @plural {n} line cut|{n} lines cut 已截断 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必须是控制台保留的行号,且 `first` 不能在 `last` 之后 +# Learn panel + Learn 学习 + Explore tutorials 浏览教程 + For you 为你推荐 + Skills 技能 + Get started 入门 + Quick skill guide 快速技能指南 + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 针对基本工具的简短实操教程,按技能分组。选择一项技能即可查看其教程。 + List Tutorials 列出教程 + Show Tutorial 显示教程 +@msg missing `tutorial` (see learn.list) 缺少 `tutorial` (请参阅 learn.list) +@msg unknown tutorial `{id}` (see learn.list) 未知教程 `{id}` (请参阅 learn.list) + # Deferred plug-in effects Refresh Deferred Effects 刷新延迟的效果 Deferred Effects 延迟的效果 @msg no plug-in effect is deferred 没有延迟的增效工具效果 @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 打开时延迟了 {n} 个增效工具效果。效果 › 刷新延迟的效果可运行它们。 + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools 切割工具 + Stencil Bridges… 镂空版连接桥… + Stencil Bridges 镂空版连接桥 + Shortest Crossing 最短跨越 + Direction 方向 +@msg The selected art has no islands to bridge 所选图稿没有需要连接的孤岛 +@msg No bridge of this width fits across the selected art 此宽度的连接桥无法放入所选图稿 +@msg Some islands could not be joined to the area around the art 部分孤岛无法连接到图稿周围的区域 +@msg `width` must be from {lo} to {hi} pt `width` 必须介于 {lo} 到 {hi} pt 之间 +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必须是 1 到 {MAX_BRIDGES_PER_ISLAND} 之间的整数 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 685f7887fb..5b89ecda53 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1126,6 +1126,8 @@ font-technology Variable 可變 Extrude & Bevel… 突出與斜角… 3D (Classic) 3D (經典) Extrude & Bevel (Classic)… 突出與斜角 (經典)… + Revolve (Classic)… 迴轉 (經典)… + Rotate (Classic)… 旋轉 (經典)… Surface 表面 Wireframe 線框 No Shading 無網底 @@ -2193,6 +2195,7 @@ file-filter Templates 範本 @msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖 @msg not an EMF or WMF file 不是 EMF 或 WMF 檔案 @msg not an HP-GL file 不是 HP-GL 檔案 +@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必須是由 1 到 255 個 #rrggbb 格式顏色組成的陣列 @msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取 @msg the EMF header is damaged EMF 檔頭已損毀 @msg damaged 已損毀 @@ -3356,6 +3359,10 @@ file-filter Templates 範本 Arch 拱形 @msg select point type or type on a path 請選取點狀文字或路徑文字 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串連文字中的路徑文字無法使用動態文字:{frames} +@msg type on an open or compound path cannot be centered on its top or bottom: {open} 開放路徑或複合路徑上的路徑文字無法在頂端或底端置中:{open} +@msg type on a path with no length or no height cannot be centered: {flat} 路徑沒有長度或沒有高度時,其路徑文字無法置中:{flat} +@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串連文字中的路徑文字無法在頂端或底端置中:{frames} +@msg select type on a closed path 請選取封閉路徑上的路徑文字 Optimize for fast web view 最佳化以便快速網頁檢視 Optimized 最佳化 Options 選項 @@ -3363,6 +3370,7 @@ file-filter Templates 範本 Options: 選項: Options… 選項… Organic Fades 自然漸隱 + Holographic 全像 or more spaces with a tab 個以上的空格取代為定位字元 Order: 順序: Ordinals 序數字 @@ -3921,6 +3929,9 @@ digits Malayalam 馬來亞拉姆數字 Rise… 上升… Roman Baseline 羅馬基線 Roman Hanging Punctuation 羅馬懸掛標點 + Korean Line Break Anywhere 韓文任意位置換行 + East Asian Composition Settings 東亞排版設定 + Korean Composition Settings 韓文排版設定 Rotate 旋轉 Rotate 90° CW 順時針旋轉 90° Rotate Tool 旋轉工具 @@ -4728,6 +4739,8 @@ digits Malayalam 馬來亞拉姆數字 Type on a Path 路徑文字 Type on a Path Options 路徑文字選項 Type on a Path Options… 路徑文字選項… + Center on Top 在頂端置中 + Center on Bottom 在底端置中 Type on a Path Tool 路徑文字工具 Type Orientation 文字方向 Type stays text, and every stroke is a line with its lineweight and dashes. 文字維持為文字,每個筆畫都是帶有線寬與虛線的線條。 @@ -5954,6 +5967,17 @@ axis Y Axis Y 軸 Charge Wave 充能波 Spark Trail 火花尾跡 Calligraphic Nibs 書法筆尖 + Bristle Brushes 鬃毛筆刷集 + Fine Liner 細勾線筆 + Pointed Flat 尖頭平筆 + Stubby Round 短圓筆 + Dry Scrub 乾擦 + Soft Dome 柔和圓頂 + Filbert 榛形筆 + Angled Round 斜圓筆 + Angled Flat 斜平筆 + Fan Blender 混色扇形筆 + Grass Fan 畫草扇形筆 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 Save Brush Library… 儲存筆刷資料庫… @@ -6333,6 +6357,10 @@ breakText Glyphs 字符 Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有筆畫使用了其中一個或多個筆刷。要從這些筆畫移除筆刷並刪除筆刷嗎? Remove Strokes 移除筆畫 Place Brush Art 置入筆刷圖稿 + Replace Brush Art 取代筆刷圖稿 + Strokes use this brush. Apply the new art to those strokes, or leave them as they are and add a copy of the brush with the new art? 有筆畫使用了此筆刷。要將新圖稿套用到這些筆畫,還是保留筆畫不變並加入一個使用新圖稿的筆刷複本? + Apply to Strokes 套用至筆畫 + Leave Strokes 保留筆畫 @msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 沒有圖稿 (只有散佈筆刷、藝術筆刷和圖樣筆刷有圖稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 @@ -6583,8 +6611,33 @@ breakText Glyphs 字符 @plural {n} line cut|{n} lines cut 已截斷 {n} 行 @msg `first` and `last` must be numbers of lines the console holds, first before last `first` 和 `last` 必須是主控台保留的行號,且 `first` 不能在 `last` 之後 +# Learn panel + Learn 學習 + Explore tutorials 瀏覽教學課程 + For you 為你推薦 + Skills 技能 + Get started 入門 + Quick skill guide 快速技能指南 + Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials. 針對基本工具的簡短實作教學課程,依技能分組。選擇一項技能即可查看其教學課程。 + List Tutorials 列出教學課程 + Show Tutorial 顯示教學課程 +@msg missing `tutorial` (see learn.list) 未提供 `tutorial` (請參閱 learn.list) +@msg unknown tutorial `{id}` (see learn.list) 未知的教學課程 `{id}` (請參閱 learn.list) + # Deferred plug-in effects Refresh Deferred Effects 重新整理延後的效果 Deferred Effects 延後的效果 @msg no plug-in effect is deferred 沒有延後的增效模組效果 @msgplural Opened with {n} plug-in effect deferred. Effect › Refresh Deferred Effects runs it.|Opened with {n} plug-in effects deferred. Effect › Refresh Deferred Effects runs them. 開啟時延後了 {n} 個增效模組效果。效果 › 重新整理延後的效果可執行它們。 + +# Cutting Tools add-on (Object › Plug-ins › Cutting Tools). + Cutting Tools 切割工具 + Stencil Bridges… 鏤空版連接橋… + Stencil Bridges 鏤空版連接橋 + Shortest Crossing 最短跨越 + Direction 方向 +@msg The selected art has no islands to bridge 所選圖稿沒有需要連接的孤島 +@msg No bridge of this width fits across the selected art 此寬度的連接橋無法放入所選圖稿 +@msg Some islands could not be joined to the area around the art 部分孤島無法連接到圖稿周圍的區域 +@msg `width` must be from {lo} to {hi} pt `width` 必須介於 {lo} 到 {hi} pt 之間 +@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必須是介於 1 到 {MAX_BRIDGES_PER_ISLAND} 的整數 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 8de3f5e579..f521e45c22 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -197,6 +197,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("flip-vertical-2", include_bytes!("../../../assets/icons/flip-vertical-2.svg")), ("folder-open", include_bytes!("../../../assets/icons/folder-open.svg")), ("frame", include_bytes!("../../../assets/icons/frame.svg")), + ("graduation-cap", include_bytes!("../../../assets/icons/graduation-cap.svg")), ("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")), ("group", include_bytes!("../../../assets/icons/group.svg")), ("hand", include_bytes!("../../../assets/icons/hand.svg")), diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 7e5bc01380..03e0196267 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -1,6 +1,7 @@ use super::save::{ - ask_before_losing, export_path, is_web, notes_of, remember_active_artboard, report_saved, report_write_error, saved_status, show_with_notes, - target_path, target_path_keeping, view_file, write_encoded, write_named, write_reporting, + ask_before_losing, ask_to_replace_files, checks_replaced, export_path, is_web, notes_of, remember_active_artboard, replaced_files, report_saved, + report_write_error, saved_status, show_with_notes, target_path, target_path_keeping, view_file, with_path, write_encoded, write_named, + write_reporting, }; use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress}; @@ -44,6 +45,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option std::sync::Arc::new(d), }; let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); + let uid = st.uid; let path = export_path(app, path, f)?; let mut again = params.clone(); if let Some(o) = again.as_object_mut() { @@ -68,11 +70,18 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option 1 { @@ -90,6 +99,11 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option = files.iter().filter_map(Value::as_str).map(str::to_string).collect(); + return Ok(ask_to_replace_files(app, uid, &files, &allowed_before, "file.exportAs", &rerun)); + } let what = match r["files"].as_array() { Some(files) => format!("{} files", files.len()), None => r["path"].as_str().unwrap_or_default().to_string(), diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 42b76ce2d8..d9d475e76f 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -264,7 +264,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } - return write_plan(app, first); + return write_plan(app, first, (save_command(mode), with_path(p, &path))); } if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); @@ -291,7 +291,42 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo crate::dialogs::opened_by_panel(app); return r; } - write_plan(app, chosen) + write_plan(app, chosen, (save_command(mode), q)) +} + +/// `p` (an object, else none) with `path`. +pub(super) fn with_path(p: &Value, path: &str) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["path"] = json!(path); + p +} + +/// The files among `paths` that a write to `path` replaces although the save panel asked only +/// about `path`: every other one that `exists` finds, but those in `known`. None without `exists`. +pub(super) fn replaced_files<'a>( + exists: Option<&crate::ExistsFn>, + path: &str, + known: &[String], + paths: impl IntoIterator, +) -> Vec { + let Some(exists) = exists else { return Vec::new() }; + paths.into_iter().filter(|p| *p != path && !known.iter().any(|k| k == p) && exists(p)).map(str::to_string).collect() +} + +/// The `exists` service with which a save or export checks the files it writes beside the one +/// picked ([`replaced_files`]), none on the web (downloads), and the files it replaces without +/// asking: those the OK of the question about them allowed while it runs that again. +pub(super) fn checks_replaced(app: &VectorcraftApp) -> (Option, Vec) { + (app.services.exists.clone().filter(|_| !is_web(app)), app.picks.replace_files_ok.clone()) +} + +/// Ask before a save or export of the document `doc` (its uid) replaces `files`, files that are +/// there beside the one picked in the save panel (a file per artboard, a linked image), which the +/// panel did not ask about. OK runs `command` with `params` again, replacing them and `allowed` +/// (those an earlier OK allowed); Cancel writes nothing → `{pending}`. +pub(super) fn ask_to_replace_files(app: &mut VectorcraftApp, doc: u64, files: &[String], allowed: &[String], command: &str, params: &Value) -> Value { + dialogs::confirm::ask_replace_files(app, doc, files, allowed, command, params.clone()); + json!({ "pending": dialogs::confirm::REPLACE_FILES }) } /// The UI command that runs a save in `mode`. @@ -433,7 +468,10 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String { /// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once /// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format /// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { +/// A file it writes beside the one at its path that is there already, and that the document's last +/// save did not write, is asked about before anything is written; `again` (a command and its params) +/// runs the save again once the question's OK allows it ([`ask_to_replace_files`]). +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, again: (&'static str, Value)) -> Result { let uid = app.session.active().ok_or("no document")?.uid; // Saves of one document finish in order. if app.background.busy_with(uid) { @@ -461,10 +499,19 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let done = job.clone(); // A native file streams into the file as it is encoded (desktop; not to a download). let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); + let (exists, allowed) = checks_replaced(app); + let known: Vec = allowed.iter().chain(app.picks.written_beside.get(&uid).into_iter().flatten()).cloned().collect(); let work = move |write: Writer| { match stream { Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + None => job.write_unless( + |paths| { + let path = job.plan().path.as_deref().unwrap_or_default(); + let replaced = replaced_files(exists.as_ref(), path, &known, paths.iter().copied()); + (!replaced.is_empty()).then(|| json!({ "replaces": replaced })) + }, + |p, b| write(p, b).map_err(EngineError::Other), + ), } .map_err(|e| e.to_string()) }; @@ -472,11 +519,20 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let then = move |app: &mut VectorcraftApp, r: Result| { // Not written: the document keeps its file, format and modified state, and the user is told. let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + // Nothing written either: asked first. + if let Some(files) = r.get("replaces").and_then(Value::as_array) { + let files: Vec = files.iter().filter_map(Value::as_str).map(str::to_string).collect(); + return Ok(ask_to_replace_files(app, uid, &files, &allowed, again.0, &again.1)); + } // The document may have closed meanwhile: the file is written all the same. done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); if retargets { note_recent(app, &path); + // The files written beside the document's file, which its next save replaces. + let beside = ["files", "linked"].iter().flat_map(|k| r[*k].as_array().into_iter().flatten()).filter_map(Value::as_str); + let beside: Vec = beside.filter(|p| *p != path).map(str::to_string).collect(); + app.picks.written_beside.insert(uid, beside); } report_saved(app, &path, &r); Ok(r) diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 2974926eb0..de3261de1b 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -114,6 +114,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Option { "window.taskBar" => app.ui.task_bar, "window.pathfinder.compoundShapes" => app.ui.shape_modes_compound, "window.taskBar.pin" => app.ui.task_bar_place.pinned, + "window.learn" => crate::panels::learn::visible(&app.ui), "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); normalize_panel(panel).is_some_and(|id| crate::panel_docking::showing(&app.ui, id)) diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3d1577e1be..c4a5f10069 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -358,6 +358,58 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert_eq!(checked(&app, id, &Value::Null), Some(true)); } +/// Type › Type on a Path › Center on Top and Center on Bottom run `type.pathCenter.top` and +/// `.bottom`, which the command palette also runs without params, and are enabled for type on a +/// closed path only. +#[test] +fn center_on_top_and_bottom_are_type_on_a_path_items() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let items: Vec<(&str, &str)> = menu_tree() + .into_iter() + .filter(|(menu, _)| *menu == "Type") + .flat_map(|(_, items)| items) + .filter_map(|item| match item { + Item::Sub("Type on a Path", children) => Some(children), + _ => None, + }) + .flatten() + .filter_map(|item| match item { + Item::Cmd(label, id, Value::Null) if id.starts_with("type.pathCenter") => Some((label, id)), + _ => None, + }) + .collect(); + let ids = ["type.pathCenter.top", "type.pathCenter.bottom"]; + assert_eq!(items, [("Center on Top", ids[0]), ("Center on Bottom", ids[1])]); + let palette = crate::palette::items(); + for (label, id) in items { + assert!(palette.iter().any(|(l, i, _)| l == &format!("Type › Type on a Path › {label}") && i == id), "{label}"); + } + let line = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone(); + let open = app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [open]})).unwrap(); + assert!(ids.iter().all(|id| !enabled(&app, id)), "type on an open path"); + let square = app.run("path.create", json!({"d": "M60 200 L160 200 L160 300 L60 300 Z"})).unwrap()["id"].clone(); + let closed = app.run("text.createInPath", json!({"path": square, "mode": "onPath", "text": "Round", "at": [60, 200]})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [closed]})).unwrap(); + assert!(ids.iter().all(|id| enabled(&app, id))); + app.run("select.set", json!({"ids": [closed, open]})).unwrap(); + assert!(ids.iter().all(|id| !enabled(&app, id)), "type on a closed path and on an open one"); + // A palette pick runs the command with no params. + app.run("select.set", json!({"ids": [closed]})).unwrap(); + crate::palette::run_pick(&mut app, ids[0]); + let doc = &app.session.active().unwrap().doc; + let start = match &doc.node(vectorcraft_doc::NodeId(closed.as_u64().unwrap())).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => match t.kind { + vectorcraft_doc::TextKind::OnPath { start, .. } => start, + ref k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + // Once round, the middle of the top edge an eighth of the way round. + assert!((start - 0.625).abs() < 1e-6, "{start}"); +} + /// Type › Dynamic Text Presets › Circle, Arch and Bow run `type.dynamicTextPreset` with their /// preset, enabled for point type and type on a path but not for area type alone. #[test] @@ -524,6 +576,38 @@ fn classic_extrude_bevel_is_in_the_3d_classic_submenu() { assert!(at("Materials…").unwrap() < at("Extrude & Bevel (Classic)…").unwrap()); } +/// Effect › 3D and Materials › 3D (Classic) › Revolve (Classic)… opens its effect dialog, after +/// Extrude & Bevel (Classic)…, and nowhere else. +#[test] +fn classic_revolve_is_in_the_3d_classic_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let entries = menu_entries(&app); + let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicRevolve")).collect(); + assert_eq!(classic.len(), 1, "listed once"); + let e = classic[0]; + assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]); + assert_eq!((e.label.as_str(), e.command.as_deref()), ("Revolve (Classic)…", Some("effect.dialog"))); + let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials")); + assert!(at("Extrude & Bevel (Classic)…").unwrap() < at("Revolve (Classic)…").unwrap()); +} + +/// Effect › 3D and Materials › 3D (Classic) › Rotate (Classic)… opens its effect dialog, after +/// Revolve (Classic)…, and nowhere else. +#[test] +fn classic_rotate_is_in_the_3d_classic_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let entries = menu_entries(&app); + let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicRotate")).collect(); + assert_eq!(classic.len(), 1, "listed once"); + let e = classic[0]; + assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]); + assert_eq!((e.label.as_str(), e.command.as_deref()), ("Rotate (Classic)…", Some("effect.dialog"))); + let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials")); + assert!(at("Revolve (Classic)…").unwrap() < at("Rotate (Classic)…").unwrap()); +} + /// A document's 25 views are all listed at the bottom of the View menu, by name, and the last /// one brings back its zoom, centre and rotation after the view changed. #[test] diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 59f4752fbf..5b88346f82 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -396,7 +396,13 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { PATH_EFFECTS .iter() .map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect }))) - .chain([Sep, c("Type on a Path Options…", "type.pathOptions")]) + .chain([ + Sep, + c("Center on Top", "type.pathCenter.top"), + c("Center on Bottom", "type.pathCenter.bottom"), + Sep, + c("Type on a Path Options…", "type.pathOptions"), + ]) .collect(), ), c("Dynamic Text", "type.dynamicText"), @@ -667,6 +673,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Image Trace", "imageTrace"), panel("Info", "info"), panel("Layers", "layers"), + cp("Learn", "window.learn", json!({})), panel("Libraries", "libraries"), panel("Links", crate::panels::links::ID), panel("Magic Wand", "magicWand"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index eb8857be30..f435bfff22 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -635,6 +635,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{collapsed?: bool} collapse every expanded dock column to icons (true), expand the outer dock column, Properties | Layers | Libraries (false), or toggle the outer column (omitted), as the double arrow at the top of the dock does; returns the new state", ), + ( + "window.learn", + "Learn", + "", + "{show?: bool} show (true), hide (false) or toggle (omitted or null) the Learn panel, as Window › Learn and the app bar's Explore tutorials button do; any other value is an error and changes nothing; returns whether the panel shows", + ), + ( + "learn.list", + "List Tutorials", + "", + "{} the Learn panel's skills in the order its Skills tab lists them: {skills: [{id, title, tutorials: [{id, title, steps: number of steps}]}]}", + ), + ( + "learn.show", + "Show Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1106406bc0..899f026398 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -26,6 +26,7 @@ pub mod history; pub mod image_trace; pub mod info; pub mod layers; +pub mod learn; pub mod libraries; pub mod library_panel; pub mod links; @@ -185,6 +186,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { css_properties::ID => css_properties::show(app, ui), three_d::ID => three_d::show(app, ui), script_console::ID => script_console::show(app, ui), + learn::ID => learn::show(app, ui), crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui), crate::addons::color::ID => crate::addons::color::show(app, ui), crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui), diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index d3970497d4..8f29c70475 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -3,7 +3,8 @@ //! one opens its Brush Options. Shift-click selects a range of brushes, Cmd/Ctrl-click adds or //! removes one, and the panel menu's Select All Unused selects the brushes no stroke paints with, //! for Delete Brush and Duplicate Brush. A scatter, art or pattern brush dragged onto an empty part -//! of the canvas places a copy of its art there. Brush libraries open in library panels +//! of the canvas places a copy of its art there, and art from the canvas Alt-dragged (Option-dragged +//! on macOS) onto such a brush replaces its art. Brush libraries open in library panels //! ([`BrushLibraries`]). use std::cell::RefCell; @@ -270,6 +271,22 @@ fn reorder_drop( } } +/// Art dragged off the canvas and held over the tile or row `resp` of a scatter, art or pattern +/// brush `name` (of type `ty`) with Alt (Option on macOS) held: the tile is outlined, and released +/// there it sets `replaced` to the brush and the art's ids, whose art then replaces the brush's. +/// Without Alt, or on another type of brush, the drop is left to the list, which opens New Brush. +fn replace_drop(ui: &Ui, resp: &egui::Response, name: &str, ty: &str, replaced: &mut Option<(String, Vec)>) { + if !matches!(ty, "scatter" | "art" | "pattern") || !ui.input(|i| i.modifiers.alt) { + return; + } + let Some(drag) = resp.dnd_hover_payload::() else { return }; + let PanelDrag::Art(ids) = &*drag else { return }; + ui.painter().rect_stroke(resp.rect, 0.0, egui::Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); + if resp.dnd_release_payload::().is_some() { + *replaced = Some((name.to_string(), ids.iter().map(|id| id.0).collect())); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let (list, current) = brushes(app); @@ -285,6 +302,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut clicked: Option<(String, egui::Modifiers)> = None; let mut options: Option = None; let mut moved: Option = None; + let mut replaced: Option<(String, Vec)> = None; let list_rect = widgets::list_box(ui, |ui| { ui.set_min_height(110.0); ui.set_width(ui.available_width()); @@ -347,6 +365,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, (name, _)) in group.into_iter().enumerate() { let resp = row(ui, name, vec2(ui.available_width(), 26.0), true); reorder_drop(ui, &resp, &list, (name, ty, i), true, &mut moved); + replace_drop(ui, &resp, name, ty, &mut replaced); pick(resp, name); } } else { @@ -354,6 +373,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, (name, _)) in group.into_iter().enumerate() { let resp = row(ui, name, vec2(64.0, 28.0), false); reorder_drop(ui, &resp, &list, (name, ty, i), false, &mut moved); + replace_drop(ui, &resp, name, ty, &mut replaced); pick(resp, name); } }); @@ -362,6 +382,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.min_rect() }); + // Art Alt-dragged onto a scatter, art or pattern brush replaces its art, asking first when + // strokes use the brush. + if let Some((name, ids)) = replaced { + crate::dialogs::brush_replace_art::replace(app, &name, &ids); + } // Art dragged off the canvas and dropped on the list opens New Brush for that art, as the New // Brush button opens it for the selection; a brush library's brush dropped there is added. let zone = ui.interact(list_rect, ui.scope_id().with("brushes-drop"), Sense::hover()); diff --git a/crates/ui-egui/src/panels/brushes/tests.rs b/crates/ui-egui/src/panels/brushes/tests.rs index e4c5b9d764..fcab4373ce 100644 --- a/crates/ui-egui/src/panels/brushes/tests.rs +++ b/crates/ui-egui/src/panels/brushes/tests.rs @@ -461,6 +461,54 @@ fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() { assert!(matches!(drag.as_deref(), Some(PanelDrag::Brush { name, def }) if *name == after[0].0 && def["name"] == json!(name)), "{drag:?}"); } +/// Art dragged off the canvas and released on a scatter, art or pattern brush with Alt held +/// replaces that brush's art (no stroke uses it, so nothing asks); without Alt, or on a +/// calligraphic brush, the list opens New Brush as before. +#[test] +fn art_alt_dropped_on_a_brush_with_art_replaces_its_art() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 12})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let out = frame_with(&ctx, &mut app, vec![], egui::Modifiers::NONE, 0.0, show); + let tiles: Vec = clickable(&out).into_iter().map(|c| c.1).filter(|r| r.width() == 64.0).collect(); + assert_eq!(shown(&brushes(&mut app).0, &[])[3], "Dots"); + let dots = app.run("brush.get", json!({"name": "Dots"})).unwrap(); + let drop = |app: &mut VectorcraftApp, at: Pos2, modifiers: egui::Modifiers, time: f64| { + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(id)])); + frame_with(&ctx, app, vec![egui::Event::PointerMoved(at)], modifiers, time, show); + frame_with(&ctx, app, vec![button(at, false)], modifiers, time + 0.1, show); + }; + // Without Alt: New Brush, and Dots stays as it was. + drop(&mut app, tiles[3].center(), egui::Modifiers::NONE, 1.0); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::new_brush::KIND)); + app.ui.dialog = None; + // Alt on a calligraphic or a bristle brush: New Brush too. + let list = brushes(&mut app).0; + let names = shown(&list, &[]); + let at = |name: &str| tiles[names.iter().position(|n| *n == name).unwrap()].center(); + for (i, name) in ["3 pt. Round", "Bristle Round"].into_iter().enumerate() { + let lib = app.run("brush.list", json!({})).unwrap(); + drop(&mut app, at(name), egui::Modifiers::ALT, 2.0 + i as f64); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::new_brush::KIND), "{name}"); + app.ui.dialog = None; + assert_eq!(app.run("brush.list", json!({})).unwrap(), lib, "{name}"); + } + assert_eq!(app.run("brush.get", json!({"name": "Dots"})).unwrap(), dots); + // Alt on Dots (scatter), Arrow (art) and Chain (pattern, its side tile): the rectangle is its + // art now, in one undo step each. + for (i, (name, key)) in [("Dots", "art"), ("Arrow", "art"), ("Chain", "side")].into_iter().enumerate() { + let steps = app.session.active().unwrap().history.undo.len(); + drop(&mut app, at(name), egui::Modifiers::ALT, 4.0 + i as f64); + assert!(app.ui.dialog.is_none(), "no stroke uses {name}: nothing asks"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "{name}"); + let art: vectorcraft_doc::Node = serde_json::from_value(app.run("brush.get", json!({"name": name})).unwrap()[key].clone()).unwrap(); + let b = art.geometric_bounds().unwrap(); + assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 12.0).abs() < 1e-6, "{name}: {b:?}"); + } +} + /// A brush dragged onto the left half of another brush of its type goes before it, with a bar /// marking the slot while it is held there, as one undo step; dropped on a brush of another /// type, it stays where it is and the status bar says why. diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 5f2f96a8cc..2f834a272c 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -266,8 +266,9 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { /// Set the Kerning field's `value` (a number, "auto", or "optical"). At a caret in text the Type tool edits /// it is the pair kerning after the character before the caret, as Alt+←/→ steps it -/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like -/// the others: the selected range, or all the text. +/// (`type.step`), or at the start of a line the space before the character after it, where Auto +/// and Optical set no space (#5111); nowhere to set at an empty line's start. Else it is a +/// character attribute like the others: the selected range, or all the text. fn set_kerning(app: &mut VectorcraftApp, value: Value) { let Some((id, a, b)) = text_editing(app) else { format(app, json!({"kerning": value})); @@ -278,15 +279,18 @@ fn set_kerning(app: &mut VectorcraftApp, value: Value) { return; } let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return }; - let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a); - if start >= a { - return; - } + let Some((target, before)) = vectorcraft_text::edit::caret_kerning_target(&t.plain_text(), a) else { return }; end_typing(app); - app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok(); + let params = if before { + json!({"id": id.0, "start": target.start, "end": target.end, "kerningBefore": value.as_f64().unwrap_or(0.0)}) + } else { + json!({"id": id.0, "start": target.start, "end": target.end, "kerning": value}) + }; + app.run("text.setRangeStyle", params).ok(); } -/// The pair before the caret, in 1/1000 em, using the layout the canvas draws. +/// The kerning at the caret, in 1/1000 em, using the layout the canvas draws: the pair before it, +/// or at the start of a line the space before the character after it. fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> { let (id, a, b) = text_editing(app)?; if a != b { @@ -294,11 +298,12 @@ fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> { } let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None }; let text = t.plain_text(); - let start = vectorcraft_text::edit::prev_char(&text, a); - if start >= a { - return None; - } + let (target, before) = vectorcraft_text::edit::caret_kerning_target(&text, a)?; + let start = target.start; let style = vectorcraft_text::edit::style_at(&t.runs, start + 1); + if before { + return Some((style.kerning_before, false)); + } if let Some(v) = style.kerning { return Some((v, false)); } diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs new file mode 100644 index 0000000000..e49832c0da --- /dev/null +++ b/crates/ui-egui/src/panels/learn.rs @@ -0,0 +1,214 @@ +//! Learn panel (Window › Learn, and Explore tutorials at the right end of the app bar): short +//! hands-on tutorials grouped by skill ([`catalog::SKILLS`]). The For you tab holds the Get started +//! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each +//! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a +//! tutorial previews its numbered steps. Back goes up one level. `learn.list` lists the tutorials, +//! `learn.show` opens one and `window.learn` shows or hides the panel. + +pub mod catalog; + +use egui::{Sense, Stroke, Ui, vec2}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::theme::Tokens; + +pub const ID: &str = "learn"; +/// The height of a skill or tutorial row. +const ROW_HEIGHT: f32 = 28.0; + +/// The panel's tabs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Tab { + #[default] + ForYou, + Skills, +} + +/// What the panel shows: its tab and, on the Skills tab, the skill and the tutorial open. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct View { + pub tab: Tab, + pub skill: Option<&'static str>, + pub tutorial: Option<&'static str>, +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.horizontal(|ui| { + for (tab, label) in [(Tab::ForYou, tl!("For you")), (Tab::Skills, tl!("Skills"))] { + if ui.selectable_label(app.ui.learn.tab == tab, label).clicked() { + app.ui.learn.tab = tab; + } + } + }); + crate::widgets::divider(ui); + match app.ui.learn.tab { + Tab::ForYou => for_you(app, ui), + Tab::Skills => skills(app, ui), + } +} + +/// The For you tab: Get started, with the Quick skill guide card. +fn for_you(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + crate::widgets::subheader(ui, tl!("Get started")); + ui.add_space(4.0); + let card = egui::Frame::NONE + .fill(t.panel_darker) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(egui::CornerRadius::same(t.radius)) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + crate::icons::icon(ui, "graduation-cap", 18.0, t.text_strong); + ui.label(egui::RichText::new(tl!("Quick skill guide")).size(13.0).color(t.text_strong)); + }); + ui.add_space(4.0); + ui.add(egui::Label::new( + egui::RichText::new(tl!("Short hands-on tutorials for the essential tools, grouped by skill. Choose a skill to see its tutorials.")) + .size(11.5) + .color(t.text_dim), + )); + }); + let resp = ui.interact(card.response.rect, ui.scope_id().with("quick-skill-guide"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Quick skill guide"))); + if resp.clicked() { + app.ui.learn = View { tab: Tab::Skills, skill: None, tutorial: None }; + } +} + +/// The Skills tab: the skills, a skill's tutorials, or a tutorial's steps. +fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { + let open = app.ui.learn.tutorial.and_then(catalog::tutorial); + if let Some((skill, tutorial)) = open { + if back(ui) { + app.ui.learn.skill = Some(skill.id); + app.ui.learn.tutorial = None; + } + steps(ui, tutorial); + return; + } + if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { + if back(ui) { + app.ui.learn.skill = None; + } + crate::widgets::subheader(ui, skill.title); + for tutorial in skill.tutorials { + if row(ui, tutorial.title, None).clicked() { + app.ui.learn.tutorial = Some(tutorial.id); + } + } + return; + } + for skill in catalog::SKILLS { + if row(ui, skill.title, Some(skill.tutorials.len())).clicked() { + app.ui.learn.skill = Some(skill.id); + } + } +} + +/// The Back button above a skill's tutorials and a tutorial's steps; true when it was clicked. +fn back(ui: &mut Ui) -> bool { + crate::widgets::icon_button(ui, "chevron-left", tl!("Back"), false, 22.0).clicked() +} + +/// A tutorial's title and its numbered steps. +fn steps(ui: &mut Ui, tutorial: &catalog::Tutorial) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tutorial.title).size(13.0).color(t.text_strong)); + ui.add_space(4.0); + for (i, step) in tutorial.steps.iter().enumerate() { + ui.horizontal_top(|ui| { + ui.label(egui::RichText::new(format!("{}.", i + 1)).color(t.text_dim)); + ui.add(egui::Label::new(egui::RichText::new(step.text).color(t.text)).wrap()); + }); + ui.add_space(2.0); + } +} + +/// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the +/// count of tutorials done of all after it. No progress is kept yet, so the ring is empty. +fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); + } + let mut left = rect.left() + 6.0; + let mut right = rect.right() - 6.0; + let chevron = egui::Rect::from_center_size(egui::pos2(right - 6.0, rect.center().y), vec2(12.0, 12.0)); + crate::icons::paint(ui, "chevron-right", chevron, t.text_dim); + right = chevron.left() - 6.0; + if let Some(total) = tutorials { + ui.painter().circle_stroke(egui::pos2(left + 8.0, rect.center().y), 7.0, Stroke::new(2.0, t.border)); + left += 24.0; + let count = ui.painter().layout_no_wrap(format!("0/{total}"), egui::FontId::proportional(11.5), t.text_dim); + right -= count.size().x; + ui.painter().galley(egui::pos2(right, rect.center().y - count.size().y / 2.0), count, t.text_dim); + right -= 6.0; + } + let name = ui.painter().layout(title.to_string(), egui::FontId::proportional(12.5), t.text, f32::INFINITY); + let clip = egui::Rect::from_min_max(egui::pos2(left, rect.top()), egui::pos2(right.max(left), rect.bottom())); + ui.painter().with_clip_rect(clip).galley(egui::pos2(left, rect.center().y - name.size().y / 2.0), name, t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), title)); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// The Learn panel's UI commands: `window.learn`, `learn.list` and `learn.show`. +pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + Some(match id { + "window.learn" => toggle(app, p), + "learn.list" => Ok(list()), + "learn.show" => open(app, p), + _ => return None, + }) +} + +/// Whether the panel can be seen: it is the front panel, the dock is not hidden and no full screen +/// mode hides the panels. +pub(crate) fn visible(ui: &crate::state::UiState) -> bool { + ui.dock && ui.screen_mode < 3 && crate::panel_docking::showing(ui, ID) +} + +/// `window.learn {show?}`: show the panel (true), hide it (false) or toggle it (omitted), as the +/// app bar's Explore tutorials button and Window › Learn do → whether it shows ([`visible`]). +/// Showing it also shows a hidden dock; in a full screen mode it makes the panel the front one. +fn toggle(app: &mut VectorcraftApp, p: &Value) -> Result { + let shown = visible(&app.ui); + let show = crate::menus::opt_bool(p, "show")?.unwrap_or(!shown); + if show && !shown { + crate::panel_docking::reveal_panel(app, ID)?; + } else if !show && shown { + app.run("window.panel", json!({ "panel": ID, "toggle": true }))?; + } + Ok(json!(visible(&app.ui))) +} + +/// `learn.list`: every skill with its tutorials and their step counts. +fn list() -> Value { + let skills = catalog::SKILLS.iter().map(|s| { + let tutorials: Vec = s.tutorials.iter().map(|t| json!({"id": t.id, "title": t.title, "steps": t.steps.len()})).collect(); + json!({"id": s.id, "title": s.title, "tutorials": tutorials}) + }); + json!({ "skills": skills.collect::>() }) +} + +/// `learn.show {tutorial}`: show the panel on the Skills tab with that tutorial's steps → the +/// tutorial. A missing or unknown id is an error and changes nothing. +fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let key = "tutorial"; + let id = match p.get(key) { + None | Some(Value::Null) => return Err("missing `tutorial` (see learn.list)".into()), + Some(Value::String(id)) => id.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + let (skill, tutorial) = catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)"))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + let steps: Vec<&str> = tutorial.steps.iter().map(|s| s.text).collect(); + Ok(json!({"skill": skill.id, "tutorial": tutorial.id, "title": tutorial.title, "steps": steps})) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/panels/learn/catalog.rs b/crates/ui-egui/src/panels/learn/catalog.rs new file mode 100644 index 0000000000..b1ffd897d7 --- /dev/null +++ b/crates/ui-egui/src/panels/learn/catalog.rs @@ -0,0 +1,185 @@ +//! The Learn panel's tutorials, VectorCraft's own short lessons, grouped by skill. The text is +//! English data: the panel shows it as written in every interface language. + +/// One step of a tutorial: what to do, and the tool it chooses or the command it runs, if any. +pub struct Step { + pub text: &'static str, + /// A tool id ([`vectorcraft_tools::catalog::tool_info`]) the step names. + pub tool: Option<&'static str>, + /// A command id the step names. + pub command: Option<&'static str>, +} + +/// A tutorial: an id for `learn.show`, its title and its numbered steps. +pub struct Tutorial { + pub id: &'static str, + pub title: &'static str, + pub steps: &'static [Step], +} + +/// A skill and its tutorials, in the order the panel lists them. +pub struct Skill { + pub id: &'static str, + pub title: &'static str, + pub tutorials: &'static [Tutorial], +} + +/// A step that names no tool or command. +const fn say(text: &'static str) -> Step { + Step { text, tool: None, command: None } +} + +/// A step that uses tool `id`. +const fn tool(text: &'static str, id: &'static str) -> Step { + Step { text, tool: Some(id), command: None } +} + +/// A step that runs command `id`. +const fn command(text: &'static str, id: &'static str) -> Step { + Step { text, tool: None, command: Some(id) } +} + +/// Every skill, in the order the Skills tab lists them. +pub const SKILLS: &[Skill] = &[ + Skill { + id: "essentials", + title: "Best of VectorCraft", + tutorials: &[ + Tutorial { + id: "draw-and-color", + title: "Draw and color a shape", + steps: &[ + tool("Choose the Rectangle tool in the toolbar.", "rectangle"), + say("Drag on the artboard to draw a rectangle."), + say("With the rectangle still selected, click a color in the Swatches panel to fill it."), + command("Click the Stroke box under the tools, then click another swatch to color the outline.", "paint.toggleActive"), + ], + }, + Tutorial { + id: "combine-shapes", + title: "Combine shapes into one", + steps: &[ + tool("Choose the Ellipse tool and draw two circles that overlap.", "ellipse"), + tool("Choose the Selection tool and drag across both circles to select them.", "selection"), + tool("Choose the Shape Builder tool and drag across the parts you want to merge.", "shapeBuilder"), + ], + }, + Tutorial { + id: "add-headline", + title: "Add a headline", + steps: &[ + tool("Choose the Type tool and click on the artboard.", "type"), + say("Type a few words, then press Escape to finish."), + say("Pick a font and a size in the Character panel."), + ], + }, + ], + }, + Skill { + id: "draw", + title: "Draw and build", + tutorials: &[ + Tutorial { + id: "pen-paths", + title: "Draw paths with the Pen", + steps: &[ + tool("Choose the Pen tool.", "pen"), + say("Click to place corner points, or drag to pull out a curve."), + say("Click the first point again to close the shape, or press Enter to leave it open."), + ], + }, + Tutorial { + id: "smooth-curves", + title: "Draw smooth curves", + steps: &[ + tool("Choose the Curvature tool.", "curvature"), + say("Click to place points: the path bends smoothly through each one."), + tool("Choose the Direct Selection tool and drag a point to reshape the curve.", "directSelection"), + ], + }, + Tutorial { + id: "pencil-sketch", + title: "Sketch with the Pencil", + steps: &[ + tool("Choose the Pencil tool.", "pencil"), + say("Drag to draw a freehand line."), + tool("Choose the Smooth tool and drag along the line to even it out.", "smooth"), + ], + }, + ], + }, + Skill { + id: "color", + title: "Apply color", + tutorials: &[ + Tutorial { + id: "fill-and-stroke", + title: "Fill and stroke", + steps: &[ + tool("Select an object with the Selection tool.", "selection"), + say("Click the Fill box under the tools, then pick a color in the Color panel."), + command("Click the double arrow beside the boxes to swap the fill and the stroke.", "paint.swap"), + ], + }, + Tutorial { + id: "gradient", + title: "Add a gradient", + steps: &[ + tool("Select an object with the Selection tool.", "selection"), + tool("Choose the Gradient tool and click the object to give it a gradient.", "gradient"), + say("Drag across the object to set the gradient's direction and length."), + ], + }, + Tutorial { + id: "copy-colors", + title: "Copy colors from other art", + steps: &[ + tool("Select the object to color with the Selection tool.", "selection"), + tool("Choose the Eyedropper tool and click the art whose colors you want.", "eyedropper"), + ], + }, + ], + }, + Skill { + id: "type", + title: "Set type", + tutorials: &[ + Tutorial { + id: "area-type", + title: "Flow text into a shape", + steps: &[ + tool("Draw a rectangle for the text to fill.", "rectangle"), + tool("Choose the Area Type tool and click the rectangle's edge.", "areaType"), + say("Type or paste your text: it wraps inside the shape."), + ], + }, + Tutorial { + id: "type-on-path", + title: "Type along a path", + steps: &[ + tool("Draw a curved path with the Pen tool.", "pen"), + tool("Choose the Type on a Path tool and click the path.", "typeOnPath"), + say("Type your text: it follows the path."), + ], + }, + Tutorial { + id: "outline-type", + title: "Turn type into shapes", + steps: &[ + tool("Select a text object with the Selection tool.", "selection"), + command("Choose Type › Create Outlines to turn its letters into paths.", "type.createOutlines"), + ], + }, + ], + }, +]; + +/// The skill whose id is `id`. +pub fn skill(id: &str) -> Option<&'static Skill> { + SKILLS.iter().find(|s| s.id == id) +} + +/// The tutorial whose id is `id`, with its skill. +pub fn tutorial(id: &str) -> Option<(&'static Skill, &'static Tutorial)> { + SKILLS.iter().find_map(|s| s.tutorials.iter().find(|t| t.id == id).map(|t| (s, t))) +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs new file mode 100644 index 0000000000..d4dbf8f00b --- /dev/null +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -0,0 +1,231 @@ +use std::collections::HashSet; + +use egui::accesskit::Node; +use serde_json::json; +use vectorcraft_engine::Session; + +use super::{ID, Tab, View, catalog, show}; +use crate::VectorcraftApp; +use crate::tests_labels::painted_text; + +const SCREEN: egui::Rect = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1400.0, 800.0)); + +fn app() -> VectorcraftApp { + crate::i18n::set_current(crate::i18n::Lang::EN); + VectorcraftApp::new(Session::new(), Default::default()) +} + +/// One frame of `draw` with screen-reader nodes on → the nodes. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(SCREEN), ..Default::default() }, |ui| draw(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default() +} + +/// The center of the node labeled `name`. +fn center(nodes: &[Node], name: &str) -> Option { + center_where(nodes, |label| label == name) +} + +/// The center of the first node whose label `pick` takes. +fn center_where(nodes: &[Node], pick: impl Fn(&str) -> bool) -> Option { + let b = nodes.iter().find(|n| n.label().is_some_and(&pick))?.bounds()?; + Some(egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) +} + +/// Click the node labeled `name` in what `draw` draws. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + frame(app, ctx, vec![], draw); + let at = center(&frame(app, ctx, vec![], draw), name).unwrap_or_else(|| panic!("{name} is drawn")); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(at), press(true)], draw); + frame(app, ctx, vec![press(false)], draw); +} + +fn context() -> egui::Context { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + ctx +} + +fn app_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + crate::chrome::app_bar(app, ui); +} + +fn shown(app: &VectorcraftApp) -> bool { + crate::panel_docking::showing(&app.ui, ID) +} + +/// Every skill has tutorials and every tutorial steps; ids are unique; every tool and command a +/// step names exists. +#[test] +fn the_catalog_is_valid() { + let mut skills = HashSet::new(); + let mut tutorials = HashSet::new(); + assert!(!catalog::SKILLS.is_empty()); + for skill in catalog::SKILLS { + assert!(skills.insert(skill.id), "skill id {} is used twice", skill.id); + assert!(!skill.title.is_empty() && !skill.tutorials.is_empty(), "skill {} is empty", skill.id); + for tutorial in skill.tutorials { + assert!(tutorials.insert(tutorial.id), "tutorial id {} is used twice", tutorial.id); + assert!(!tutorial.title.is_empty() && !tutorial.steps.is_empty(), "tutorial {} is empty", tutorial.id); + for step in tutorial.steps { + assert!(step.text.ends_with('.'), "{}: {:?}", tutorial.id, step.text); + if let Some(tool) = step.tool { + assert!(vectorcraft_tools::catalog::tool_info(tool).is_some(), "{}: no tool {tool}", tutorial.id); + } + if let Some(cmd) = step.command { + let known = + crate::menus::UI_COMMANDS.iter().any(|c| c.0 == cmd) || vectorcraft_engine::cmd::command_specs().iter().any(|c| c.id == cmd); + assert!(known, "{}: no command {cmd}", tutorial.id); + } + } + } + } +} + +/// Explore tutorials at the right end of the app bar shows the Learn panel, and hides it again. +#[test] +fn explore_tutorials_on_the_app_bar_shows_and_hides_the_learn_panel() { + let mut app = app(); + let ctx = context(); + assert!(!shown(&app)); + let nodes = frame(&mut app, &ctx, vec![], app_bar); + let search = center_where(&nodes, |label| label.starts_with("Search commands and tools")); + let learn = center(&nodes, "Explore tutorials"); + let (search, learn) = (search.expect("the search icon shows"), learn.expect("the app bar has Explore tutorials")); + assert!(learn.x < search.x && (learn.y - search.y).abs() < 1.0, "beside the search icon: {learn:?}, {search:?}"); + click(&mut app, &ctx, "Explore tutorials", app_bar); + assert!(shown(&app), "{}", app.ui.status); + click(&mut app, &ctx, "Explore tutorials", app_bar); + assert!(!shown(&app)); +} + +/// The command and params of Window › Learn. +fn window_learn() -> (&'static str, serde_json::Value) { + use crate::menus::Item; + let window = crate::menus::menu_tree().into_iter().find(|(title, _)| *title == "Window").map(|(_, items)| items).unwrap(); + window + .iter() + .find_map(|item| match item { + Item::Cmd("Learn", cmd, p) => Some((*cmd, p.clone())), + _ => None, + }) + .expect("Window › Learn") +} + +/// Window › Learn shows and hides the panel, as `window.learn` does. +#[test] +fn window_learn_shows_and_hides_the_panel() { + let mut app = app(); + let (cmd, params) = window_learn(); + app.run(cmd, params.clone()).unwrap(); + assert!(shown(&app)); + assert_eq!(crate::menus::checked(&app, cmd, ¶ms), Some(true), "Window › Learn is checked"); + app.run(cmd, params.clone()).unwrap(); + assert!(!shown(&app)); + assert_eq!(crate::menus::checked(&app, cmd, ¶ms), Some(false)); + assert_eq!(app.run("window.learn", json!({"show": true})).unwrap(), json!(true)); + assert_eq!(app.run("window.learn", json!({"show": true})).unwrap(), json!(true), "shown stays shown"); + assert_eq!(app.run("window.learn", json!({})).unwrap(), json!(false)); + assert!(app.run("window.learn", json!({"show": "yes"})).is_err()); + assert!(!shown(&app)); +} + +/// With the dock hidden, Learn as the front panel is not visible: `window.learn`, Window › Learn +/// and Explore tutorials show the dock instead of closing the panel. +#[test] +fn window_learn_shows_a_hidden_dock() { + let mut app = app(); + let (cmd, params) = window_learn(); + assert_eq!(app.run("window.learn", json!({"show": true})).unwrap(), json!(true)); + app.ui.dock = false; + assert_eq!(crate::menus::checked(&app, cmd, ¶ms), Some(false), "Window › Learn is unchecked"); + assert_eq!(app.run("window.learn", json!({"show": false})).unwrap(), json!(false)); + assert!(shown(&app) && !app.ui.dock, "hiding a panel that cannot be seen changes nothing"); + app.run(cmd, params).unwrap(); + assert!(app.ui.dock && shown(&app), "Window › Learn shows the dock"); + // A full screen mode hides the panels: the panel stays the front one and shows when it ends. + app.ui.screen_mode = 3; + assert_eq!(app.run("window.learn", json!({})).unwrap(), json!(false)); + assert!(shown(&app)); + app.ui.screen_mode = 0; + assert_eq!(app.run("window.learn", json!({})).unwrap(), json!(false)); + assert!(!shown(&app)); +} + +/// The For you tab's Quick skill guide card opens the Skills tab, which lists every skill with its +/// count of tutorials. +#[test] +fn the_skills_tab_lists_every_skill() { + let mut app = app(); + let ctx = context(); + let text = painted_text(&mut app, show); + assert!(text.contains("Get started") && text.contains("Quick skill guide"), "{text}"); + click(&mut app, &ctx, "Quick skill guide", show); + assert_eq!(app.ui.learn.tab, Tab::Skills); + let text = painted_text(&mut app, show); + for skill in catalog::SKILLS { + assert!(text.contains(skill.title), "{} in {text}", skill.title); + assert!(text.contains(&format!("0/{}", skill.tutorials.len())), "{text}"); + } + let skill = &catalog::SKILLS[1]; + click(&mut app, &ctx, skill.title, show); + let text = painted_text(&mut app, show); + for tutorial in skill.tutorials { + assert!(text.contains(tutorial.title), "{} in {text}", tutorial.title); + } +} + +/// Choosing a tutorial previews its numbered steps; Back goes to its skill, then to the skills. +#[test] +fn a_tutorial_previews_its_steps() { + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }; + click(&mut app, &ctx, tutorial.title, show); + assert_eq!(app.ui.learn.tutorial, Some(tutorial.id)); + let text = painted_text(&mut app, show); + for (i, step) in tutorial.steps.iter().enumerate() { + assert!(text.contains(&format!("{}.", i + 1)) && text.contains(step.text), "{} in {text}", step.text); + } + click(&mut app, &ctx, "Back", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some(skill.id), tutorial: None }); + click(&mut app, &ctx, "Back", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None }); +} + +/// `learn.show` opens the panel on a tutorial's steps; `learn.list` lists every tutorial. +#[test] +fn learn_show_opens_a_tutorial_and_learn_list_lists_them() { + let mut app = app(); + let shown_tutorial = app.run("learn.show", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(shown_tutorial["skill"], "color"); + assert_eq!(shown_tutorial["steps"].as_array().map(Vec::len), Some(3)); + assert!(shown(&app)); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("gradient") }); + let list = app.run("learn.list", json!({})).unwrap(); + let skills = list["skills"].as_array().unwrap(); + assert_eq!(skills.len(), catalog::SKILLS.len()); + let count: usize = skills.iter().map(|s| s["tutorials"].as_array().map_or(0, Vec::len)).sum(); + assert_eq!(count, catalog::SKILLS.iter().map(|s| s.tutorials.len()).sum::()); + assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3})); +} + +/// A missing, mistyped or unknown tutorial id is an error and leaves the panel as it was. +#[test] +fn learn_show_rejects_a_missing_or_unknown_tutorial() { + let mut app = app(); + let before = app.ui.learn.clone(); + for (params, error) in [ + (json!({}), "missing `tutorial` (see learn.list)"), + (json!({"tutorial": 3}), "`tutorial` must be a string"), + (json!({"tutorial": "nope"}), "unknown tutorial `nope` (see learn.list)"), + ] { + assert_eq!(app.run("learn.show", params).unwrap_err(), error); + assert_eq!(app.ui.learn, before); + assert!(!shown(&app)); + } +} diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 32d15d715f..cabca387ae 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -877,6 +877,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // Korean lines break between any two syllables, not only between words. + let anywhere = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); + if menu_item(ui, tl!("Korean Line Break Anywhere"), has, anywhere) { + toggle_korean_line_break_anywhere(app); + } } ui.separator(); let composer = shared_composer(app); @@ -899,12 +904,20 @@ fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { format(app, json!({"kinsokuType": key})); } +/// Korean Line Break Anywhere (Paragraph panel menu): the menu item is checked when every paragraph +/// the panel acts on ([`format`]) has it, and a click turns it off for all of them then, and on for +/// all of them otherwise, in one undo step. +fn toggle_korean_line_break_anywhere(app: &mut VectorcraftApp) { + let on = shared_para(app, |p| p.korean_line_break_anywhere) == Some(true); + format(app, json!({"koreanLineBreakAnywhere": !on})); +} + /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification /// options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi /// Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman -/// Hanging Punctuation off, no kashidas and no tab stops, as one undo step named Reset Panel (inside -/// an undo group that is already open, as part of its step). +/// Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo +/// step named Reset Panel (inside an undo group that is already open, as part of its step). fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); @@ -924,7 +937,7 @@ fn reset_panel(app: &mut VectorcraftApp) { "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", "direction": "auto", "leadingModel": "romanBaseline", "mojikumi": "lineEndHalf", "burasagari": "standard", "kinsoku": "hard", - "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false + "kinsokuType": "pushOutOnly", "composer": "everyLine", "romanHangingPunctuation": false, "koreanLineBreakAnywhere": false }), ); para_cmd(app, "text.tabs.clear", json!({})); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 347975f680..517ef1eeab 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -133,6 +133,39 @@ fn kinsoku_shori_type_sets_the_selected_paragraphs_in_one_undo_step() { assert_eq!(types(&app), (KinsokuShoriType::PushOutOnly, KinsokuShoriType::PushOutOnly)); } +/// Korean Line Break Anywhere: new type has it off; the menu item turns it on for the paragraphs +/// the Type tool's selection touches in one undo step, is unchecked while the selected paragraphs +/// differ, turns it on for all of them then and off again once they all have it. Reset Panel turns +/// it off. +#[test] +fn korean_line_break_anywhere_toggles_the_selected_paragraphs_in_one_undo_step() { + let (mut app, id) = editing_text("가나\n다라\n"); + app.session.prefs.show_east_asian_options = true; + let flags = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).korean_line_break_anywhere, text.para_at(1).korean_line_break_anywhere) + }; + assert_eq!(flags(&app), (false, false), "new type has it off"); + select_range(&mut app, 0, 3); + let history = app.session.active().unwrap().history.undo.len(); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (true, false)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para(&app, |p| p.korean_line_break_anywhere), None, "mixed: unchecked"); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (true, true)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_korean_line_break_anywhere(&mut app); + assert_eq!(flags(&app), (false, false)); + app.session.set_tool_option("selectAll", &json!(true)); + toggle_korean_line_break_anywhere(&mut app); + app.session.set_tool_option("selectAll", &json!(true)); + reset_panel(&mut app); + assert_eq!(flags(&app), (false, false), "Reset Panel turns it off"); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6dcd4a516f..07b53cc6d8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -159,6 +159,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { crate::panels::image_trace::trace_button(app, ui, ui.available_width()); actions.push((tl!("Crop Image"), "ui.cropImage")); actions.push((tl!("Mask"), "object.maskImage")); + // Remove Background at once with its defaults, as the reference app's Quick Actions do + // (). + actions.push((tl!("Remove Background"), crate::addons::image::REMOVE_BACKGROUND)); } // Object › Path's Offset Path and Simplify take paths: type alone could only fail, and offsets // live through the Appearance panel's Effect › Path › Offset Path (#2238). diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index fb12e4fdc0..6a984a1ced 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -686,15 +686,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // A click that lands on a stop's whole point or tick selects that stop and records no // undo step. let added = !stops.iter().any(|s| s.position == position); - if added { - stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); - stops.sort_by(|a, b| a.position.total_cmp(&b.position)); - } - let i = stops.iter().position(|s| s.position == position).unwrap_or(0); - set_pstate::>(ui.ctx(), "tabs-marker-sel", None); - set_pstate(ui.ctx(), "tabs-sel", i); - if added { - apply(app, &stops); + let max = TabStop::MAX_STOPS; + if added && stops.len() >= max { + // A full list takes no more stops: the click leaves the stops and the + // selection as they are and shows the limit. + app.status(format!("at most {max} tab stops")); + } else { + if added { + stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + } + let i = stops.iter().position(|s| s.position == position).unwrap_or(0); + set_pstate::>(ui.ctx(), "tabs-marker-sel", None); + set_pstate(ui.ctx(), "tabs-sel", i); + if added { + apply(app, &stops); + } } } } @@ -729,7 +736,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } // Repeat Tab: copies of the selected stop across the ruler, spaced by its distance from the // previous stop, or from the paragraph's left indent plus its list level's indent when it is - // the first stop. + // the first stop. The copies stop at the 256-stop limit, which the status bar then shows. if menu_item(ui, tl!("Repeat Tab"), sel < stops.len() && selected_marker.is_none(), false) { let step = match sel { 0 => (stops[0].position - left_indent).abs(), @@ -738,8 +745,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { .max(1.0); let base = stops[sel].clone(); stops.truncate(sel + 1); + let max = TabStop::MAX_STOPS; let mut pos = base.position + step; while pos <= base.position + step * 20.0 && pos < 2000.0 { + if stops.len() >= max { + app.status(format!("at most {max} tab stops")); + break; + } stops.push(TabStop { position: pos, ..base.clone() }); pos += step; } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 9e96866ab2..97e8c8a00a 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -1255,6 +1255,35 @@ fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() { assert_eq!(current(&app).unwrap().1, before); } +/// With 256 stops, a click on an empty part of the ruler adds none and shows the limit in the +/// status bar. Repeat Tab near the end of the list adds copies only up to 256 stops and shows the +/// limit too. +#[test] +fn the_ruler_and_repeat_tab_stop_at_256_stops() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + // 250 stops 1 pt apart from 0 pt, then six 2 pt apart from 260 pt. + let start: Vec = (0..250).map(f64::from).chain((0..6).map(|i| 260.0 + 2.0 * f64::from(i))).collect(); + let stops: Vec = start.iter().map(|p| json!({"position": p})).collect(); + app.session.execute("text.tabs.set", &json!({ "stops": stops })).unwrap(); + let ctx = egui::Context::default(); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + set_pstate(&ctx, "tabs-sel", 3usize); + drag_on_ruler(&ctx, &mut app, 290.0, 290.0, false); + assert_eq!(positions(&app), start); + assert_eq!(pstate::(&ctx, "tabs-sel"), 3); + assert_eq!(app.ui.status, "at most 256 tab stops"); + // Repeat Tab on the stop at 260 pt (11 pt past the one before it): five copies, not 20. + app.status(""); + set_pstate(&ctx, "tabs-sel", 250usize); + choose(&ctx, &mut app, "Repeat Tab"); + let repeated = positions(&app); + assert_eq!(repeated.len(), 256); + assert_eq!(repeated[249..], [249.0, 260.0, 271.0, 282.0, 293.0, 304.0, 315.0]); + assert_eq!(app.ui.status, "at most 256 tab stops"); +} + /// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's /// left indent (18 pt further in for each list level past the first), from the paragraph the /// caret is in while the Type tool edits text. diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6918e9118f..4d8959f262 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -132,6 +132,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { [ json!("art-ribbons"), json!("calligraphic-nibs"), + json!("bristle-brushes"), json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails"), diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 7699516d78..d2b0a26d3f 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -125,6 +125,13 @@ pub(crate) struct Picks { replacing: Option, /// The file that question's OK allows to be replaced while what asked runs again. replace_ok: Option, + /// The files that the OK of the question whether to replace the files a save or export writes + /// beside the one picked allows to be replaced while it runs that again + /// ([`crate::dialogs::confirm::ask_replace_files`]). + pub(crate) replace_files_ok: Vec, + /// The files each document's last save wrote beside its own file, by document uid: its next + /// save replaces them without asking. + pub(crate) written_beside: std::collections::HashMap>, /// What runs now handles the answer of a file dialog automation opened ([`as_automated`]). automated: bool, /// The web's file pickers opened ([`open_async`]) that have not answered yet @@ -162,6 +169,7 @@ impl Picks { self.entry = None; self.answer = None; self.replace_ok = None; + self.replace_files_ok.clear(); self.automated = false; } @@ -438,7 +446,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { /// Make the document an entry asked for (`doc`, by its uid) active again before it runs again: /// another may be active by now (opened from the Finder, by an agent), or it may be gone → false /// when it can't run (the status bar says why). -fn on_document(app: &mut VectorcraftApp, doc: Option) -> bool { +pub(crate) fn on_document(app: &mut VectorcraftApp, doc: Option) -> bool { let Some(uid) = doc else { return true }; let Some(i) = app.session.documents().iter().position(|d| d.uid == uid) else { app.status(tl!("The document was closed while its file dialog was open")); diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index ebb42852a9..de9407e2f3 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -577,8 +577,8 @@ pub fn image_summary(i: &Value) -> (String, String) { } /// The Control bar for the one selected image: Linked File or Embedded, its file name, colour mode -/// and effective resolution, Embed or Unembed, Edit Original (a linked file), Image Trace, Mask and -/// Crop Image. Whether one image is selected. +/// and effective resolution, Embed or Unembed, Edit Original (a linked file), Image Trace, Mask, +/// Crop Image and Remove Background. Whether one image is selected. pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let Some(i) = selected_image_info(app) else { return false }; let (name, details) = image_summary(i); @@ -595,7 +595,11 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool ui.separator(); link_buttons(app, ui, id, linked, &name, None); crate::panels::image_trace::trace_button(app, ui, crate::panels::image_trace::TRACE_BUTTON_W); - for (label, cmd, w) in [(tl!("Mask"), "object.maskImage", 52.0), (tl!("Crop Image"), "ui.cropImage", 84.0)] { + for (label, cmd, w) in [ + (tl!("Mask"), "object.maskImage", 52.0), + (tl!("Crop Image"), "ui.cropImage", 84.0), + (tl!("Remove Background"), crate::addons::image::REMOVE_BACKGROUND, 128.0), + ] { if widgets::flat_button(ui, label, w).clicked() { crate::menus::invoke(app, cmd, json!({})); } diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index de038296ab..3e74ad394f 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -43,7 +43,8 @@ pub type ReadHeadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; -pub type ExistsFn = Box bool>; +/// Shared, so Background Save and Export can check from a worker thread too. +pub type ExistsFn = Arc bool + Send + Sync>; pub type Inbox = Arc)>>>; @@ -89,8 +90,9 @@ pub struct Services { pub read_head: Option, pub write: Option, /// Is there a file or folder at a path (desktop)? The app asks before replacing a file that a - /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]). - /// Without it, no file counts as there. + /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]), and + /// before a save or export replaces a file it writes beside that one (a file per artboard, a + /// linked image). Without it, no file counts as there. pub exists: Option, /// Files that arrived asynchronously (web drops): (name, bytes). pub inbox: Option, diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0059c176f4..4a0e73da6b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -209,6 +209,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::three_d::ID, "3D and Materials", "dc-cube"), (crate::panels::variables::ID, "Variables", "chart-column"), (crate::panels::script_console::ID, "Script Console", "message-circle"), + (crate::panels::learn::ID, "Learn", "graduation-cap"), (crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"), (crate::addons::color::ID, "Color Effects", "sun"), (crate::addons::pathtools::ID, "Anchor Inspector", "dc-anchor-smooth"), @@ -401,6 +402,9 @@ pub struct UiState { /// Type › Find Font…'s last Find In choice (`find_font::FIND_IN`), kept while the app runs. #[serde(skip)] pub find_font_in: String, + /// The Learn panel's tab, skill and tutorial, kept while the app runs. + #[serde(skip)] + pub learn: crate::panels::learn::View, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -1031,6 +1035,7 @@ impl Default for UiState { transient_focus: false, color_settings_scroll: false, find_font_in: String::new(), + learn: Default::default(), control_bar: false, control_bar_bottom: false, control_bar_hidden_groups: Default::default(), diff --git a/crates/ui-egui/src/tests/background.rs b/crates/ui-egui/src/tests/background.rs index add53156e8..a71a55d1fe 100644 --- a/crates/ui-egui/src/tests/background.rs +++ b/crates/ui-egui/src/tests/background.rs @@ -122,6 +122,29 @@ fn exports_run_in_the_background_and_the_preference_turns_both_off() { assert!(app.background.jobs.is_empty()); } +/// A Background Export with a file per artboard checks for files there from its worker: one with +/// an artboard file's name is asked about once the export ends, before anything is written, and +/// the question's OK writes every file. +#[test] +fn a_background_export_asks_before_replacing_an_artboard_file() { + let gate = Gate::default(); + let (mut app, files) = app(gate.clone()); + app.services.exists = Some(Arc::new(|p: &str| p == "/out/a-B.png")); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("artboard.new", json!({"x": 200, "y": 0, "width": 100, "height": 80, "name": "B"})).unwrap(); + let r = io::export(&mut app, Some("png"), Some("/out/a.png".into()), &json!({"useArtboards": true})).unwrap(); + assert_eq!(r["background"].as_bool(), Some(true), "{r}"); + gate.open(); + background::wait_all(&mut app); + assert!(files.lock().unwrap().is_empty(), "nothing is written while it asks"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (crate::dialogs::confirm::REPLACE_FILES, "“a-B.png” already exists. Replace it?")); + crate::dialogs::confirm(&mut app).unwrap(); + background::wait_all(&mut app); + let names: Vec = files.lock().unwrap().iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(names, ["/out/a-Artboard-1.png", "/out/a-B.png"]); +} + /// A save that returns before the file is written still says what it writes and which options it /// does not use, as `app.save` (MCP's `save_file`) replies with it: what the format loses first, as /// the reply of a save written at once. diff --git a/crates/ui-egui/src/tests/nativeoptions.rs b/crates/ui-egui/src/tests/nativeoptions.rs index 5f136d514c..e97443eb36 100644 --- a/crates/ui-egui/src/tests/nativeoptions.rs +++ b/crates/ui-egui/src/tests/nativeoptions.rs @@ -64,6 +64,98 @@ fn save_as_native_asks_for_its_options_and_writes_every_artboard() { assert_eq!(written.borrow().len(), 10); } +/// The paths written so far. +fn names(written: &crate::tests_svg::Written) -> Vec { + written.borrow().iter().map(|(p, _)| p.clone()).collect() +} + +/// Save As with a file per artboard: a file there with an artboard file's name, which the save +/// panel did not ask about, is asked about before anything is written. Cancel writes nothing; OK +/// writes every file, and Save to the document's own file then writes them again without asking. +#[test] +fn save_as_asks_before_replacing_an_artboard_file_that_is_there() { + let (mut app, written) = app("/tmp/doc.vectorcraft"); + three_boards(&mut app); + let there = ["/tmp/doc-B.vectorcraft", "/tmp/doc-D.vectorcraft", "/tmp/doc-E.vectorcraft"]; + app.services.exists = Some(std::sync::Arc::new(move |p: &str| there.contains(&p))); + let pick = |app: &mut crate::VectorcraftApp| { + app.run("file.saveAs", Value::Null).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("separateArtboards".into(), json!(true)); + d.fields.insert("all".into(), json!(true)); + dialogs::confirm(app).unwrap() + }; + let r = pick(&mut app); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (dialogs::confirm::REPLACE_FILES, "“doc-B.vectorcraft” already exists. Replace it?")); + assert_eq!(d.fields["files"], json!(["/tmp/doc-B.vectorcraft"])); + assert!(written.borrow().is_empty(), "nothing is written while it asks"); + dialogs::cancel(&mut app); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert!(app.session.active().unwrap().path.is_none()); + pick(&mut app); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/tmp/doc.vectorcraft", "/tmp/doc-Artboard-1.vectorcraft", "/tmp/doc-B.vectorcraft", "/tmp/doc-C.vectorcraft"]); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft")); + app.run("file.save", Value::Null).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(written.borrow().len(), 8); + // A new artboard's file is not one the last save wrote: Save asks about it alone. + app.run("artboard.new", json!({"x": 600, "y": 0, "width": 100, "height": 80, "name": "D"})).unwrap(); + let r = app.run("file.save", Value::Null).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["files"], json!(["/tmp/doc-D.vectorcraft"])); + assert_eq!(written.borrow().len(), 8); + // An artboard added while it asks writes a file the question did not name: OK asks about it. + app.run("artboard.new", json!({"x": 800, "y": 0, "width": 100, "height": 80, "name": "E"})).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["files"], json!(["/tmp/doc-E.vectorcraft"])); + assert_eq!(written.borrow().len(), 8); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(written.borrow().len(), 14); + assert!(!app.session.active().unwrap().is_dirty()); +} + +/// A question asked when a Background Save or Export ends waits for a dialog open then to close. +#[test] +fn the_question_waits_for_an_open_dialog() { + let (mut app, _) = app("/tmp/doc.vectorcraft"); + let uid = app.session.active().unwrap().uid; + dialogs::confirm::tell(&mut app, "Open", ""); + dialogs::confirm::ask_replace_files(&mut app, uid, &["/tmp/doc-B.vectorcraft".into()], &[], "file.save", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("message")), Some("Open".to_string())); + dialogs::cancel(&mut app); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE_FILES)); +} + +/// Export with a file per artboard asks the same way, naming every file that is there; OK writes +/// them. +#[test] +fn export_asks_before_replacing_artboard_files_that_are_there() { + let (mut app, written) = app("/tmp/art.png"); + three_boards(&mut app); + let there = ["/tmp/art-B.png", "/tmp/art-C.png"]; + app.services.exists = Some(std::sync::Arc::new(move |p: &str| there.contains(&p))); + let r = crate::io::export(&mut app, Some("png"), Some("/tmp/art.png".into()), &json!({"useArtboards": true})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::REPLACE_FILES, "{r}"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("message").as_str(), d.str("detail").as_str(), d.str("__command").as_str()), + ("“art-B.png” already exists. Replace it?", "art-B.png\nart-C.png", "file.exportAs") + ); + assert!(written.borrow().is_empty()); + // Another document made active meanwhile: OK exports the one that asked. + let uid = app.session.active().unwrap().uid; + let question = app.ui.dialog.take(); + app.run("file.new", json!({"width": 50, "height": 50})).unwrap(); + app.ui.dialog = question; + dialogs::confirm(&mut app).unwrap(); + assert_eq!(names(&written), ["/tmp/art-Artboard-1.png", "/tmp/art-B.png", "/tmp/art-C.png"]); + assert_eq!(app.session.active().map(|d| d.uid), Some(uid)); +} + #[test] fn save_a_copy_and_save_dont_ask_and_options_reach_the_file() { let (mut app, written) = app("/tmp/plain.vectorcraft"); diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index c1e3f822cd..90e6fdc78d 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -539,7 +539,7 @@ fn preset_dialogs_export_asks_off_the_ui_thread() { #[test] fn a_sheets_answer_given_its_extension_asks_before_replacing() { let (mut app, shown, written) = app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/card.vectorcraft")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/card.vectorcraft")); app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); assert!(app.run("file.save", json!({})).is_err()); shown.answer(&["/art/card"]); @@ -566,7 +566,7 @@ fn a_preset_dialogs_export_asks_before_replacing_over_it() { w.borrow_mut().push(path.to_string()); Ok(()) })), - exists: Some(Box::new(|_: &str| true)), + exists: Some(std::sync::Arc::new(|_: &str| true)), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/tests/saveext.rs b/crates/ui-egui/src/tests/saveext.rs index 497ac0c749..b8bb2ee6e4 100644 --- a/crates/ui-egui/src/tests/saveext.rs +++ b/crates/ui-egui/src/tests/saveext.rs @@ -215,7 +215,7 @@ fn a_typed_extension_of_a_format_the_save_does_not_write_is_replaced() { #[test] fn a_replaced_extension_asks_before_replacing_a_file_there() { let (mut app, shown, written) = portal_app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/card.vectorcraft")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/card.vectorcraft")); assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/card.png"]), "/art/card.vectorcraft"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE)); assert!(written.borrow().is_empty()); @@ -254,7 +254,7 @@ fn a_document_in_another_format_saves_as_the_type_picked() { #[test] fn a_name_given_the_picked_types_extension_asks_before_replacing() { let (mut app, shown, written) = portal_app(); - app.services.exists = Some(Box::new(|p: &str| p == "/art/poster.ai")); + app.services.exists = Some(std::sync::Arc::new(|p: &str| p == "/art/poster.ai")); assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/poster", "ai"]), "/art/poster.ai"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE)); assert!(written.borrow().is_empty()); @@ -284,7 +284,7 @@ fn on_disk(tag: &str, typed: &str) -> (VectorcraftApp, std::path::PathBuf) { let picked = dir.join(typed).to_string_lossy().into_owned(); let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), - exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), + exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())), pick_save: Some(Box::new(move |_: &FilePick| Some(picked.clone()))), ..Default::default() }; @@ -371,7 +371,7 @@ fn every_write_through_a_save_panel_asks_before_replacing() { ]; for (cmd, params, path) in cases { let (mut app, w) = saving_app("/tmp/out"); - app.services.exists = Some(Box::new(|_: &str| true)); + app.services.exists = Some(std::sync::Arc::new(|_: &str| true)); app.run("select.all", json!({})).unwrap(); assert!(app.run(cmd, params).is_err(), "{cmd}"); assert!(w.borrow().is_empty(), "{cmd}: nothing is written while it asks"); @@ -382,7 +382,7 @@ fn every_write_through_a_save_panel_asks_before_replacing() { } // Export As: the question's OK picks the file again, and PNG Options follows. let (mut app, w) = saving_app("/tmp/out"); - app.services.exists = Some(Box::new(|_: &str| true)); + app.services.exists = Some(std::sync::Arc::new(|_: &str| true)); app.run("file.exportAs", json!({"format": "png"})).unwrap(); assert!(dialogs::confirm(&mut app).is_err()); let open = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.str("path"))); diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index d42be49ff9..7c93308ec5 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -208,6 +208,34 @@ fn type_on_a_path_offers_dynamic_text() { assert!(!has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Type on a closed path offers Center on Top and Center on Bottom; type on an open path does not. +#[test] +fn type_on_a_closed_path_offers_center_on_top_and_bottom() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let line = id(app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()); + let open = id(app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()); + select(&mut app, &[open]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Type on a Path Options") && !has(&texts, "Center on Top") && !has(&texts, "Center on Bottom"), "{texts:?}"); + let circle = id(app.run("path.create", json!({"d": "M100 300 A100 100 0 1 1 300 300 A100 100 0 1 1 100 300 Z"})).unwrap()); + let t = id(app.run("text.createInPath", json!({"path": circle, "mode": "onPath", "text": "Round", "at": [90, 300]})).unwrap()); + select(&mut app, &[t]); + let start = |app: &VectorcraftApp| match kind(app, t) { + NodeKind::Text(t) => match t.kind { + TextKind::OnPath { start, .. } => start, + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Center on Top") && has(&texts, "Center on Bottom"), "{texts:?}"); + click(&mut app, "Center on Top"); + assert!((start(&app) - 0.75).abs() < 1e-3, "{}", start(&app)); + click(&mut app, "Center on Bottom"); + assert!((start(&app) - 0.25).abs() < 1e-3, "{}", start(&app)); +} + /// Area type offers Dynamic Text beside Convert To Point Type, which turns Block on and off; area /// type in a thread does not. #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e8c9393deb..46fde93aad 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -828,6 +828,23 @@ mod gradient_chip_tests { assert!(chip.iter().all(|c| c.a() == 0), "{chip:?}"); } + /// A Holographic glow's chip is clear at the corners and nearly opaque at the center; a foil's + /// chip is opaque everywhere. + #[test] + fn holographic_chips_show_a_glow_clear_at_its_edge_and_a_foil_opaque() { + use vectorcraft_color::Paint; + let lib = vectorcraft_color::libraries::builtin_library("holographic").unwrap(); + let chip = |name: &str| match &lib.swatch(name).unwrap().paint { + Paint::Gradient(g) => gradient_chip_colors(g, 20.0, 20.0, 4, 4), + p => panic!("{name}: {p:?}"), + }; + let glow = chip("Mint Halo"); + assert!([0, 4, 20, 24].iter().all(|&i| glow[i].a() == 0), "{glow:?}"); + assert!(glow[2 * 5 + 2].a() >= 240, "{glow:?}"); + let foil = chip("Prism Veil"); + assert!(foil.iter().all(|c| c.a() == 255), "{foil:?}"); + } + #[test] fn freeform_chip_mixes_points_in_the_model_they_share() { use vectorcraft_color::{Color, Freeform, FreeformPoint}; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3a32baa99e..6619cffd6f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -111,7 +111,7 @@ the two can still win that race. | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. A save or export that writes other files beside that path (a file per artboard, linked images, image maps) checks them before it writes anything: when any of them exists, it writes nothing and opens a `replaceFiles` dialog (fields `message`, `detail` (each file's name when there are several), `files`, `__command`, `__params`; the reply is `{"pending": "replaceFiles"}`, or comes once a Background Save or Export ends): `ui.dialog.confirm` runs the save or export again and replaces them, `ui.dialog.cancel` writes nothing; a dialog open when a background job asks stays open, and the question follows it. A save does not ask about the files the document's last Save or Save As in this session wrote. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, width?, height?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in PNG and JPEG files (WebP files store no resolution); `width` or `height` sets the size of each file in pixels instead (`width` wins), and PNG and JPEG files store the resolution that results; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | @@ -807,6 +807,9 @@ EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`. +HP-GL: Export As… lists it (`file.exportAs {format: "hpgl"}`); it has no options dialog, so `ui.dialog.confirm` asks +for the path and writes the `.plt` file with the default options (one per artboard with Use Artboards). + TIFF, BMP and Targa Options: Export As… with the TIFF, BMP or Targa format (`file.exportAs {format: "tiff"}`, then the save dialog) opens `tiffOptions`, `bmpOptions` or `tgaOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then TIFF's `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK document starts on `cmyk`), diff --git a/docs/development.md b/docs/development.md index 2362fc6f1a..e7831adf1b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development -> **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending. +> **Last updated:** 2026-11-12 · **Change:** minor (clipboard failure helper follows its Linux/BSD and test callers; previously: Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending). > **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. > **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. > **Last updated:** 2026-10-29 · **Change:** Linux clipboard: the Flatpak on GNOME copies and pastes text through the core Wayland protocol. @@ -351,6 +351,10 @@ parameters. Shipped code therefore never panics. Anything that can fail returns ### Enforced by lints +The clipboard timeout failure helper is compiled for Linux/BSD workers and unit tests; production +macOS reads synchronously. Keep helper target gates aligned with their callers instead of disabling +dead-code warnings across the workspace. + `Cargo.toml` denies these clippy lints for the whole workspace, and `cargo xtask ci` runs clippy with `-D warnings`. The gate targets the current stable Rust (`rust-version` in `Cargo.toml` is only the minimum that builds the app, and no toolchain is pinned): when a new stable adds lints, they are fixed @@ -508,6 +512,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Compile the clipboard failure helper only for Linux/BSD worker callers and unit tests; keep production macOS free of the unused helper without suppressing workspace dead-code warnings. | | 2026-11-06 | minor | Localisation: Hindi catalog, zero/one plurals, desktop and web font requirements; AI/MT draft, native review pending. | | 2026-10-31 | minor | Files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance` opts out. | | 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. | diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2602b78bc4..6b14fa0907 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -28,7 +28,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | | SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | -| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) | +| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers its resolution, PDF export warns once per object with the requested and the lowest achieved ppi @@ -181,13 +181,13 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering, -angular cut caps, exact visibility, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable) is 25–45 h. +angular cut caps, exact visibility, the rest of 3D Classic (Map Art), Turntable) is 25–45 h. ## Revision history diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 75c95bb33a..105b0bda80 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -155,7 +155,8 @@ private editing data. VectorCraft: under them, clipped to where they show, along a smooth outline traced at the image's own resolution; that art stays) or omitted (Transparent Images, `transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and - scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline + scaling, and the Standard Vertical Roman Alignment and Tate-chu-yoko marks as `Tv` and `TY`), which the reading + app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline graphics and gradient or pattern fills make that type outlines, and so do rotated characters (Character Rotation, which a version 7 text object can't hold: the outlines keep the rotation, and the type is no longer editable text in the file), and Text: Outlines (`outlineText: true`) writes @@ -291,7 +292,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | -| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | no | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `tests_hpgl.rs`, `import_fuzz.rs` | +| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | @@ -341,6 +342,9 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, after the ways it was tried before (its glyphs alone and grown by half its strokes' weight, #6535, on its own warp and on its saved grid), before a grid moved onto its drawn art, and only where that box is another: its glyph outlines with their fills and strokes baked as the envelope bakes them (geometry effects on them applied: a stroke a Transform effect moves counts where it is moved to), each piece measured by its visual bounds, which are exact for miter joins (a tip counts where the miter limit keeps it, not the miter allowance at every corner that the bounds with strokes take) and round joins and an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes; a contour of zero length, which paints nothing, doesn't count. Effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. The box is measured only when that try comes and the import has a check left for it, and once for an envelope. Groups round type and other objects in the contents are measured as before. In users' files (used locally, never committed) the drawn art of some stroked type in an envelope maps from that box: where the type has sharp corners at its edges (slanted stems, pointed feet) and miter joins, or a stroke moved by a Transform effect (a drop shadow), its glyphs grown by half the weight gave too small a box and the envelope kept its drawn art; and where a glyph holds a contour of zero length at its edge, the glyphs' box counted it. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 161 in 50, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 6 files, by at most 0.94% of their pixels, each where an envelope of type now opens live in place of its drawn art (the page's own art): 1 closer to the file's own page and 5 further; the notes change in the same 6 files: the note on type in an envelope goes where none keeps its drawn art, and a file whose envelopes of type all kept their drawn art, which opened without the page comparison, now opens with it and carries its notes (the share of art or type that differs from the page, type the page has without a text object, the PDF part's note on DeviceN colours). A synthetic file of 256 such envelopes of 2,000 characters each opens in the same time as before (13.3 s in a debug build): the box is measured only when its try comes. Tests `ai::envelope::tests::mitred_type_is_also_tried_mapped_from_what_its_stroke_paints_miter_tips_included`, `type_with_a_mitred_character_stroke_is_also_tried_mapped_from_what_it_paints`, `a_stroke_moved_by_a_transform_effect_counts_where_it_paints`, `round_joined_type_is_not_tried_twice_for_the_same_box`, `a_contour_of_zero_length_paints_nothing`, `a_side_within_the_slack_of_the_geometric_box_is_that_side`, `tests_native::a_warp_of_mitred_type_maps_from_what_its_stroke_paints_where_its_drawn_art_does` (#6824, part of #3179) | +| 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | +| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | +| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | | 2026-11-11 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: type's strokes in an envelope (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them (used locally, never committed: there the drawn art keeps those strokes as stroked paths at the type's own weights, and its ring is as wide wherever the grid squeezes or stretches the type). With Distort Appearance, VectorCraft bent them with the art, as their filled outlines, so an envelope of stroked type in a squeezed or stretched grid didn't draw as its drawn art and kept it. The same holds in an envelope inside an envelope. Paths' strokes, geometry effects, opacity masks, linear gradients and pattern fills bend as before. On 224 of our own files: envelopes of type that open live go from 135 in 37 files to 154 in 45, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 31 files, by at most 2.3% of their pixels, 19 closer to the file's own page and 12 further, 11 of those where an envelope of type now opens live in place of its drawn art (the page's own art) and one by 4 pixels. Tests `envelope_distort::type_strokes_paint_at_their_own_weight_in_a_squeezed_envelope`, `a_stroke_on_types_appearance_paints_at_its_own_weight_in_a_squeezed_envelope`, `a_paths_stroke_still_bends_in_a_squeezed_envelope`, `tests_native::an_envelope_of_stroked_type_whose_drawn_art_strokes_the_bent_outlines_at_the_weight_opens_live` (#6644) | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | diff --git a/docs/gaps.md b/docs/gaps.md index 612171d107..83afa40942 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -630,8 +630,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (seven original libraries generated in code: Art Ribbons, Calligraphic Nibs, Bristle Brushes (one + bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -652,7 +652,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Fine Grain, Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). @@ -664,7 +664,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, the rest of 3D - Classic (Revolve, Rotate, Map Art), Turntable, textures, map art and shading in 3D export. + Classic (Map Art), Turntable, textures, map art and shading in 3D export. - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. @@ -858,6 +858,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | An imported document's own title (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | diff --git a/docs/mcp.md b/docs/mcp.md index 7bf5ae9956..fa22c66414 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -165,6 +165,14 @@ the suite) keep the agent to the folders you give it: and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other fields. This applies to every document, also one the person or a client with wider roots opened. The paths a client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed. +- `image.info` gives `link: null` for a linked file outside the read roots, as `links.info` leaves it out; its id, + name, `linked` and the other fields stay. `links.findFiles` and `text.findFontFiles` replies, including later + `{}` and `{stop: true}` calls on a search another client started, leave out the files found outside the read + roots (with their `size` and `match`), a sought link's `path` outside them, and `folder` when the folder searched + is outside them; `error` then gives the reason without the path (for example `the folder can't be read`). The + rows keep their `name`, `ids`, family, style and status, and the counts stay. `text.addFontFiles` gives + `folder: null` when VectorCraft's Fonts folder is outside the read roots, and only the file name in `copied` and + `kept` `to` and in `skipped` `file` for a path outside them; the fonts are still copied. - `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show a folder preference outside the read roots (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`) as `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and @@ -463,7 +471,14 @@ or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseT counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper.release`, `shaper.expand`, `transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and -change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a +change nothing; an omitted or null `id` keeps the default. Commands that need an object id (`document.node`, the +`path.*` anchor and handle commands, `text.reshapePath`, `text.editRange`, the `object.mesh.*` point commands, +`layer.target`, `node.move`, `select.magicWand` and the others like them, and the `source`, `target`, `group` and +`path` keys of `appearance.transfer`, `appearance.copyFrom`, `layer.move`, the `livePaint.*` commands and +`text.createInPath`) refuse a value that is not a non-negative integer with "`id` must be a whole number", naming the +key, and report a key left out or null as missing. The optional `group` of `livePaint.fill` and `parent` of +`node.move` are refused the same way when given; left out, they keep their defaults. `livePaint.info` reads `id` when +`group` is left out or null, and reports a missing group only when both are left out. A command that leaves out an object selected inside a selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes `ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key, @@ -527,7 +542,7 @@ applied colors still join the list while it is hidden. | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | -| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | +| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `hpgl` (a `.plt` plot file for plotters and cutters), `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF, WMF and HP-GL write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". | @@ -1873,8 +1888,13 @@ no engines, and the engines it left are dropped when their own thread next uses and the other eight are kept. Each named engine has a heap of 64 MiB, which holds what it keeps between runs; a run that goes past it ends with the heap limit, and the engine stays usable. - The documents go back after an uncaught error, a limit or `script.stop` as they do for any run, and the engine keeps - the globals the run set before it ended. An internal error drops the engine. An error in a function an earlier run - defined is reported without a line and column. + the globals the run set before it ended. An internal error drops the engine. +- An error in code an earlier run compiled, such as a function it defined, is reported at that code's line and column + in the earlier run's script, a file it included or a file its `$.evalFile` ran, and the report's `file` names that + file. For an earlier script given as `source`, `file` is that run's name. The report's `name` and the console line's + name stay the calling run's, as in `caller.js: /path/lib.jsx:12:5: Error: kept`. Each engine keeps these places + for 8 MiB of source maps, the oldest going first; an error in code whose place is gone, or in code that `eval` or + `Function` compiled, is reported without a line and column, and the code still runs. - `vc`, `$`, `alert`, `confirm` and `prompt` act for the run that calls them, including a copy an earlier run kept in a global. `vc.args` is the calling run's `args`, through a `vc` object an earlier run kept too. An engine holds no document: `vc.document` is a copy, read when it is called. What a run sets on `$` (`$.locale`, @@ -2450,6 +2470,26 @@ kept with the workspace, and built-in workspaces and workspaces saved without it {"name":"invoke_menu","arguments":{"command":"window.toolsBesideCanvas","params":{"on":true}}} ``` +## Learn panel + +Window › Learn and the Explore tutorials button beside the search icon at the right end of the app bar show and hide +the Learn panel. Its For you tab holds a Get started section with a Quick skill guide card; choosing the card opens +the Skills tab. The Skills tab lists VectorCraft's own skills, each with its number of tutorials and an empty progress +ring. Choosing a skill lists its tutorials, choosing a tutorial shows its numbered steps, and Back goes up one level. +The tutorial text is English. `window.learn {show?}` shows the panel (`show: true`), hides it (`show: false`) or +toggles it (`show` omitted or null), and answers whether it shows; Window › Learn and Explore tutorials run it. +The panel counts as hidden while the dock is hidden or a full screen mode hides the panels. Showing it shows a hidden +dock; in a full screen mode it makes Learn the front panel, which shows when the mode ends, and answers false. Any +other value of `show` is an error and changes nothing. `learn.list {}` → `{skills: [{id, title, tutorials: [{id, title, steps}]}]}`, in the order the Skills tab +lists them, where `steps` is the number of steps. `learn.show {tutorial}` shows the panel on its Skills tab with that +tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error +and changes nothing. Behavior reference: +[Quick skill guide](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html). + +```json +{"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} +``` + ## Ruler guides Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the @@ -2590,8 +2630,9 @@ already walks Image Trace. Short agent entry points: locked or sits in a hidden or locked group or layer. Choosing another selection tool selects the instance whole again. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the - new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own - art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and + new art still has, less a fill or stroke the object now at that path does not draw (a change left empty is dropped); + `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own art, and each edit + there keeps the changes the same way; making the symbol static drops them, and so does `symbol.replace` with a different symbol (a replace with the instance's own symbol keeps them). ```json @@ -2964,6 +3005,21 @@ Tile, Start Tile or End Tile. A tile with a repeat frame has an unpainted rectan the frame. A symbol instance in the art is measured by its symbol's art. A calligraphic or bristle brush is refused with "`<name>` has no art (only scatter, art and pattern brushes do)". +`brush.replaceArt {name, ids?|id?, applyToStrokes?}` makes the selected art the brush's art and keeps its other +options; several objects become one group, as in `brush.new`. A pattern brush takes a group named for a tile, or a +group of such groups, tile by tile: an unpainted rectangle at the back of a tile group becomes that tile's repeat frame, +a tile group's art goes back without that group, and the tiles that are not given stay as they are. Of two groups named +for the same tile, the frontmost one counts. Other art becomes the side tile. When strokes use the brush and +`applyToStrokes` is `false`, the brush and its strokes stay as they were, and a copy of the brush with the new art, +named "`<name>` copy", goes after it and becomes the current brush. Otherwise, and for a brush no stroke uses, the brush +changes and its strokes redraw. It replies `{name}`, the brush that has the new art, and refuses a calligraphic or +bristle brush, an empty selection and art (or a tile it is given, such as a tile group left with only its frame's +rectangle) with no width. `brush.replaceArt` and `brush.new` measure a symbol instance in the art by its symbol's art. +In the app, art Alt-dragged (Option-dragged on macOS) from the canvas onto a scatter, art or pattern brush in the +Brushes panel runs it. When strokes use the brush, the app checks the art first and shows why in the status bar when +the brush cannot take it; otherwise a dialog (`brushReplaceArt`) asks: Apply to Strokes, Leave Strokes +(`applyToStrokes: false`) or Cancel. + `variable.bind`, `variable.unbind`, `variable.delete`, `dataset.set`, `dataset.update`, `dataset.delete`, `dataset.select`, `dataset.next` and `dataset.prev` add no undo step when they leave the document as it was, such as binding art to a variable it already has or applying the active data set again: the steps to redo stay, and a saved @@ -3123,8 +3179,8 @@ See [Revolve](revolve.md) for the scope and limitations. replaces the lighting rig and ambient of an existing Revolve, Extrude & Bevel, Inflate or Rotate effect. `item: null` addresses object-level effects; an item index addresses a fill/stroke's effects. All targets must have a 3D effect at `index`, otherwise nothing changes. Extrude & Bevel -(Classic) has no lighting presets: a target with it at `index` is an error, nothing changes, and -`effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. +(Classic), Revolve (Classic) and Rotate (Classic) have no lighting presets: a target with any of them at `index` is an +error, nothing changes, and `effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. The four original rigs use one white front light at 80% intensity. Standard keeps the existing default; Diffuse uses 100% Softness and 35% Ambient Light, Top Left uses Rotation 135 and Height @@ -3156,7 +3212,7 @@ art", and a loaded file draws the source path, as it does for a solid past its l documentation names the controls and their ranges ([3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)). The settings are saved in the native file; SVG and PDF exports draw the shadow, and `effect.expandAppearance` keeps -it as a path. Extrude & Bevel (Classic) has no shadow. See [three-d.md](three-d.md#shadows). +it as a path. Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) have no shadow. See [three-d.md](three-d.md#shadows). ```json {"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"shadow":true,"shadowPosition":"below","shadowDistance":10,"shadowBounds":150}}}} @@ -3232,6 +3288,51 @@ an unknown surface or shading color is refused with an error and the document is {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":40,"bevel":"rounded","bevelHeight":6,"surface":"diffuse","blendSteps":64}}}} ``` +## Revolve (Classic) + +`effect.apply {effect: "threeD.classicRevolve", params: {...}, ids?, item?}` is Effect › 3D and Materials › +3D (Classic) › Revolve (Classic)…. It sweeps the profile round a vertical axis as `threeD.revolve` does, at 64 segments +for a full turn, and draws every face, back to front, with the classic surfaces. Parameters: `angle` (degrees, +0..360, default 360), `cap` (true closes the profile's open ends, false leaves the surface hollow), `offset` (points, +0..1000, default 0; the axis's distance from the profile, so a value above 0 makes a ring), `edge` (`left`, the +default, or `right`: the edge of the profile's bounds the axis is at), `rotationX/Y/Z` and `perspectiveAngle` as +above, and `surface`, the lights, `ambient`, `highlightIntensity`, `highlightSize`, `blendSteps`, `shadingColor` and +`shadingCustom` as Extrude & Bevel (Classic) takes them. It has no `drawHiddenFaces`. The effect also goes on a group +or layer, whose members turn about one shared axis, or on a fill or stroke. The public documentation describes the +dialog ([Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)) +and its Angle (0 to 360), Cap, Offset (0 to 1000) and Left Edge or Right Edge settings +([Revolve options](https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=283)); +the defaults are VectorCraft's own. An unknown parameter (`segments` included), a number out of range, a flag that +is not true or false, another `edge`, surface or shading color is refused with an error and the document is not +changed. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicRevolve","item":null,"params":{"angle":270,"offset":10,"surface":"diffuse","blendSteps":64}}}} +``` + +## Rotate (Classic) + +`effect.apply {effect: "threeD.classicRotate", params: {...}, ids?, item?}` is Effect › 3D and Materials › +3D (Classic) › Rotate (Classic)…. It turns every fill and stroke as one flat plane about the center of the object's +bounds, as `threeD.rotate` does, each in its own paint, and draws the plane from either side. Parameters: +`rotationX/Y/Z` and `perspectiveAngle` as above; `surface` (`diffuse`, the default, lights the art softly, and +`none` keeps the paint unlit); and the lights, `ambient`, `blendSteps`, `shadingColor` and `shadingCustom` as Extrude & +Bevel (Classic) takes them. It has no highlight, no `drawHiddenFaces` and no shadow. The effect also goes on a group +or layer, whose members turn about one shared center, or on a fill or stroke. The public documentation describes the +dialog's Position and Surface settings +([Rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)), +the Position presets, rotations from -180 to 180 and Perspective from 0 to 160 +([Set 3D rotation position options](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=270)), +and No Shading and Diffuse Shading as the only surfaces for an object that uses only this effect +([Surface shading options](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=271)). +The defaults and the shading are VectorCraft's own. An unknown parameter (`highlightSize` and `drawHiddenFaces` +included), a number out of range, a flag that is not true or false, a surface other than `none` or `diffuse`, or +another shading color is refused with an error and the document is not changed. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicRotate","item":null,"params":{"rotationX":0,"rotationY":40,"rotationZ":0,"surface":"diffuse","blendSteps":64}}}} +``` + ## Edit Colors and Recolor Artwork @@ -3356,6 +3457,16 @@ that shape, and the effects before it are left out. Its Contents row stands for vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the image. Use Preview Bounds measures an image by its frame. +A Warp effect (`warp.arc` … `warp.twist`) on an image, or on a group that holds one, bends the image's pixels with its +frame: after the last effect in the list, the image is cut into 10 × 10 cells of two triangles, each a clipped piece of +the image under its own affine map. The cell count is VectorCraft's own. Effects after the Warp reshape only the frame, +a second Warp bends the same pieces, and a Transform effect moves them. A placed gradient on a group that holds the +image follows the group's bounds as the pieces draw them. The pieces keep the +image's fill color when it colorizes a grayscale image, and so do the pieces an envelope cuts. Under any geometry +effect, an image keeps that color and is drawn with or without smoothing as the image is. SVG writes the image once and +draws the pieces with `<use>`; PDF, EPS, `.ai` and PNG write the bent pieces. Removing the warp shows the image as it +was. The images stay unbent when their pieces would make the art pass 2 million objects, and an image stays unbent when +its clipping frame keeps its shape because the warp would pass the effects' anchor budget. ```json {"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}} @@ -3482,8 +3593,8 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Calligraphic Nibs, Bristle +Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). @@ -5074,7 +5185,8 @@ Pen's second anchor at (150, 431), in line with the square's centre. `type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and clears manual kerning in the type objects in `ids`, the Type tool's selected text or the selected type objects, keeping Optical; at a caret - it resets only the kerning of the character before it. `type.resetHorizontalScale` (Cmd+Shift+X, + it resets only the kerning there: of the character before it, or at the start of a line (of the text, a paragraph, + or after a forced line break) the space before the character after it (`kerningBefore`). `type.resetHorizontalScale` (Cmd+Shift+X, `press_key {key: "X", mods: {cmd: true, shift: true}}`) sets the horizontal scale to 100%, as the Character panel shows it, in the same text; at a caret it leaves the text as it is. In the app, `type.highlightKerning` (Cmd+Alt+K), `type.highlightTracking` (no default key) and `type.highlightFontName` (Cmd+Alt+Shift+F) show the Character panel and @@ -5679,13 +5791,23 @@ They also write the `copyrightNotice` as `dc:rights`, the `copyrightUrl` as `xmp `copyrightStatus` as `xmpRights:Marked` (`True` for copyrighted, `False` for public domain, none for unknown) to the XMP packet. A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`, -`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `author`, -`description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` that its XMP -packet (`dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, `dc:rights`, -`xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary (`Author`, -`Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give one. A value that -`file.info` would refuse is left out, and the title is set as for any other open (the file name, or a new untitled -name for a template). +`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `title`, +`author`, `description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` +that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, +`dc:rights`, `xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary +(`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give +one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its +root `<title>` element (not a shape's or group's). A title is read with its white space made single spaces and +without control characters, at most 65,536 characters, as `file.info` takes one. A value that `file.info` would +refuse is left out. A file that gives no title, a blank one or the `Untitled-N` name New gives leaves File Info with +the document's name as its title, and a template opens with a new untitled name. +The title a file gave is File Info's alone: the document's name, which is what its tab shows until it has a file and +what Save As and the default export names start from, stays the file's name with its extension (`box.svg`), or the +title a native file holds. File Info's `title` is the one the file gave, else the document's name. It is saved with the +document (a native file, and the editing data of an SVG, PDF, `.ai` or EPS that carries it), and the files an export +writes carry it (PDF `Title` and XMP `dc:title`, SVG `<title>` and Dublin Core, PNG text, EPS and `.ai` `%%Title`), +where the document has one. Changing the `title` with `file.info` sets the document's name, as it always did, and drops +the title the file gave. `document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as `fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies). @@ -6412,13 +6534,68 @@ from the origin, so P2 lies there when the file sets no scaling points. `SP0` draws nothing. Labels, the symbols of symbol mode (`SM`) and raster images are not drawn, the input window (`IW`) does not clip the art, the art of later pages is drawn over the first page, and hatched and patterned fills are filled solid; `warnings` lists each of these. A file named as a plot file that doesn't start with plotter -instructions is refused with "not an HP-GL file". VectorCraft does not write HP-GL. +instructions is refused with "not an HP-GL file". ```json {"name":"open_file","arguments":{"path":"/tmp/part.plt"}} {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/part.plt","at":[300,200]}}} ``` +## HP-GL export + +`document.export` (and `export`, and Export As) with format `hpgl` writes a plot file (`.plt`) of the first artboard +(or `artboard`) for pen plotters and cutters; `useArtboards: true` writes one file per chosen artboard +(`{stem}-{artboard}.plt`), `false` the bounds of the visible art. Each visible path is drawn once along its own line: +a filled shape is its closed outline, also when a stroke gives its color, and a stroked path is its center line, so +fills, stroke widths, dashes, brushes and arrowheads are not painted. Type is drawn as its glyph outlines, and a +gradient mesh as its outline, also inside a blend (a mesh whose every point is at 0% opacity draws nothing). A +symbol instance is drawn as its symbol's art with the art's effects, in the colors of the instance's stain. +Clipping masks cut the art they clip, and the art is cut at the artboard's edges. Hidden and non-printing layers, +hidden objects, objects at 0% opacity, template layers and guides are left out, and so are placed images, with a +warning. Coordinates are plotter units (1016 to the inch, y up) from the artboard's bottom-left corner, or its +center with `origin: "center"`, rounded to whole units, as the HP-GL/2 and HP RTL Reference Guide defines them +([book listing](https://informit.com/store/hp-gl-2-and-hp-rtl-reference-guide-a-handbook-for-program-9780201310146); +the [vendor's support page](https://support.hp.com/sg-en/document/c01121263) names it as the language reference). +The guide also gives the instruction syntax and the encoding of `PE` numbers that the writer follows. Art more than +8,388,607 units (about 210 m) from the origin is cut off, with a warning. + +Each color is a pen. Pen 1 is the first color the plot draws in, pen 2 the next new one, and so on, up to 255 pens; +colors past those draw with the pen of the nearest color. The files of one export share the numbering, so a color is +the same pen in each file. `pens: ["#000000", "#ff0000"]` gives the pen table instead (pen 1 first): each path draws +with the pen of the nearest color, the lower pen when two are as near. A path's color is its topmost visible stroke's, +else its topmost visible fill's, where a fill or stroke at 0% opacity does not count. A gradient's color is the average +of its stops, a gradient mesh's the average of its points' colors (points at 0% opacity left out), and a pattern's its first visible solid fill (gray +when it has none). The pens draw in turn, each pen's paths in paint order, or with `sortPaths: true` each next path +the one that starts nearest where the pen stopped (an open path may be drawn from its other end, and a closed one +starts at its point nearest the pen). With `insideFirst: true`, a path is drawn only after every path of the same +pen that lies inside it, for example a letter's counter before the letter and a part's holes before the part. A +path lies inside a closed path when it does not cross that path's line, every part of it lies inside that path (by +the even-odd rule) or on its line, and some part lies inside: a path touching the line around it is inside, a path +crossing it or leaving it through a corner is not, and copies of one outline are not inside each other. Without +`sortPaths` the paths otherwise keep paint order; with it, the next path is the nearest of those free to draw. +Curves become straight moves no further than `curveTolerance` points from the curve (default 0.1, from 0.01 to 10). +These defaults, the 255-pen limit and the rules for pen numbers, colors and the order of paths above are +VectorCraft's own. + +`hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP<n>;` and each path `PUx,y;PDx,y,…;`, and +ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's +paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. An `"hpgl"` plot +with a coordinate outside −32,768 to 32,767 units (about 82 cm from the origin, the range of 16-bit numbers) is written +as it is, with a warning that older devices may not read it; this check is VectorCraft's own. `warnings` names +type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke +looks that are not drawn, art past the plotter's range, HP-GL coordinates past 16-bit numbers, colors past 255 pens, +and the limits on points, clipping work, symbols, live art, nesting depth and sorting. Sorting and finding which paths lie inside which share one +work budget for the plot, 500 million box and point comparisons. When sorting runs out of it, the paths not yet +drawn keep paint order, each after the paths inside it with `insideFirst`. When it runs out while the writer is +finding which paths lie inside which, the paths of that pen and of the later pens keep paint order. Both cases add +a warning. + +```json +{"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/sheet.plt","useArtboards":true,"hpglVersion":"hpgl2","pens":["#000000","#ff0000"],"sortPaths":true}}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/stencil.plt","sortPaths":true,"insideFirst":true}}} +``` + ## TIFF, BMP and Targa export `document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one @@ -7217,6 +7394,25 @@ Panel sets it back to Push Out Only. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} ``` +## Korean Line Break Anywhere + +`text.setFormat {koreanLineBreakAnywhere: bool}` (Paragraph panel menu › Korean Line Break Anywhere, shown with the +East Asian options) turns it on or off for the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / +`end` range touches). Off, a Korean word (the characters between two spaces, when any of them is Hangul, Han and kana +included) breaks only at its spaces, after East Asian punctuation in it, and at forced line breaks, so a word that does +not fit moves to the next line whole; a word wider than the line still breaks where it overflows. On, a line may break +between any two Hangul syllables. Both composers and vertical type follow it, No Break and the Kinsoku Set still apply, +and hyphenation never adds a hyphen to Hangul. Text without Hangul keeps its own breaks. New type and imported text have +it off; paragraphs saved before the option open with it on, the breaks they were laid out with. It is saved with the +paragraph (`korean_line_break_anywhere` in `para`) only when off, paragraph styles carry it (Paragraph Style Options › +East Asian Composition Settings), and Reset Panel turns it off. A value that is not true or false is refused. One undo +step. `.ai` editing data does not hold it, and a save reports a Korean paragraph with it on as left out. The +help page: https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/set-line-breaks-korean-text.html + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"koreanLineBreakAnywhere":true}}} +``` + ## Hanging punctuation (burasagari) `text.setFormat {burasagari: "none"|"standard"|"forced"}` sets the Paragraph panel menu's Burasagari (None / Regular / @@ -7776,6 +7972,26 @@ begins or ends, dragging the centre bracket slides the type along its path and, {"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}} ``` +`type.pathCenter.top {ids?|id?}` and `type.pathCenter.bottom {ids?|id?}` (Type › Type on a Path › Center on Top and +Center on Bottom, and the Contextual Task Bar for type on a closed path) slide the selected type on a closed path along +it so that the middle of the span between its brackets lands on the path's topmost or bottommost point. This is +VectorCraft's own behavior. The path is measured as the object is drawn, after its transform, so a rotated, scaled or +skewed circle centers on its drawn top. On a level stretch, such as a rectangle's top edge, the middle of the stretch +is used; with several separate topmost points, the middle of the shortest part of the path that holds them all is +used. The span keeps its length, and type that runs once round stays once round. The first paragraph's alignment +becomes Center unless it is Center or Justify Center, so the type is centered there. The type is not flipped: on the +bottom of a circle, whose path runs clockwise, it reads upside down outside the circle until `type.pathOptions {flip: +true}` turns it inside. Point type and area type in the selection are left as they are. Type on an open or compound +path, type in a thread, and type on a path with no length or no height are refused with an error that names them, +and nothing changes. The menu items are enabled, and the task bar shows them, only while every selected type on a +path is on one closed path and none of it is in a thread. Several objects move in one undo step. When every object is +already centered there, nothing changes and no undo step is recorded. Each replies `{ids, start, end}` with the first +object's brackets. + +```json +{"name":"run_command","arguments":{"command":"type.pathCenter.top","params":{}}} +``` + ## Dynamic Text `type.dynamicText {on?: bool, ids?}` (Type › Dynamic Text, and the Contextual Task Bar for type on a path) turns @@ -7793,12 +8009,12 @@ its look, in one undo step. Point type in the selection is left as it is, and ar ([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; `text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and -list them in `dynamicTextOff`. When every object already has the setting asked for, nothing changes and no undo step +list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step is recorded. It replies `{on, ids, fitScale}` (the first object's factor, 1 when off), and the menu item is checked while the first selected type on a path or area type has it on. `document.inspect` reports `dynamicText` and `fitScale` for type -on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS and the -editing data for other apps write the type at its drawn sizes; the editing data does not hold the setting and says -so in a warning. +on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS, legacy +`.ai` and the editing data for other apps write the type at its drawn sizes; legacy `.ai` and the editing data do not +hold the setting, and each says so in a warning. ```json {"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true}}} @@ -7841,8 +8057,9 @@ targeted objects, as before. With a range, the character attributes of `text.set that range of characters. When every paragraph that `text.tabs.set` or `text.tabs.clear` targets already has those stops, the command changes nothing and records no undo step. `text.tabs.set` rejects a supplied `align`, `leader`, or `alignOn` value that is not a string, an empty `alignOn` value or one of more than one character, a leader of more than -8 characters, a position outside 0 to 100000 pt, and more than 256 stops. It sorts the stops by position and keeps the -first of stops at one position. +8 characters, a position outside 0 to 100000 pt, and more than 256 stops unless every paragraph it targets already +holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and +keeps the first of stops at one position. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in @@ -7924,8 +8141,8 @@ the Paragraph Styles panel, or Paragraph Style Options… in its menu, does the `paraStyle.edit {name, to, attrs}`, which takes the same arguments as `charStyle.edit` and replaces the style's name and attributes in one undo step; the paragraphs that use the style keep their local overrides. OK with no field changed records no undo step, and Preview updates the paragraphs while the dialog is open (Cancel restores them). -The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `tabs`, `composition`, `hyphenation` or -`justification`) and `preview`, and these fields: +The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `tabs`, `composition`, `hyphenation`, +`justification` or `eastAsian`) and `preview`, and these fields: - Basic Paragraph Formats: `justify` (`Auto`, `Left`, `Center`, `Right`, `JustifyLeft`, `JustifyCenter`, `JustifyRight` or `JustifyAll`), and `left_indent`, `right_indent`, `first_line_indent`, `space_before` and @@ -7944,6 +8161,8 @@ The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `ta - Justification: `word_min`, `word_desired`, `word_max`, `letter_min`, `letter_desired`, `letter_max`, `glyph_min`, `glyph_desired` and `glyph_max` (percent), `auto_leading` (percent) and `single_word` (`fullJustify`, `alignLeft`, `alignCenter` or `alignRight`). +- East Asian Composition Settings: the `korean_line_break_anywhere` checkbox (Korean Composition Settings › Korean + Line Break Anywhere). A field left as it was keeps the attribute as the style has it, and an empty field leaves its attribute unset; an empty Hyphenation or Justification field takes its default while another field of that section is set. Attributes @@ -7958,8 +8177,9 @@ position. A value the style already has is kept. An invalid value leaves the style, its paragraphs and Undo history unchanged, and keeps the dialog open. Reset Panel removes the section's attributes from the definition: Basic Paragraph Formats removes `justify` and the five distances, Tabs removes `tabs`, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and -`hyphenation`, and -Justification removes `justification`. `ui.paraStyleOptions.resetPanel {}` performs that reset for the section +`hyphenation`, +Justification removes `justification`, and East Asian Composition Settings removes `korean_line_break_anywhere`. +`ui.paraStyleOptions.resetPanel {}` performs that reset for the section selected in Paragraph Style Options or New Paragraph Style; it is disabled on General. In the native file a text object's `para` holds the first paragraph's attributes and `paras` (left out when every @@ -8448,6 +8668,20 @@ share of the content's width or height in proportion to the length of its contro its curve parameter, so content spreads evenly along a segment without handles. An envelope opened from an `.ai` file keeps the corners the file saves, and they stay on the same anchors when the envelope is moved, rotated or reflected. These are VectorCraft's own rules, and existing top-object envelopes redraw by them. +A warp bends the content's bounds as one Bézier patch. The style sets the patch's control points from the bend and +the size of the bounds (Arc's top and bottom edges, for example, are cubic curves close to arcs around one center), then +the horizontal distortion scales each column of control points about its middle, and the vertical distortion scales +each row. Content with no width or no height takes the middle of the patch along that side. New warp envelopes, and +those opened from `.ai` files, carry `version` 1 for this map, and `object.envelope.info` reports it. Envelopes in +`.vectorcraft` documents saved before the version existed read as `version` 0, the earlier map, and keep their look, +and editing an envelope keeps its version. `version` (0 or 1) on `object.envelope.makeWithWarp`, +`object.envelope.resetWithWarp` and `object.envelope.options` sets the map. The Warp effects (`warp.arc` … +`warp.twist`) take the same map through the same `version` param: `effect.apply` gives a new Warp `version` 1 unless +`params.version` is 0, the built-in graphic styles' Warps have 1, and Effect › Warp edits keep each Warp's own version. +These commands refuse any other value with `` `version` must be 0 or 1 ``; `1.0` counts as 1. An `.ai` file holds a +warp's settings and not its version, so a `version` 0 warp saved to `.ai` and opened again draws with `version` 1. +These patches are VectorCraft's own rules; the styles and the ranges of Bend and the distortions are those of the Warp +options (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). `object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected, `object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps @@ -9284,6 +9518,60 @@ whole points and Escape cancels. A drag keeps a Contour's own corners and miter options `corners` and `miterLimit` (`tool.setOption`). A border with a gap is two Contours of one shape (the gap first, so it ends up in front), baked with `effect.expandAppearance`, then `object.pathfinder.minusFront`. +## Cutting Tools add-on + +Cutting Tools is a built-in add-on that prepares art for cutting. Its command is `addon.cutting.bridges`; in the desktop +app it is Object › Plug-ins › Cutting Tools › Stencil Bridges…. + +### Stencil Bridges + +`addon.cutting.bridges` cuts bridges out of the selected art so that a stencil cut from it holds together. An island is +an area the art encloses, such as the inside of an O, A or 8 or a hole in a shape; cut out of a sheet, it would fall +away. A bridge is a strip of the sheet left uncut across the art, which keeps the island attached +([stencil islands and bridges](https://en.wikipedia.org/wiki/Stencil)). The rules below are VectorCraft's own. + +- The art is measured by the area its outlines enclose, whatever their paint: paths and compound paths with their fill + rule (open paths closed as a fill closes them), groups by their members, and type by its glyph outlines. Hidden + objects, and the members of hidden groups, are left out. The type object itself is not converted. An outline that + encloses less than the square of the curve tolerance (below) counts as part of the area around it. +- Every island is joined to the area around the art, directly or through another island, by the shortest crossings + that fit (among crossings of about the same length, the one nearest the middle of the island). Then each island gets + more bridges, up to `count`: each at least two widths from the island's other bridges and no longer than twice the + shortest crossing a bridge fits plus `width`. An island too narrow for a strip `width` wide gets no bridge, and islands that a + bridge could join only to each other and not to the sheet get none either. `unbridged` counts the islands the cut + result still encloses. +- The result is one compound path (a plain path when one outline is left) with the strips cut out of its outlines, + painted as the topmost selected shape, in the place of the topmost selected object that has outlines. It is selected. + Selected objects with no outlines in the art, such as hidden objects or type holding only spaces, stay where they are. +- With `keepOriginal` the selected objects that make up the art move to a new hidden layer named "Stencil Original" + ("Stencil Original 2" and so on when that name is taken), above the other layers, in their stacking order and outside + the groups that held them; with false they are replaced. +- Art with no islands, or with islands that no bridge of the width fits, is left as it is: the result carries a + `warning` and no undo step is made. Otherwise the run is one undo step, with a `warning` when the cut result still + encloses some islands. +- Curves are followed in straight segments within a twentieth of `width` (0.002 to 0.25 pt); a curve that would need + more than 4096 segments gets a larger tolerance. Art that reaches more than 10000000 pt from the origin or has more + than 200000 anchors is refused before it is combined. Art is also refused when its combined area has more than 4000 + outlines, when its outlines need more than 200000 straight segments, and when finding which outlines lie inside + which, or measuring the crossings until every island is joined, takes more work than a fixed budget allows. When the + budget runs out while extra bridges are being added, the bridges found so far are kept. + +| Parameter | Values (default) | +|---|---| +| `width` | each bridge's width, 0.01 to 1000 pt, as a number of points or a string with a unit such as `"2 mm"` (2) | +| `count` | the bridges each island gets, a whole number from 1 to 16; fewer where it has no room, and more where other islands are joined to the sheet through it (1) | +| `direction` | `vertical`, `horizontal`, or `shortest` (the shortest crossing among 12 directions 15° apart) (`vertical`) | +| `keepOriginal` | true; false replaces the selected art | + +→ `{id (the new path, or null), islands, bridges, unbridged, original (the hidden layer's id, or null), warning?}`. +Stencil Bridges… opens the command dialog (`ui.paramDialog`) on Width, Count, Direction (Vertical, Horizontal, Shortest +Crossing) and Keep Original. + +```json +{"name":"run_command","arguments":{"command":"addon.cutting.bridges","params":{"width":"2 mm"}}} +{"name":"run_command","arguments":{"command":"addon.cutting.bridges","params":{"width":3,"count":2,"direction":"shortest","keepOriginal":false}}} +``` + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is @@ -9654,7 +9942,9 @@ VectorCraft addition and changes no parity score. - **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on one straight line along the object's own axis, with the baseline starting where the first character sat on the path (type with no characters starts at its start bracket). Type on a Path Options (effect, align to path, spacing, flip, - brackets) don't apply to point type and are dropped. Without `keepOriginal` the type object itself becomes the point + brackets) don't apply to point type and are dropped. Type with Dynamic Text on (`type.dynamicText`) becomes point + type with it off, at the sizes it was drawn at: each size, leading and baseline shift is multiplied by the fit factor. + Without `keepOriginal` the type object itself becomes the point type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as `type.convertToPointType` does for area type. - It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left diff --git a/docs/plugins.md b/docs/plugins.md index d3af5fa06c..f616479167 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,9 +1,9 @@ # Plug-ins: the WebAssembly plug-in API (ABI v1) VectorCraft does not host native plug-ins built for other illustration apps: that needs unsafe FFI into -arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** -run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's -plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents. +arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** run in a +sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's plug-in API (same +sandbox, same manifest and parameter schema), extended for vector documents. There are four kinds of plug-in: @@ -20,8 +20,8 @@ There are four kinds of plug-in: paths and compound paths. File › Open lists their files, File › Open With lists the importers by name, and the host hands them the file's bytes. -This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the -`plugin.*` commands in `crates/engine/src/cmd/plugin.rs`). +This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the `plugin.*` commands +in `crates/engine/src/cmd/plugin.rs`). ## Using plug-ins @@ -44,14 +44,15 @@ An exporter adds a write-only format, `plugin.<id>`, which `document.formats` li its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats. An importer adds a read-only format, `plugin.<id>`, which `document.formats` lists with its `plugin` id. `openExtensions` -holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format -reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the -one whose id sorts first opens it. `document.open {"importer": "plugin.<id>", "params"?: {…}}` names the importer, -which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` -only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an -importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high, -and within ±4,000,000 pt); the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens as a new document when it is dropped on the canvas. These rules are -VectorCraft's own. +holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open +{"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format reads the file, by its content or +by its extension, and the importer lists the file's extension; when several do, the one whose id sorts first opens it. +`document.open {"importer": "plugin.<id>", "params"?: {…}}` names the importer, which must list the file's extension, +also one a built-in format reads (an importer that lists `svg` opens an `.svg` only this way). A damaged file of a +built-in format reports that format's error, and the file is not handed to an importer. The importer's objects become a +new document whose artboard is their bounds (at least 1 pt wide and high, and within ±4,000,000 pt); the document keeps +no path, so Save asks for a name. File › Place reads only the built-in formats, and a file only an importer reads opens +as a new document when it is dropped on the canvas. These rules are VectorCraft's own. In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with @@ -60,25 +61,23 @@ of numbers and colors, labelled, explained and sorted into sections as its manif parameters it runs at once. Edit › Keyboard Shortcuts lists each installed filter under Object › Plug-ins, where it can be given a shortcut (`plugin:<id>`); the shortcut stays saved while the plug-in is removed and applies again when it is reinstalled. Parameters too many for the window's height scroll inside the dialog, which keeps its -heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use -Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the -same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › -Open's "All readable files" and under a filter of their own, named after the importer (other open dialogs, such as New -from Template and Load Styles, list only the files the built-in formats read). These lists leave out an importer's -extensions that a built-in format reads, and those File › Open installs, imports or loads as something else (`.wasm`, -`.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give -those files to the importer, and Open With does. **File › Open With** lists the -installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its -extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. On the -desktop the importer runs off the UI thread, and a run that takes a while shows a progress window with Cancel, which -stops it. An importer -with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open -button opens the file; File › Open runs an importer with its parameters' defaults. Open Recent opens a file the way -File › Open does: it does not remember the importer or the parameters given to Open With. A built-in format reads the -file if one can, and otherwise the first importer by id that lists its extension reads it with its parameters' defaults. -**File › Open** of a `.wasm` file installs -a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the -background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed +heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out +(the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is +picked, and its Export button writes the file. Importers' files are listed under File › Open's "All readable files" and +under a filter of their own, named after the importer (other open dialogs, such as New from Template and Load Styles, +list only the files the built-in formats read). These lists leave out an importer's extensions that a built-in format +reads, and those File › Open installs, imports or loads as something else (`.wasm`, `.kys`, `.jsx`, presets, libraries +and swatch or graphic style libraries such as `.ase`). File › Open does not give those files to the importer, and Open +With does. **File › Open With** lists the installed importers by name (`file.openWith {"importer", "path"?, +"params"?}`), and each opens a file picked among its extensions with that importer (on the web, File › Open's picker +opens instead) and adds it to Open Recent. On the desktop the importer runs off the UI thread, and a run that takes a +while shows a progress window with Cancel, which stops it. An importer with parameters, given no `params`, asks for them +in a dialog of the same kind once the file is picked, and its Open button opens the file; File › Open runs an importer +with its parameters' defaults. Open Recent opens a file the way File › Open does: it does not remember the importer or +the parameters given to Open With. A built-in format reads the file if one can, and otherwise the first importer by id +that lists its extension reads it with its parameters' defaults. **File › Open** of a `.wasm` file installs a plug-in +too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background +([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). ### Commands @@ -117,9 +116,9 @@ the object is drawn as if the effect weren't there, and the record is kept for w ## The sandbox -The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter -(MIT OR Apache-2.0). It builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits -(`vectorcraft_plugins::Limits`, defaults): +The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter (MIT OR Apache-2.0). It +builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits (`vectorcraft_plugins::Limits`, +defaults): | Limit | Default | On violation | |---|---|---| @@ -155,15 +154,15 @@ measured with the page's `performance.now()`. Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a simulation, which a minute and 50 M instructions don't cover. Such a filter declares `"budget": "heavy"` in its manifest (only a filter may: a live effect runs whenever the document is drawn, an exporter has an export's budget and -an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s -(`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns how -much there is only once the run has ended. +an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, +and 300 s (`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns +how much there is only once the run has ended. - **Installing asks first.** Install Plug-in… and File › Open show what the filter asks for, and install it once the user clicks OK (see [Permissions](#permissions) for who can). `plugin.install` refuses it without `allowHeavy: true`, and refuses `allowHeavy` from a request except in a session started with `vectorcraft-cli --allow-plugin-grants` - (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again - at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder + (`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed + again at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder asks nobody. - **It runs in the background.** Its dialog has no Preview. OK (or the menu item, without parameters) runs it on a thread of its own while editing goes on, shown in the status bar and, after a moment, in a progress window with @@ -203,17 +202,15 @@ capability model proposed for PhotoCraft plug-ins `state`, which only its commands use. A filter may list any of its three, and `commands` with a `commandAllowlist`. Any kind may add `progress`, `settings` and `cache`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, `progress`, `settings`, `cache` or `commands` with an `api` - below 2, or - one of the names kept for a later plug-in API - (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. + below 2, or one of the names kept for a later plug-in API (`ui.panel`, `ui.tool`) is refused when the module is + installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of the file and each permission in plain words, and install it once the user clicks OK, if the file still has the - SHA-256 shown; Cancel installs nothing. The - names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. - Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected input (`ui.key`, `ui.click`) - are refused and the dialog stays open. Enter confirms it only when OK has the keyboard focus. `ui.dialog.set` can't - change what it installs. OK stays disabled until the question has been on screen in the focused window for half a - second (VectorCraft's own value, from the delay the security guidance at + SHA-256 shown; Cancel installs nothing. The names shown have control and bidirectional characters escaped (`\u{202E}`) + and are cut to 64 characters. Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected + input (`ui.key`, `ui.click`) are refused and the dialog stays open. Enter confirms it only when OK has the keyboard + focus. `ui.dialog.set` can't change what it installs. OK stays disabled until the question has been on screen in the + focused window for half a second (VectorCraft's own value, from the delay the security guidance at https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/security-considerations-for-browser-ui.md gives for dialog boxes), so a click, Enter or Space, or a press begun before then, installs nothing. The half second starts over for every new question, even one that reads the same, and when the window gets the focus back. Cancel @@ -280,23 +277,22 @@ capability model proposed for PhotoCraft plug-ins does not count for any session in the same process that uses the same `Plug-ins` folder, by any path to it, until `grants.json` can be written for that plug-in, as when the plug-in is granted again. Another process on the same folder, for example `vectorcraft-cli` beside the app, still reads `grants.json` as it is written. A grant for the - same id kept in another `Plug-ins` folder counts. In Safe Mode, and for - a stored module that the user disabled, + same id kept in another `Plug-ins` folder counts. In Safe Mode, and for a stored module that the user disabled, VectorCraft does not read the module, and `grants.json` keeps the grant for its id. The Additional Plug-ins Folder and `plugin.reload` do not change the `Plug-ins` folder: when they leave a module that lists no permissions under an id, the process forgets its own grant for that id, and a grant kept in `grants.json` stays with the stored module it covers. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again, replaced by a module that lists no permissions, or removed. When such an entry can't be removed from the file, `plugin.install` and `plugin.remove` treat it as a grant - that can't be removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than - 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept - in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins - Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a + that can't be removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written + larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for + `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, a stored plug-in + is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins Folder and + `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a `permissions` list, or with an empty one, needs no grant. -- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; without `state` its - `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the - document is refused. A refused output leaves the document as it was. +- The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; + without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that + changes the document is refused. A refused output leaves the document as it was. - `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host imports](#host-imports-api-level-2), a filter's `inputs` ([reading type, images and symbols](#reading-type-images-and-symbols)) and a live effect's [`paintOutput`](#painted-live-effects). A higher one @@ -307,12 +303,11 @@ capability model proposed for PhotoCraft plug-ins - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls except what a plug-in writes to its -[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed -modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the -document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that -fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). A run that succeeds clears the -error of a failed run. A check that finds every swatch and pattern a painted result names clears only the error of a -refused swatch or pattern. +[settings and cache](#settings-and-cache). Traps, exhausted budgets, malformed modules, bad manifests, out-of-range +pointers, negative return codes and invalid output all become errors, and the document is left untouched: a filter's +output is checked completely before one edit applies it. A live effect that fails draws the object's geometry unchanged; +`plugin.info` reports why (`lastError`). A run that succeeds clears the error of a failed run. A check that finds every +swatch and pattern a painted result names clears only the error of a refused swatch or pattern. wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts @@ -365,12 +360,12 @@ still returns `1`. `vc_progress`, `vc_cancelled` and `vc_log` need `progress`; t filter that lists `progress` runs in the background as a heavy one does, under its light budget, when it runs at once from Object › Plug-ins or its shortcut, or when OK runs it with Preview off; with Preview on, its dialog keeps the previewed run. A filter that also lists a `commandAllowlist` runs where it is called, since its command calls need - the document. A plug-in that checks `vc_cancelled` can - stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that - ignores it is still stopped by the instruction and time budgets. With a known total, a report is passed on only when - it reaches another tenth of a percent; with an unknown total, each change of `done` is passed on. After 2,000 reports - in a run, one change in 1,000 is passed on, and so is the first report after the total becomes known or unknown. A - live effect's progress is not shown, since it is drawn as part of something else. + the document. A plug-in that checks `vc_cancelled` can stop early, and the host discards what it returns after the + Cancel or after its time budget ran out; one that ignores it is still stopped by the instruction and time budgets. + With a known total, a report is passed on only when it reaches another tenth of a percent; with an unknown total, each + change of `done` is passed on. After 2,000 reports in a run, one change in 1,000 is passed on, and so is the first + report after the total becomes known or unknown. A live effect's progress is not shown, since it is drawn as part of + something else. - Log lines go to the app's log (target `vectorcraft_plugins`) as `<plug-in id>: <text>`, with control and bidirectional characters escaped. The whole text must be UTF-8. A line shows its first 1,024 bytes, as at most 1,024 characters with the escapes, and ends with `…` when it is cut. A run writes at most 200 lines and 32 KiB of shown @@ -405,8 +400,7 @@ or modified time differs from the last read or write. A get answered from memory set to the value the key already has writes nothing, and neither does a delete of a key that has no value. A delete of the last setting removes the file and counts no written bytes. Every other successful set or delete writes the file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and -the run waits for it. -A live effect runs while the art draws, so keep the entries it uses small. +the run waits for it. A live effect runs while the art draws, so keep the entries it uses small. - A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. @@ -416,28 +410,27 @@ A live effect runs while the art draws, so keep the entries it uses small. also fails with `-4` when the `cache` folder holds more than 8,192 items; clearing the cache removes every file in it. - The desktop app and `vectorcraft-cli` keep them in a folder named from the id under `storage/` in the `Plug-ins` - folder (ids that differ only in case get two folders): `settings.json` and a - `cache` folder with one file per entry, named by the host. A settings file that is too large or doesn't parse is - moved to `settings.unreadable.json`, replacing an earlier one there, and the settings start empty. A link in that - folder is refused (`-5`). Writes go to a temporary file that then replaces the old one; a temporary file a failed - write leaves in the `cache` folder does not count toward the caps, and clearing the cache removes it. + folder (ids that differ only in case get two folders): `settings.json` and a `cache` folder with one file per entry, + named by the host. A settings file that is too large or doesn't parse is moved to `settings.unreadable.json`, + replacing an earlier one there, and the settings start empty. A link in that folder is refused (`-5`). Writes go to a + temporary file that then replaces the old one; a temporary file a failed write leaves in the `cache` folder does not + count toward the caps, and clearing the cache removes it. - A plug-in reaches only the storage of its own id. Replacing it with another module of the same id - (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. - `plugin.remove` first renames the folder, adding `.removed` to its name (or `.removing` while a `.removed` folder - holds settings an earlier removal kept). When that fails, `plugin.remove` fails and the plug-in stays installed with - its storage as it was. Before VectorCraft deletes a renamed folder, it writes an empty file beside it with `.delete` - added to the folder's name, and removes that file once the folder is gone. When that file can't be written, the - folder is not deleted and is treated like any renamed folder without a `.delete` file (see below). Files that can't be deleted stay in the - renamed folder with the `.delete` file beside it, where no plug-in reaches them. A later `plugin.remove` tries again - to delete them when a plug-in with that id is installed or a storage folder for the id exists, and that deletion can - fail too. For an id that isn't installed and has only such a folder, `plugin.remove` does not delete it and fails as - not found. When `plugin.remove` can't remove the grant and can't rename the folder back either, its error names the - renamed folder. The next load, install or `plugin.remove` of the plug-in renames a renamed folder that has no - `.delete` file back while the plug-in has no folder of its own; when it has one, that folder is used and the renamed - folder stays until `plugin.remove` deletes both. - `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is - any. - `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and + (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a + run of the plug-in still going gets `-5` from every storage call after that. `plugin.remove` first renames the folder, + adding `.removed` to its name (or `.removing` while a `.removed` folder holds settings an earlier removal kept). When + that fails, `plugin.remove` fails and the plug-in stays installed with its storage as it was. Before VectorCraft + deletes a renamed folder, it writes an empty file beside it with `.delete` added to the folder's name, and removes + that file once the folder is gone. When that file can't be written, the folder is not deleted and is treated like any + renamed folder without a `.delete` file (see below). Files that can't be deleted stay in the renamed folder with the + `.delete` file beside it, where no plug-in reaches them. A later `plugin.remove` tries again to delete them when a + plug-in with that id is installed or a storage folder for the id exists, and that deletion can fail too. For an id + that isn't installed and has only such a folder, `plugin.remove` does not delete it and fails as not found. When + `plugin.remove` can't remove the grant and can't rename the folder back either, its error names the renamed folder. + The next load, install or `plugin.remove` of the plug-in renames a renamed folder that has no `.delete` file back + while the plug-in has no folder of its own; when it has one, that folder is used and the renamed folder stays until + `plugin.remove` deletes both. `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept + for that id, when there is any. `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and `plugin.info` give the cache's size as `cacheBytes`. - Without a `Plug-ins` folder (the web build, and embedders that attach none) the storage calls return `-5`. @@ -458,8 +451,9 @@ has the id, `-6` when a plug-in may not call the command, `-7` when the command `-8` when the command ran and failed, `-9` when a limit below is passed, and `-11` when nothing can be called (from the module's start function, in a run that isn't a filter's run on a document, or after a call in the run failed). -- Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, - traps, runs out of budget or is canceled leaves every document, its selection and its history as they were. +- Every code also fails the run, whatever the plug-in does next, and the error names the call and why it failed. The + run's calls and its output are one undo step, named after the plug-in or its command. A run that fails, traps, runs + out of budget or is canceled leaves every document, its selection and its history as they were. - Each call runs on the document the run is for, with its params checked as any request's are, and sees what the calls before it changed: a `select.all` followed by `object.move` moves everything. The filter's own input is the one it was given when the run started; an edit still in progress (typing, or a shape being drawn) is kept as its own undo @@ -540,18 +534,17 @@ the module's start function, in a run that isn't a filter's run on a document, o no empty part between dots. A malformed list is refused when the module is installed, and the error names the id. - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are - clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults - are also clamped to their range, including the implicit zero default of an `int` parameter. Besides `number`, - `int`, `bool` and `choice` there are `string` (`{"type": "string", "default": "", "maxLength": 256}`: a text field; - longer text is cut to `maxLength`, at most 4,096 characters) and `numberList` (`{"type": "numberList", "min": 0, - "max": 360, "default": [45, 135], "minItems": 1, "maxItems": 16}`: numbers typed separated by commas or spaces, - each clamped to `min..max`; a list of too few or too many is an error, and `maxItems` is at most 256) and `color` - (`{"type": "color", "default": "#3a7bd5"}`: a color button that opens a color picker; the default, black when left - out, and a value given to `plugin.run` may be a color object as paints hold one or a hex string, and the plug-in - always gets a color object, its components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; a - hex string and the picker's choice are display colors, given as the working RGB color that shows as them, so in a - working RGB space other than sRGB the numbers differ from the hex). Hosts from before `color` refuse a manifest that - uses it. A plug-in declares up to 256 + clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults are also clamped + to their range, including the implicit zero default of an `int` parameter. Besides `number`, `int`, `bool` and + `choice` there are `string` (`{"type": "string", "default": "", "maxLength": 256}`: a text field; longer text is cut + to `maxLength`, at most 4,096 characters) and `numberList` (`{"type": "numberList", "min": 0, "max": 360, "default": + [45, 135], "minItems": 1, "maxItems": 16}`: numbers typed separated by commas or spaces, each clamped to `min..max`; a + list of too few or too many is an error, and `maxItems` is at most 256) and `color` (`{"type": "color", "default": + "#3a7bd5"}`: a color button that opens a color picker; the default, black when left out, and a value given to + `plugin.run` may be a color object as paints hold one or a hex string, and the plug-in always gets a color object, its + components clamped, such as `{"model": "rgb", "r": 0.23, "g": 0.48, "b": 0.84}`; a hex string and the picker's choice + are display colors, given as the working RGB color that shows as them, so in a working RGB space other than sRGB the + numbers differ from the hex). Hosts from before `color` refuse a manifest that uses it. A plug-in declares up to 256 parameters, at most 64 of them outside groups. Each parameter may also say how dialogs show it. Hosts from before these keys ignore them, so a plug-in that @@ -576,17 +569,18 @@ parameters uses `label`, `unit`, `help` and `visibleWhen`, and lists the paramet order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide (compound guides too) and clipping paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never touches unless its manifest lists them in `inputs` ([reading type, images and -symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one -object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts -from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a -fill's colour to decide its shape, but it changes only the geometry, unless it declares `paintOutput` ([painted live -effects](#painted-live-effects)). +symbols](#reading-type-images-and-symbols)). With nothing selected the list is empty (a generator then adds its own +objects). A live effect gets one object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and +`strokes`, as a filter gets them (hosts from before these give geometry only). The paints are read only: an effect may +draw as wide as a stroke or use a fill's colour to decide its shape, but it changes only the geometry, unless it +declares `paintOutput` ([painted live effects](#painted-live-effects)). An **exporter** gets something else: the native `.vectorcraft` document JSON without indentation, optional preview/PDF -renditions or save-preference metadata (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, -appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks -`version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME -type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. +renditions or save-preference metadata (the format is documented in +[`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, appearance and effect record, +artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks `version`, since a later host +may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their +data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. An **importer** gets the bytes of the file being opened, as they are (not JSON), at most 128 MiB of them. @@ -615,12 +609,11 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON), (absent: no handle), `kind` `"Smooth"` when the handles stay collinear. Coordinates are points, y down. - `fills`, `strokes`: the object's fills and strokes in paint order (bottom first); `null` is no paint. A stroke has its `paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`) - and `miterLimit`. Paints use the - native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient": - {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", - "freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", - c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except - Lab's. + and `miterLimit`. Paints use the native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, + `{"type": "gradient", "gradient": {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, + midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern", + "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", c, m, y, k}`, `{"model": "gray", k}` (ink: 0 + white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except Lab's. - `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or `plugin.<id>` for a live-effect plug-in), `params` and `visible`. Its fills' and strokes' own effects aren't listed. - `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). @@ -631,9 +624,9 @@ A filter whose manifest lists `inputs` also gets the selected objects of those k and under the same rules (hidden and locked ones, objects that clip a clip group, alone or inside a group that clips it, and anything inside a symbol, a placed document or a live object are left out). They are never turned into outlines, rectangles or the symbol's art. Each has `id`, `type`, `name` (when it has one), `bounds` (`null` until the -object has been laid out or drawn) and -`transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, the symbol's art) into the document. -In this example the character and paragraph styles are cut short; a filter gets every attribute: +object has been laid out or drawn) and `transform`, `[a, b, c, d, e, f]` mapping its own space (text space, pixel space, +the symbol's art) into the document. In this example the character and paragraph styles are cut short; a filter gets +every attribute: ```json {"objects": [ @@ -662,13 +655,13 @@ In this example the character and paragraph styles are cut short; a filter gets `pixelsOmitted: false`. A CMYK image's pixels are its inks through the working CMYK profile, as sRGB. The pixels of all the images in one input take at most 16 MiB in base64, and an image the host starts to decode takes its size from those 16 MiB even when decoding fails. A larger image, one whose pixels don't fit what the images before - it in paint order left, and a linked image (`linked: true`) have - `pixelsOmitted: true` and no pixels; the host checks an image's size before it decodes the image. An image of any - size also has `pixelsOmitted: true` when its data is missing from the document, when its width or height is 0, when - its data cannot be decoded (including a non-CMYK image whose decoder enforces the host's 64 MiB memory limit and - needs more, and a TIFF the host rejects as too large), or when the dimensions in its data differ from `pixelSize`. - A plug-in checks `pixelsOmitted` before it reads `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never - reads a linked image's file for a filter and never gives its path. + it in paint order left, and a linked image (`linked: true`) have `pixelsOmitted: true` and no pixels; the host checks + an image's size before it decodes the image. An image of any size also has `pixelsOmitted: true` when its data is + missing from the document, when its width or height is 0, when its data cannot be decoded (including a non-CMYK image + whose decoder enforces the host's 64 MiB memory limit and needs more, and a TIFF the host rejects as too large), or + when the dimensions in its data differ from `pixelSize`. A plug-in checks `pixelsOmitted` before it reads + `pixelsBase64`, which is present only when `pixelsOmitted` is `false`. The host never reads a linked image's file for + a filter and never gives its path. - A symbol instance (`"symbolInstance"`): `symbolId` is the name of the symbol it shows, with `missing: true` when the document has no symbol of that name. `overrides` is `{}`. @@ -699,16 +692,16 @@ toward the 64 MiB output cap. ``` `mode` is `"filter"`, `"effect"`, `"export"`, `"import"` or `"command"` (a [command](#commands) run, whose -`command` is the command's id); `bounds` the selection's geometric bounds (a live effect: the reference -box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard -under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document -(`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the -object's effects), or `{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, -counted from the bottom as in `fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", -"name": "Poster"}` instead: the extension of the file being written (one the exporter lists) and its name without the -extension (the document's name when the export is not given a path), which some formats keep in their header. An -import's `_context` is `{"mode": "import", "extension": "plt", "name": "Poster"}`: the extension of the file being -opened and its name without the extension. +`command` is the command's id); `bounds` the selection's geometric bounds (a live effect: the reference box of the +effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the +selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until +it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the object's effects), or +`{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, counted from the bottom as in +`fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", "name": "Poster"}` instead: +the extension of the file being written (one the exporter lists) and its name without the extension (the document's name +when the export is not given a path), which some formats keep in their header. An import's `_context` is `{"mode": +"import", "extension": "plt", "name": "Poster"}`: the extension of the file being opened and its name without the +extension. ### Output @@ -785,17 +778,17 @@ is a piece drawn by itself, in the order returned, with paints of its own: path or compound path is hit-tested on its source path and appearance. On an object with no geometry effect of its own, Pathfinder effects and compound shapes use the object's path for an effect on a fill or stroke, as they do for every other effect on a fill or stroke; on an object that has one, they also use the pieces of a painted effect on a - fill or stroke. A 3D effect later in the stack uses the - object's input path when the pieces name a swatch or pattern. Each piece counts against the budget of the - object's geometry effects as well as its anchors, so all the painted effects in a stack make at most 100,000 pieces - together; a run whose pieces would pass the budget draws its input as it was. + fill or stroke. A 3D effect later in the stack uses the object's input path when the pieces name a swatch or pattern. + Each piece counts against the budget of the object's geometry effects as well as its anchors, so all the painted + effects in a stack make at most 100,000 pieces together; a run whose pieces would pass the budget draws its input as + it was. - The result is checked whole before any of it is drawn: the limits of a filter's output (100,000 objects, 4,000,000 anchors, 256 fills and 256 strokes per piece, 256 stops per gradient), finite numbers in range with nothing clamped (color components, tints, offsets, opacities, midpoints 0.13 to 0.87, stops in order of `offset`, stroke - widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every pattern named in - the document. One fault refuses the whole result: that step of the stack draws its input as it was, the effect - record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example - `Test Tone: plug-in output: objects[1].strokes[0].width: …`. + widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every + pattern named in the document. One fault refuses the whole result: that step of the stack draws its input as it was, + the effect record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example `Test Tone: + plug-in output: objects[1].strokes[0].width: …`. - The pieces are never written into the document while it is drawn, measured, previewed, saved or exported. PDF and SVG export draw them as the canvas does, from a copy of the document. Object › Expand Appearance writes each piece as an ordinary path or compound path with its fills and strokes, as one undo step. Object › Expand of gradient and pattern @@ -823,12 +816,13 @@ crates/plugins/example/build.sh # cargo build --release --target wasm32-unk The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source. The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, -WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never -touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests -build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a -base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, and -`wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows in a -live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host functions. +WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as +long as it never touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, +which the tests build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters +(with a base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, +and `wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows +in a live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host +functions. ## Performance diff --git a/docs/roadmap.md b/docs/roadmap.md index cf782106a1..51b0eb31bb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including Bristle Brushes, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b8c8a86178..bbd91f238f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; scores and estimates unchanged); previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -220,9 +220,9 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (seven generated libraries including Bristle Brushes, Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | @@ -230,7 +230,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces; materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step` | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | @@ -489,13 +489,13 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (seven generated in code including Bristle Brushes, Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | @@ -541,7 +541,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries seven (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -591,6 +591,8 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. +- **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged. +- **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. @@ -600,7 +602,7 @@ since 2026-10-01. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. -- **Clicks pass through raster effects:** clicks with the Selection and Direct Selection tools, and the Selection tool's pointer and marquee, leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. +- **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. @@ -756,6 +758,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: an error a `finally` block passes on is still reported where it was thrown when the block's allocations collect garbage under a small heap limit, and a `finally` block that `return`, `break` or `continue` leaves drops the saved location of its error as it leaves, so a collection no longer counts that code as live. VectorCraft's own rule. `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. + - Scripts: a runtime error is shown at the line that was running ([the help page](https://extendscript.docsforadobe.dev/extendscript-toolkit/debugging-in-the-toolkit/)). VectorCraft's own rules: an error in code an earlier run of a named engine compiled, such as a function it defined, is reported at that code's file, line and column (the earlier run's script, a file it included or a file its `$.evalFile` ran), while the report and its console line keep the calling run's name; a script given as text is named by its run's name, and each engine keeps these places for 8 MiB of source maps, the oldest going first. `crates/engine/src/tests/script_engines.rs`. - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. @@ -763,6 +766,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). + - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. @@ -778,7 +782,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - With `Options::e4x` set, an E4X name standing alone (`@a`, `@ns::a`, `@[expr]`, `ns::a`, `*`) inside `with` or a filter predicate can be assigned, with `=` or a compound operator, incremented, decremented, deleted and used as a `for` or `for each` variable, as well as read. Each of these uses the nearest XML or XMLList value in scope that holds the name and evaluates the name once. When no XML value in scope holds the name, the read or write throws a `ReferenceError` before the right-hand side runs and creates no child or attribute, as ECMA-357 section 11.1 (https://ecma-international.org/publications-and-standards/standards/ecma-357/) specifies, so a filter such as `list.(@id == '1')` throws when an item has no `@id` and no outer XML value holds one. Tests: `crates/js/src/xml_tests.rs`. - - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `<!DOCTYPE>` in an XML string gives a `SyntaxError`. An XML declaration at the start of an XML string, after optional white space, is checked against XML 1.0's grammar (https://www.w3.org/TR/xml/#NT-XMLDecl): a version of `1.` and digits, then an optional encoding name and an optional standalone `yes` or `no`, in that order; any other declaration, or one with a character XML does not allow, gives a `SyntaxError`. The parser takes fuel for the length of every name, white space run and declaration it scans, and for the text it searched when a quote, CDATA section, comment or processing instruction is not closed. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. + - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings. `xml.xpath(expression[, variables])` evaluates an XPath 1.0 expression (https://www.w3.org/TR/1999/REC-xpath-19991116/) with all 13 axes and the 27 core functions, and returns the selected nodes as an XMLList in document order without repeats. Each property of `variables` binds the variable of its name, an XMLList runs the expression on each of its elements in turn, and a prefix in a name test resolves through the context element's in-scope namespaces. An expression that breaks the grammar gives a `SyntaxError`; an unknown function, a wrong argument count, an unbound prefix or variable, and a result that is not a node-set or that holds the root or a namespace node give a `TypeError`. `id()` selects nothing, and evaluation uses no native stack, takes fuel and counts against the heap limit. `<!DOCTYPE>` in an XML string gives a `SyntaxError`. An XML declaration at the start of an XML string, after optional white space, is checked against XML 1.0's grammar (https://www.w3.org/TR/xml/#NT-XMLDecl): a version of `1.` and digits, then an optional encoding name and an optional standalone `yes` or `no`, in that order; any other declaration, or one with a character XML does not allow, gives a `SyntaxError`. The parser takes fuel for the length of every name, white space run and declaration it scans, and for the text it searched when a quote, CDATA section, comment or processing instruction is not closed. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. @@ -827,6 +831,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Align New Objects to Pixel Grid (`document.setup {alignNewObjectsToPixelGrid}`, a checkbox in New Document's Advanced Options and a checked item in the Transform panel menu) gives every path and compound path drawn in the document, the paths of a new grid or flare included, the pixel-aligned property, snapped in the step that draws it; it is on in the Mobile and Web presets and off in the others, saved presets and Recent keep it, and the document saves it. Type, images and groups do not take it, and art already in the document, pasted, duplicated or placed keeps its own property (VectorCraft's own rules) ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::align_new_objects_makes_new_paths_pixel_aligned`, `align_new_objects_reaches_grids_flares_and_add_on_shapes`, `align_new_objects_is_saved_and_read_back`, `tests::newdoc::align_new_objects_to_pixel_grid_follows_the_preset`, `panels::transform::tests::align_new_objects_menu_item_toggles_the_active_document`, `dialogs::new_document::tests::align_new_objects_checkbox_follows_the_preset_and_reaches_the_document`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). + - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. @@ -850,7 +855,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. @@ -900,7 +906,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. - - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning of the character before it. + - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning there: of the character before it, or at the start of a line the space before the character after it (#5111). - Cmd/Ctrl+Alt+K (`type.highlightKerning`) and Cmd/Ctrl+Alt+Shift+F (`type.highlightFontName`) show the Character panel and give its Kerning field the keyboard focus with the value selected, or open its font family menu with the search field focused, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for highlighting kerning and the font name field. The page lists the same key for selecting the tracking value; `type.highlightTracking` focuses the Tracking field the same way and has no default key in Keyboard Shortcuts. - Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a layer through the New Layer dialog box. OK adds the layer above the current layer, and Cancel adds none. Cmd/Ctrl+L still adds a layer without the dialog. - F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new symbol. OK makes the symbol and puts an instance of it in place of the art, and Cancel makes none. With nothing selected, F8 opens no dialog, and the status bar says that nothing is selected. Shift+F8 and Cmd/Ctrl+F8 still show the Transform and Info panels. @@ -1153,6 +1159,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). +- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn, and on a path writes the space as the first character's `dx`; vertical type moves the character down its column. A space on the first letter of a ligature keeps the ligature whole. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG). - **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`. - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. @@ -1189,8 +1196,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency leave art without transparency as it is when it overlaps only other art without transparency, unless it overlaps a member of a see-through object's group and lies above that member and below the group's topmost object. Such art joins the group, and the group takes the place of its topmost object, so the stacking order stays the same. In files without transparency with Clip Complex Regions on, art under the group within one image pixel of a member joins it too. The 1 pt antialiasing margin goes around a group's art once, however deeply its groups nest. On a grid of 3,000 rectangles with a 0.5 pt stroke, 60 of them with a drop shadow, the Flattener Preview counts 180 affected objects where it counted 3,000, and Flatten Transparency with the Medium preset makes 240 flat regions and 2 images where it made one image of the whole grid. These rules are VectorCraft's own (`tests::flatten::opaque_art_that_touches_only_opaque_art_stays`, `tests::flatten::opaque_art_the_group_would_move_under_joins_it`). New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. + Type › Type on a Path › Center on Top and Center on Bottom (`type.pathCenter.top`, `type.pathCenter.bottom`, also on the Contextual Task Bar for type on a closed path) slide type on a closed path so the middle of its span lands on the path's topmost or bottommost point as drawn, keep the span's length, and set the first paragraph to Center unless it is Center or Justify Center. This is VectorCraft's own behavior (`crates/geom/src/arc.rs`, `crates/engine/src/tests/pathtype.rs`). Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). - Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). + Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: reshaping or scaling the path fits the text again; one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off, threading the type or Release Type on a Path keeps the drawn sizes; SVG, EPS, legacy `.ai` and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, threaded frames cannot use it, and Convert To Point Type turns it off, keeping the sizes (`crates/engine/src/cmd/typecmd/block_tests.rs`). Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset`) put point type or type on a path on a circular, upward-curving or downward-curving path with Dynamic Text on, with the text's position against the path (above, center, below) and its direction ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). VectorCraft's own rules: the circle is as wide as the text and the text starts at its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text; above, center and below are Align to Path's Descender, Center and Ascender (`crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. @@ -1198,7 +1206,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. - The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation off, no kashidas and no tab stops, as one undo step. + The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. + The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. @@ -1294,7 +1303,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and two pattern libraries (Geometric Patterns, and Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, seven gradient libraries (among them Organic Fades, soft perceptually mixed multi-stop fades that thin out to transparent, and Holographic, seven opaque linear foils of narrow pastel and pearl bands and seven radial glows that fade out to a clear edge) and two pattern libraries (Geometric Patterns, and Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. @@ -1356,6 +1365,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.placeArt`). The copy is centered where it is dropped. A pattern brush comes out as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. The centering and the tile layout are VectorCraft's own rules. Evidence: `tests::brushsym::place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped`, `tests::brushsym::pattern_brushes_place_their_tiles_in_a_row` (vectorcraft-engine), `canvas::tests::a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there` (vectorcraft-ui-egui). + Art Alt-dragged (Option-dragged on macOS) from the canvas onto a scatter, art or pattern brush in the Brushes panel becomes the brush's art, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.replaceArt`). When strokes use the brush, a dialog asks whether to change those strokes (Apply to Strokes) or leave them as they are (Leave Strokes), or to cancel, the choice the [help reference](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=263) (archived copy, p. 263) describes for a modified brush in use. Leave Strokes keeps the brush and its strokes as they were and adds a copy of the brush with the new art, named "`<name>` copy", which becomes the current brush. That copy is VectorCraft's own rule. A pattern brush takes back the tile groups `brush.placeArt` places, frames included, and takes other art as its side tile, which is also VectorCraft's own rule. Evidence: `tests::brushsym::replace_art_applies_to_strokes_or_leaves_them_on_the_old_brush`, `tests::brushsym::placed_pattern_tiles_go_back_into_the_brush_tile_by_tile`, `tests::brushsym::other_art_replaces_the_side_tile_of_a_pattern_brush` (vectorcraft-engine), `dialogs::brush_replace_art::tests::leave_strokes_adds_a_copy_and_cancel_changes_nothing`, `panels::brushes::tests::art_alt_dropped_on_a_brush_with_art_replaces_its_art`, `canvas::tests::art_alt_dragged_off_the_canvas_reaches_the_panels_as_the_original` (vectorcraft-ui-egui). In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). Pattern brush corner tiles are square to the side tiles at a right-angle corner and turn 90° from one corner to the next, as the [Using Illustrator CS5](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf) help (About patterns) describes. That document's steps for making corner tiles build both the outer and the inner tile as the corner joining a side tile on its right to one below it, and the brush reads corner art that way, with Flip Along and Flip Across or without (#6110). At corners that are not right angles, the tile is turned halfway between the two sides, which is VectorCraft's own rule. Evidence: `tests::pattern_corner_tiles_are_square_to_the_sides`, `tests::pattern_corner_tiles_sit_on_the_corners_diagonal` (vectorcraft-brush). @@ -1514,6 +1524,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). + - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). @@ -1530,7 +1542,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`). - Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged. - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). + - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root `<title>`. The title is File Info's alone (`DocMetadata::title`): the document's name stays the file's name with its extension, a file that gives no title leaves File Info with that name, and exports (PDF, SVG, PNG, EPS, `.ai`) and native saves carry it; changing the title in File Info sets the document's name as before (#3626). - PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`). - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). @@ -1718,6 +1730,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. +- **Refreshing many deferred effects (G17):** Refresh Deferred Effects (`effect.refreshDeferred`) looks up the objects asked for, the stages reached, the objects drawn whole and the inputs that ran in hash sets, so its time no longer grows with the square of the number of deferred stages. The deferred records are indexed by stage and input, so asking whether a stage stays deferred, replacing a stage's records when it runs, and ending a drawing after the first view look at that stage's records only, and the records keep the order they were made in. With 40,000 deferred stages, one refresh in a release build took 38 ms, down from 1,036 ms, and asking about and running each stage once took 16 ms, down from 2,287 ms (wall-clock time on a computer busy with other builds, one run each). Regressions: `crates/plugins/src/deferred.rs`. Scores and hours unchanged. - **Refresh Deferred Effects by object (G17):** `effect.refreshDeferred` takes `ids`, or `id` for one, naming objects in the layers or in the art of a symbol, pattern, brush or opacity mask, the objects `effect.deferredStatus` lists; an object without a deferred stage adds nothing to the counts, and an id of no object in the document is an error. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. @@ -1956,6 +1969,10 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov A VectorCraft addition, not a feature of the reference app (it has no command for this), so no area score moves. Object › Plug-ins › Type Tools › Release Type on a Path… splits each selected type on a path into point type holding the same text and the plain path it was set on, or keeps only one of them (#5532). The path keeps its exact shape (open or closed, anchors and handles, the object's transform), layer and place in the stacking order, with no fill and no stroke since type paths carry no paint of their own. The text keeps its runs, character and paragraph attributes and becomes point type on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options don't apply to point type and are dropped. Keep Original leaves the type on a path and adds the released objects above it. Other objects are left alone and a selection with no type on a path makes no undo step; otherwise the run is one undo step, the released objects are selected, and the dialog's Preview works as Type on a Path Options' does (#5360). Tests: `cmd::addons::typetools::tests::release` in `vectorcraft-engine` (each mode, Keep Original, mixed and empty selections, closed, open, curved and flipped paths, transformed and grouped type, multi-style and unicode text with a centred paragraph, empty type, undo and redo, preview, cancel and commit, bad params refused), `release_type_on_a_path_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Type Tools add-on › Release Type on a Path. +## Cutting Tools add-on: Stencil Bridges (2026-11-12) + +A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Stencil Bridges… cuts strips of a set width (0.01 to 1000 pt, 2 by default) out of the selected art so that a stencil cut from it keeps its islands, such as the inside of an O or a hole in a shape, attached to the sheet ([stencil islands and bridges](https://en.wikipedia.org/wiki/Stencil)). Every island is joined to the sheet, directly or through another island, by the shortest crossings that fit, then gets more bridges spread around it up to the count asked for (1 to 16); bridges run vertically, horizontally or along the shortest crossing among 12 directions 15° apart. The art is measured by the area its outlines enclose (hidden objects left out), type by its glyph outlines without converting the type, and becomes one compound path with the gaps cut; the selected objects that make up the art go to a new hidden layer, Stencil Original, or are replaced, and selected objects with no outlines stay where they are. Art with no islands is left as it is with a message and no undo step, islands the cut result still encloses are counted and reported, and the run is one undo step. Tests: `addons::stencil::tests` in `vectorcraft-pathops` (an O, two holes, nested islands, curved outlines, width, direction, the shortest crossing at an angle, more bridges per island and their spacing, narrow islands, slivers, outlines that touch at a corner, the curve tolerance, open outlines, limits and the work budget), `cmd::addons::cutting::stencil::tests` in `vectorcraft-engine` (the hidden layer and undo, replacing, width with units, type, hidden group members, objects with no outlines, the result's paint, kind and place in the stacking order, no islands, narrow islands, bad params refused), `stencil_bridges_opens_its_dialog_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Stencil Bridges. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on. @@ -1986,7 +2003,7 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m ## Image Tools add-on: Remove Background (2026-11-11) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). ## Color Effects add-on: Find Nearest Swatch (2026-11-01) @@ -2057,6 +2074,8 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints (its strokes' miter tips, and strokes a Transform effect moves, included), after its glyphs grown by half its strokes' weight; `.ai` import evidence updated (#6824); scores and estimates unchanged. | +| 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. | +| 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | diff --git a/docs/three-d.md b/docs/three-d.md index 1026a9e1be..a79e6a4900 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -141,7 +141,7 @@ them all. The members stay editable: moving or editing one reshapes the solids. ## Clipping groups, blends and symbol instances -Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate and Revolve) also applies to a clipping +Every 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Inflate, Rotate, Rotate (Classic), Revolve and Revolve (Classic)) also applies to a clipping group, a blend or a symbol instance, alone or inside a group or layer. The help page says that effects apply to objects, groups or layers ([Apply and modify effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html)), that a clipping mask hides everything outside its shape ([Clipping masks](https://helpx.adobe.com/illustrator/using/clipping-masks.html)), @@ -279,7 +279,7 @@ Our reading: command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the art", and a loaded file draws the source path. - Canvas, SVG and PDF exports draw the shadow, the art's bounds take it in, and Expand Appearance keeps it as a - path below the faces. Extrude & Bevel (Classic) has no shadow. + path below the faces. Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) have no shadow. ## Parameters (`effect.apply`, `effect.setParams`) @@ -336,8 +336,11 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a circle builds. -- **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), - Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. +- **3D (Classic)**: Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) are built, with their surface + types and blend steps. Revolve (Classic) sweeps the surface Revolve sweeps, at 64 segments for a full turn, and draws + every face back to front, so it has no Draw Hidden Faces setting. Rotate (Classic) turns the plane Rotate turns, seen + from either side, with the No Shading and Diffuse surfaces only. The track cube, map art, Turntable and 3D export + are separate, unbuilt items. Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md). diff --git a/docs/type-parity.md b/docs/type-parity.md index 7d7af40c51..30547d5a17 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. | | 2026-11-11 | minor | Type in envelopes: its strokes (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight, Distort Appearance on or off, as the drawn art of users' `.ai` files strokes them; with Distort Appearance they bent with the art as their filled outlines. The same holds in an envelope inside an envelope. Geometry effects on type's fills and strokes, and an opacity mask on type, still bend; Expand gives the bent outlines with the strokes on them. The visual bounds of envelopes, blends and repeats count type's character strokes as type's own visual bounds do. This changes how VectorCraft's own envelopes of stroked type draw (#6644). Score unchanged (estimated) | | 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 36a91691b1..dbe69b7603 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -373,6 +373,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`; `panels::learn::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | @@ -519,6 +520,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Remove Background for a selected image in the Contextual Task Bar, Properties › Quick Actions and the Control bar, which runs at once with its defaults. Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Ruler origin: the corner drag snaps as a drawn point does, onto the art's and the artboards' anchors, centres, edges and paths, and with Snap to Point along the ruler guides; scores and estimates unchanged | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. |