From 987dd18aff291b18cc5968a4583683eb83ab5864 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:41:25 -0800 Subject: [PATCH] File formats: HP-GL (.plt) plots export for pen plotters and cutters VectorCraft could open and place HP-GL plot files but not write them, so art for a pen plotter or a vinyl cutter had to go through another program. Export As, document.export and Export Selection now write HP-GL. vectorcraft_cad::hpgl::export draws each visible path once along its own line: a filled shape is its closed outline and a stroked path its center line. Type is drawn as glyph outlines, a gradient mesh as its outline, and a symbol instance as its art with its effects and 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, guides and placed images are left out. Coordinates are plotter units (1016 to the inch, y up) from the artboard's bottom-left corner or its center, and art more than 8,388,607 units from the origin is cut off. Each color is a pen, in the order the plot first draws in it (up to 255), or a pen table maps each path to the pen of the nearest color. With Use Artboards every file of the export uses the same numbering. The pens draw in turn, each in paint order or, with sortPaths, nearest path first. Curves are flattened to curveTolerance (0.1 pt by default). hpglVersion "hpgl" writes IN, SP, PU and PD; "hpgl2" also writes the palette (NP, PC) and each pen's paths as one encoded polyline (PE). Options are checked, and Export As writes the file with the defaults. Plots read back by the HP-GL reader give the same lines. Docs: docs/mcp.md, docs/control-protocol.md, docs/file-format-parity.md, docs/target-app-parity.md. --- crates/cad/Cargo.toml | 2 +- crates/cad/src/hpgl.rs | 7 + crates/cad/src/hpgl/write.rs | 1033 ++++++++++++++++++ crates/cad/src/hpgl/write/clip.rs | 265 +++++ crates/cad/src/hpgl/write/tests.rs | 936 ++++++++++++++++ crates/cad/src/lib.rs | 6 +- crates/engine/src/cmd/fileio.rs | 2 + crates/engine/src/cmd/fileio/action_batch.rs | 1 + crates/engine/src/cmd/fileio/commands.rs | 6 +- crates/engine/src/cmd/fileio/encode.rs | 5 +- crates/engine/src/cmd/fileio/formats.rs | 16 +- crates/engine/src/cmd/fileio/hpgl.rs | 119 ++ crates/engine/src/cmd/fileio/screens.rs | 2 +- crates/engine/src/cmd/fileio/tests.rs | 2 + crates/engine/src/cmd/fileio/tests_hpgl.rs | 115 +- crates/engine/src/tests/areafit.rs | 2 +- crates/mcp/src/tests/fileio.rs | 1 + crates/mcp/src/tools/definitions.rs | 4 +- crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/dialogs/tests_hpgl.rs | 28 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/control-protocol.md | 3 + docs/file-format-parity.md | 2 +- docs/mcp.md | 46 +- docs/target-app-parity.md | 1 + 51 files changed, 2611 insertions(+), 23 deletions(-) create mode 100644 crates/cad/src/hpgl/write.rs create mode 100644 crates/cad/src/hpgl/write/clip.rs create mode 100644 crates/cad/src/hpgl/write/tests.rs create mode 100644 crates/engine/src/cmd/fileio/hpgl.rs create mode 100644 crates/ui-egui/src/dialogs/tests_hpgl.rs 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..9e77495aa2 --- /dev/null +++ b/crates/cad/src/hpgl/write.rs @@ -0,0 +1,1033 @@ +//! 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. +//! 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. +//! +//! 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; + +use std::collections::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 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 point comparisons sorting may make, over the whole plot. +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 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 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`]). + pub tolerance: f64, + /// The pen table: pen 1's color first. 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, + /// 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, 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()); + } + let tolerance = + if opts.tolerance.is_finite() { opts.tolerance.clamp(*TOLERANCE_RANGE.start(), *TOLERANCE_RANGE.end()) } else { DEFAULT_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 { + let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); + for paths in &mut by_pen { + let (stop, sorted) = nearest_first(paths, at, &mut work); + at = stop; + if !sorted { + warn(&mut warnings, UNSORTED); + } + } + } + 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` reordered from `at`: each next path is the one nearest where the pen stopped, by its +/// start or, for an open path (then drawn backwards), its end, or for a closed path any of its +/// points (where it then starts). Comparing points stops once `work` is spent; the paths left keep +/// their order. Returns where the pen stops, and `false` when the work ran out. +fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mut u64) -> ((i64, i64), bool) { + // Each path with its box. + let mut left: Vec<_> = std::mem::take(paths) + .into_iter() + .map(|p| { + let b = p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]); + (p, b) + }) + .collect(); + while !left.is_empty() { + // (index, reversed, the closed path's point to start at, distance) + let mut best: (usize, bool, Option, i128) = (0, false, None, i128::MAX); + for (i, (p, b)) in left.iter().enumerate() { + let (Some(&s), Some(&e)) = (p.first(), p.last()) else { continue }; + *work = work.saturating_sub(1); + 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); + } + } + if *work == 0 { + paths.extend(left.into_iter().map(|(p, _)| p)); + return (at, false); + } + let (mut p, _) = left.remove(best.0); + 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, true) +} + +/// 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/tests.rs b/crates/cad/src/hpgl/write/tests.rs new file mode 100644 index 0000000000..2093fe8b48 --- /dev/null +++ b/crates/cad/src/hpgl/write/tests.rs @@ -0,0 +1,936 @@ +//! 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()); +} + +#[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;"); +} + +#[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); +} + +#[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 525e9b6734..d96df39b6a 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/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 6478cc81b8..7de909e92a 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; diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 97b44597b6..8a82b00a5c 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -526,6 +526,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 4fe2d011d9..4ba3be95af 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 { "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 { "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., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), 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 { "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..0eeff7ab2e 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 { } // 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 { 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 { "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)?; diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index b04104f70f..aeaf962faf 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", diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs new file mode 100644 index 0000000000..0016afa10e --- /dev/null +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -0,0 +1,119 @@ +//! 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) 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", + }, +]; + +/// The HP-GL options in `p` (the region is set per plot). +fn parse(p: &Value) -> Result { + 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> = 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)?; + 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) -> Result { + 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::>>()?; + 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 = 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/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 56c0d808a5..80b2dca633 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 6236d281e2..0e7fba5397 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -418,6 +418,7 @@ fn export_extensions_name_every_writable_format() { "eps", "emf", "wmf", + "plt", "tga", "psd" ] @@ -453,6 +454,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..1cb4c03b83 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,111 @@ 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"] { + 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 { + 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"), + ]; + for (p, want) in cases { + let e = refused(&mut s, p.clone()); + assert!(e.contains(want), "{p}: {e}"); + } + let many: Vec = (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;"); +} + +/// 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/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 { 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 17c7ecc912..783f3c0030 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/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f9788d59a8..07c8d79834 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -876,6 +876,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/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/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..81380a9c3d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4377,6 +4377,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 първо завършете редактирането на мострата diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..d92603bb7b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4768,6 +4768,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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..b7ec8525ce 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5617,6 +5617,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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..63ac301d81 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4851,6 +4851,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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..ca09b4bed0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4478,6 +4478,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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..2ced4ef2a1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4460,6 +4460,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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..6c31c1d611 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4498,6 +4498,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 ολοκληρώστε πρώτα την επεξεργασία του μοτίβου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..9f31d491b6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4543,6 +4543,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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..3b98ca2986 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4759,6 +4759,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..ec2e75ad44 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4532,6 +4532,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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..275f1f0276 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7093,6 +7093,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 देखें) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..1a895478fd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5228,6 +5228,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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..553354beca 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4478,6 +4478,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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..6fd0502fb8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4586,6 +4586,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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..cd6799964b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4656,6 +4656,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 破損しています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..64fd588698 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4303,6 +4303,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 ჯერ დაასრულეთ პატერნის რედაქტირება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..528a75447c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4583,6 +4583,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 손상됨 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..7a64d8fbbf 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4490,6 +4490,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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..a76f1839c0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4676,6 +4676,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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..5669345067 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4082,6 +4082,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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..4426c78863 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4580,6 +4580,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 сначала завершите редактирование узора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..0264c0f1c3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4447,6 +4447,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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..2dca3b1d7e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4301,6 +4301,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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..38b8a66d1c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4570,6 +4570,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 спочатку завершіть редагування візерунка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..5929e2b253 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4883,6 +4883,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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..6994dec220 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4362,6 +4362,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 已损坏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..294e3bf25f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2174,6 +2174,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 已損毀 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 704ec14d6f..bd15a3bec5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -796,6 +796,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/file-format-parity.md b/docs/file-format-parity.md index fa682c0272..ba2ddfdaa8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -291,7 +291,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). CNC preset and VCarve QA remain 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) | diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..6843a543ab 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -510,7 +510,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). 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". | @@ -6292,13 +6292,55 @@ 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). Curves become straight moves no further than `curveTolerance` points from the +curve (default 0.1, from 0.01 to 10). These defaults, the 255-pen limit and the rules for pen numbers and colors above +are VectorCraft's own. + +`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. `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, colors past 255 pens, and the limits on points, clipping +work, symbols, live art, nesting depth and sorting. + +```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}}} +``` + ## TIFF, BMP and Targa export `document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..ef3d1a2c11 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1494,6 +1494,7 @@ 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`). - 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`).