Merge pull request #6790 from dotCooCoo/feat/hpgl-export

File formats: HP-GL (.plt) plots export for pen plotters and cutters
This commit is contained in:
BFlat
2026-11-12 18:19:22 +07:00
committed by GitHub
51 changed files with 2665 additions and 23 deletions
+1 -1
View File
@@ -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
+7
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+265
View File
@@ -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<Vec<Point>>,
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<Vec<Point>>, rule: FillRule) -> Option<Self> {
let rings: Vec<Vec<Point>> = rings.into_iter().filter(|r| r.len() >= 3).collect();
let bounds = rings.iter().flatten().fold(None, |b: Option<Rect>, 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<f64> {
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<Vec<Vec<Point>>> {
let edges = self.edges();
let mut out: Vec<Vec<Point>> = vec![];
let mut cur: Vec<Point> = 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<Vec<Point>>, cur: &mut Vec<Point>) {
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<f64> {
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<Point> {
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<Vec<Point>> {
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());
}
}
+973
View File
@@ -0,0 +1,973 @@
//! 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<Node>) -> 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::<i64>().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<Node>, 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(), &regions).unwrap();
assert_eq!(plots.len(), 2);
for p in &plots {
assert_eq!(p.pens, [[0, 0, 0], [255, 0, 0]]);
}
assert_eq!(text(&plots[0]), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;");
assert_eq!(text(&plots[1]), "IN;PU;SP1;PU141,1129;PD282,1129;PU;SP2;PU141,1270;PD282,1270;PU;SP0;");
}
#[test]
fn hidden_and_non_printing_art_is_left_out() {
let mut d = doc_with(|d| {
let mut hidden = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK);
hidden.visible = false;
let none = Node::path(d.alloc_id(), rect(30.0, 10.0, 40.0, 20.0), Appearance::basic(Paint::None, Paint::None, 1.0));
vec![hidden, none]
});
let mut layer = Node::layer(d.alloc_id(), "Notes", LayerColor::Preset(1));
if let NodeKind::Layer { printable, .. } = &mut layer.kind {
*printable = false;
}
let art = stroked(&mut d, rect(50.0, 10.0, 60.0, 20.0), Color::BLACK);
layer.children_mut().unwrap().push(Arc::new(art));
d.layers.push(Arc::new(layer));
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
}
/// Hidden and template layers, guides of either kind and placed images draw nothing; an image
/// comes back as a warning.
#[test]
fn layers_guides_and_images_that_draw_nothing_are_left_out() {
let mut d = doc_with(|d| {
let mut guide = stroked(d, rect(10.0, 10.0, 20.0, 20.0), Color::BLACK);
if let NodeKind::Path { guide, .. } = &mut guide.kind {
*guide = true;
}
let ruler = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 50.0, artboard: None });
let image = vectorcraft_doc::ImageObject {
key: "raster-1".into(),
width: 4,
height: 2,
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
pixels: None,
};
vec![ruler, guide, Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)))]
});
let hidden = d.add_layer(Some("Notes"));
let template = d.add_layer(Some("Tracing"));
for l in [hidden, template] {
let n = stroked(&mut d, rect(30.0, 10.0, 40.0, 20.0), Color::BLACK);
d.insert(Some(l), 0, n).unwrap();
}
if let Some(n) = d.node_mut(hidden) {
n.visible = false;
}
if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) {
*template = true;
}
let out = plot(&d, &opts(&d));
assert_eq!(text(&out), "IN;PU;SP0;");
assert_eq!(out.warnings, [IMAGES]);
}
/// A non-printing layer's Crop Marks are left out with the layer, and so is a template layer's.
#[test]
fn the_crop_marks_of_a_layer_left_out_are_left_out() {
for template in [false, true] {
let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK)]);
let layer = d.layers[0].id;
if let Some(n) = d.node_mut(layer) {
n.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true });
if let NodeKind::Layer { printable, template: t, .. } = &mut n.kind {
(*printable, *t) = (template, template);
}
}
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;", "template: {template}");
}
}
/// A guide's Crop Marks are left out with the guide, in the layers and in a symbol's art.
#[test]
fn the_crop_marks_of_a_guide_are_left_out() {
let guide = |d: &mut Document| {
let mut g = stroked(d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK);
g.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true });
if let NodeKind::Path { guide, .. } = &mut g.kind {
*guide = true;
}
g
};
let d = doc_with(|d| {
let g = guide(d);
vec![Node::group(d.alloc_id(), vec![Arc::new(g)])]
});
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
let mut d = Document::new(200.0, 100.0);
let g = guide(&mut d);
let art = Node::group(d.alloc_id(), vec![Arc::new(g)]);
let d = symbol_doc(art, |_| {});
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
}
/// Inline graphics in type are left out with a warning.
#[test]
fn inline_graphics_in_type_are_left_out_with_a_warning() {
let d = doc_with(|d| {
let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let mut t = TextObject::point(Point::new(10.0, 60.0), "O", style.clone());
t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("mark"), style));
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == INLINE_GRAPHICS), "{:?}", out.warnings);
}
/// A gradient's pen is the average of its stops' colors.
#[test]
fn a_gradient_takes_the_average_of_its_stops() {
let mut g = vectorcraft_color::GradientPaint::new(Default::default());
g.gradient.stops[0].color = Color::rgb8(255, 0, 0);
g.gradient.stops[1].color = Color::rgb8(0, 0, 255);
let d =
doc_with(|d| vec![Node::path(d.alloc_id(), rect(10.0, 10.0, 60.0, 40.0), Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 1.0))]);
assert_eq!(plot(&d, &opts(&d)).pens, [[128, 0, 128]]);
}
/// An object or group at 0% opacity shows nothing, and a fill or stroke at 0% opacity gives a
/// path no color: here only the blue fill's outline is drawn.
#[test]
fn art_at_no_opacity_is_left_out() {
let (red, green, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 255, 0), Color::rgb8(0, 0, 255));
let d = doc_with(|d| {
let mut faded = stroked(d, rect(10.0, 10.0, 20.0, 20.0), red);
faded.opacity = 0.0;
let inner = stroked(d, line((30.0, 10.0), (40.0, 10.0)), green);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
group.opacity = 0.0;
let mut clear_fill = filled(d, rect(10.0, 30.0, 20.0, 40.0), green);
let mut fill_and_clear_stroke =
Node::path(d.alloc_id(), rect(50.0, 10.0, 60.0, 20.0), Appearance::basic(Paint::solid(blue), Paint::solid(red), 1.0));
for item in clear_fill.appearance.items.iter_mut().chain(fill_and_clear_stroke.appearance.items.iter_mut()) {
match item {
AppearanceItem::Fill(f) if f.paint == Paint::solid(green) => f.opacity = 0.0,
AppearanceItem::Stroke(s) => s.opacity = 0.0,
_ => {}
}
}
vec![faded, group, clear_fill, fill_and_clear_stroke]
});
let out = plot(&d, &opts(&d));
assert_eq!(out.pens, [[0, 0, 255]]);
assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out));
}
/// A pattern's pen is the color of its first visible solid fill: a hidden tile object, one at 0%
/// opacity and a fill at 0% opacity are skipped.
#[test]
fn a_pattern_takes_the_color_of_its_first_visible_fill() {
let mut d = doc_with(|_| vec![]);
let mut hidden = filled(&mut d, rect(0.0, 0.0, 5.0, 5.0), Color::rgb8(255, 0, 0));
hidden.visible = false;
let mut faded = filled(&mut d, rect(0.0, 5.0, 5.0, 10.0), Color::rgb8(0, 255, 0));
faded.opacity = 0.0;
let mut clear_fill = filled(&mut d, rect(5.0, 5.0, 10.0, 10.0), Color::rgb8(255, 255, 0));
for item in &mut clear_fill.appearance.items {
if let AppearanceItem::Fill(f) = item {
f.opacity = 0.0;
}
}
let shown = filled(&mut d, rect(5.0, 0.0, 10.0, 5.0), Color::rgb8(0, 0, 255));
let art = [hidden, faded, clear_fill, shown].map(Arc::new).to_vec();
d.patterns.push(vectorcraft_doc::PatternDef::new("tiles", art));
let path = Node::path(
d.alloc_id(),
rect(10.0, 10.0, 50.0, 50.0),
Appearance::basic(Paint::Pattern { pattern: "tiles".into(), xf: Default::default() }, Paint::None, 1.0),
);
let layer = d.layers[0].id;
d.insert(Some(layer), usize::MAX, path).unwrap();
assert_eq!(plot(&d, &opts(&d)).pens, [[0, 0, 255]]);
}
#[test]
fn art_is_cut_at_the_artboard_and_by_clipping_masks() {
let d = doc_with(|d| {
let across = stroked(d, line((-50.0, 50.0), (250.0, 50.0)), Color::BLACK);
let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::default());
let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::BLACK);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![across, group]
});
let t = text(&plot(&d, &opts(&d)));
assert_eq!(t, "IN;PU;SP1;PU0,706;PD2822,706;PU282,988;PD564,988;PU;SP0;");
}
#[test]
fn type_is_drawn_as_its_outlines() {
let d = doc_with(|d| {
let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Ol", style))))]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TEXT_OUTLINES), "{:?}", out.warnings);
// A pen-up move to each contour (the O's two and the l's one), and the two around the pen.
let t = text(&out);
assert!(t.matches("PU").count() >= 5, "{t}");
}
#[test]
fn an_empty_or_zero_length_plot_has_no_moves() {
let empty = Document::new(200.0, 100.0);
assert_eq!(text(&plot(&empty, &opts(&empty))), "IN;PU;SP0;");
let d =
doc_with(|d| vec![stroked(d, line((10.0, 10.0), (10.0, 10.0)), Color::BLACK), stroked(d, line((10.0, 10.0), (10.01, 10.0)), Color::BLACK)]);
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
let bad = HpglOptions { region: Rect::new(0.0, 0.0, 0.0, 10.0), ..HpglOptions::default() };
assert_eq!(export(&d, &bad).err().as_deref(), Some("the export region is empty"));
let nan = HpglOptions { region: Rect::new(0.0, 0.0, f64::NAN, 10.0), ..HpglOptions::default() };
assert!(export(&d, &nan).is_err());
}
/// A tolerance outside its range and a pen table of more than 255 pens are errors, not changed.
#[test]
fn bad_options_are_errors() {
let d = three();
for tolerance in [0.0, 0.009, 10.01, f64::NAN, f64::INFINITY] {
let o = HpglOptions { tolerance, ..opts(&d) };
assert_eq!(export(&d, &o).err().as_deref(), Some("the curve tolerance must be from 0.01 to 10 points"), "{tolerance}");
}
for tolerance in [0.01, 10.0] {
assert!(export(&d, &HpglOptions { tolerance, ..opts(&d) }).is_ok(), "{tolerance}");
}
let o = HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS + 1], ..opts(&d) };
assert_eq!(export(&d, &o).err().as_deref(), Some("the pen table holds more than 255 pens"));
assert!(export(&d, &HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS], ..opts(&d) }).is_ok());
}
#[test]
fn art_past_the_plotter_range_is_cut_off() {
let d = doc_with(|d| vec![stroked(d, line((0.0, 50.0), (1e9, 50.0)), Color::BLACK)]);
let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() });
assert!(out.warnings.iter().any(|w| w == OUT_OF_RANGE), "{:?}", out.warnings);
assert_eq!(text(&out), format!("IN;PU;SP1;PU0,706;PD{MAX_COORD},706;PU;SP0;"));
// Art past the range but off the region asked for would be cut off anyway: no warning.
let d = doc_with(|d| vec![stroked(d, line((-1e9, 50.0), (10.0, 50.0)), Color::BLACK)]);
let out = plot(&d, &HpglOptions { region: Rect::new(0.0, 0.0, 1e9, 100.0), ..HpglOptions::default() });
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP1;PU0,706;PD141,706;PU;SP0;");
}
/// HP-GL past 16-bit coordinates is written as it is, with a warning; HP-GL/2 gets none.
#[test]
fn hpgl_past_16_bit_coordinates_warns() {
let region = Rect::new(-2400.0, 0.0, 2400.0, 100.0);
// Past 32,767 from the bottom-left corner, within the range from the center.
let d = doc_with(|d| vec![stroked(d, line((-2000.0, 50.0), (2000.0, 50.0)), Color::BLACK)]);
let out = plot(&d, &HpglOptions { region, ..HpglOptions::default() });
assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]);
assert_eq!(text(&out), "IN;PU;SP1;PU5644,706;PD62089,706;PU;SP0;");
let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() });
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP1;PU-28222,0;PD28222,0;PU;SP0;");
// Past −32,768 and 32,767 from the center.
let d = doc_with(|d| vec![stroked(d, line((-2400.0, 50.0), (2400.0, 50.0)), Color::BLACK)]);
let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() });
assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]);
assert_eq!(text(&out), "IN;PU;SP1;PU-33867,0;PD33867,0;PU;SP0;");
let out = plot(&d, &HpglOptions { region, origin: Origin::Center, version: Version::Hpgl2, ..HpglOptions::default() });
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
}
#[test]
fn a_plot_stops_at_its_point_budget() {
let d = doc_with(|d| (0..MAX_POINTS / 4).map(|i| stroked(d, rect(1.0, 1.0, 2.0 + (i % 50) as f64, 3.0), Color::BLACK)).collect());
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings);
assert!(text(&out).matches("PU").count() <= MAX_POINTS / 5 + 3);
}
/// The points a plain plot moves through.
fn moves(t: &str) -> usize {
t.split_terminator(';')
.filter(|i| i.starts_with("PU") || i.starts_with("PD"))
.map(|i| i[2..].split(',').filter(|v| !v.is_empty()).count() / 2)
.sum()
}
#[test]
fn one_path_longer_than_the_budget_is_cut_at_it() {
let d = doc_with(|d| {
let mut bp = BezPath::new();
bp.move_to((1.0, 1.0));
for i in 0..MAX_POINTS + 10 {
bp.line_to((if i % 2 == 0 { 2.0 } else { 1.0 }, 1.0 + (i % 50) as f64));
}
vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings);
let n = moves(&text(&out));
assert!(n > 2 && n <= MAX_POINTS, "{n}");
}
/// A path whose flattening stops at the budget while its moves so far lie off the artboard: the
/// rest, over the artboard, is left out with a warning.
#[test]
fn a_path_cut_short_by_the_budget_warns() {
let d = doc_with(|d| {
let mut bp = BezPath::new();
bp.move_to((-50.0, 10.0));
for i in 0..MAX_POINTS * 4 + 10 {
bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0));
}
bp.line_to((50.0, 50.0));
bp.line_to((60.0, 50.0));
vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings);
}
/// A filled path cut short by the budget is not closed: the move from where it stopped back to its
/// start is not on its outline. Only its first move, along y = 50 off the artboard's left edge,
/// is drawn.
#[test]
fn a_filled_path_cut_short_stays_open() {
let d = doc_with(|d| {
let mut bp = BezPath::new();
bp.move_to((10.0, 50.0));
bp.line_to((-40.0, 50.0));
for i in 0..MAX_POINTS * 4 + 10 {
bp.line_to((if i % 2 == 0 { -40.0 } else { -50.0 }, 10.0));
}
vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD0,706;PU;SP0;");
}
/// A blend's steps that its bent spine turns onto the region plotted are drawn, although its keys
/// and spine lie off it.
#[test]
fn blend_steps_a_spine_turns_onto_the_region_are_drawn() {
let d = doc_with(|d| {
let keys =
vec![stroked(d, line((-400.0, -200.0), (200.0, -200.0)), Color::BLACK), stroked(d, line((-560.0, -60.0), (40.0, -60.0)), Color::BLACK)];
let mut spine = BezPath::new();
spine.move_to((-100.0, -200.0));
spine.quad_to((-100.0, -60.0), (-260.0, -60.0));
let spec = vectorcraft_doc::BlendSpec {
spacing: vectorcraft_doc::BlendSpacing::Steps(20),
spine: Some(PathData::from_bezpath(&spine)),
orientation: vectorcraft_doc::BlendOrientation::AlignToPath,
..Default::default()
};
vec![Node::new(d.alloc_id(), NodeKind::Blend { children: keys.into_iter().map(Arc::new).collect(), spec: Box::new(spec) })]
});
// The keys and spine lie above y = -55; the steps turned along the spine cross this area.
let region = Rect::new(-300.0, 0.0, -200.0, 50.0);
let t = text(&plot(&d, &HpglOptions { region, ..HpglOptions::default() }));
assert!(t.contains("PD"), "{t}");
}
/// A closed path that starts on the artboard, leaves it and comes back is one line, with no
/// pen lift where it started.
#[test]
fn a_closed_path_cut_at_the_artboard_is_one_line() {
let d = doc_with(|d| {
let mut bp = BezPath::new();
bp.move_to((150.0, 10.0));
bp.line_to((250.0, 10.0));
bp.line_to((250.0, 50.0));
bp.line_to((150.0, 50.0));
bp.close_path();
vec![stroked(d, PathData::from_bezpath(&bp), Color::BLACK)]
});
let t = text(&plot(&d, &opts(&d)));
assert_eq!(t, "IN;PU;SP1;PU2822,706;PD2117,706,2117,1270,2822,1270;PU;SP0;");
}
/// A clipping path that paints is drawn once: where its fill paints (before the art it clips),
/// or where its stroke paints (after it).
#[test]
fn a_clipping_path_is_drawn_where_it_paints() {
let clip_doc = |fill: Paint, stroke: Paint| {
doc_with(|d| {
let clip = Node::path(d.alloc_id(), rect(20.0, 20.0, 40.0, 40.0), Appearance::basic(fill, stroke, 1.0));
let clipped = stroked(d, line((0.0, 30.0), (200.0, 30.0)), Color::BLACK);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![group]
})
};
let red = Paint::solid(Color::rgb8(255, 0, 0));
let filled = clip_doc(red.clone(), Paint::None);
assert_eq!(plot(&filled, &opts(&filled)).pens, [[255, 0, 0], [0, 0, 0]]);
let stroked = clip_doc(Paint::None, red.clone());
assert_eq!(plot(&stroked, &opts(&stroked)).pens, [[0, 0, 0], [255, 0, 0]]);
let both = clip_doc(Paint::solid(Color::rgb8(0, 0, 255)), red);
let out = plot(&both, &opts(&both));
assert_eq!(out.pens, [[0, 0, 0], [255, 0, 0]], "drawn once, in its stroke's color");
// Two pen changes, a move to each of the two paths, and the last pen up.
assert_eq!(text(&out).matches("PU").count(), 5, "{}", text(&out));
// A stroke at 0% opacity isn't drawn: the clipping path is drawn in its fill, below.
let faded = doc_with(|d| {
let mut clip = Node::path(
d.alloc_id(),
rect(20.0, 20.0, 40.0, 40.0),
Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0),
);
for item in &mut clip.appearance.items {
if let AppearanceItem::Stroke(s) = item {
s.opacity = 0.0;
}
}
let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![group]
});
assert_eq!(plot(&faded, &opts(&faded)).pens, [[0, 0, 255], [0, 0, 0]]);
// An open clipping path with a fill and a stroke is drawn closed, as other filled paths are.
let mut open = BezPath::new();
open.move_to((20.0, 20.0));
open.line_to((40.0, 20.0));
open.line_to((40.0, 40.0));
let open = PathData::from_bezpath(&open);
let open_doc = doc_with(|d| {
let clip = Node::path(d.alloc_id(), open, Appearance::basic(Paint::solid(Color::rgb8(0, 0, 255)), Paint::solid(Color::rgb8(255, 0, 0)), 1.0));
let clipped = Node::path(d.alloc_id(), line((0.0, 30.0), (200.0, 30.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![group]
});
let t = text(&plot(&open_doc, &opts(&open_doc)));
assert!(t.ends_with("SP2;PU282,1129;PD564,1129,564,847,282,1129;PU;SP0;"), "{t}");
}
/// A symbol `art` and an instance of it at its place.
fn symbol_doc(art: Node, instance: impl FnOnce(&mut Node)) -> Document {
let mut d = doc_with(|d| {
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
instance(&mut n);
vec![n]
});
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art) });
d
}
/// Symbols nested more than 8 deep are left out with a warning.
#[test]
fn symbols_nested_too_deep_are_left_out_with_a_warning() {
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s9".into(), xf: Affine::IDENTITY })]);
let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s0".into(), art: Arc::new(art) });
for i in 1..10 {
let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("s{}", i - 1), xf: Affine::IDENTITY });
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("s{i}"), art: Arc::new(inner) });
}
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == TOO_DEEP), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP0;");
}
/// A symbol instance stained by its fill draws in the stained color.
#[test]
fn a_stained_symbol_instance_takes_its_stain() {
let mut d = Document::new(200.0, 100.0);
let art = stroked(&mut d, rect(10.0, 10.0, 60.0, 40.0), Color::BLACK);
let d = symbol_doc(art, |n| n.appearance = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 1.0).into());
assert_eq!(plot(&d, &opts(&d)).pens, [[255, 0, 0]]);
}
/// The geometry effects in a symbol's art apply: a line moved 30 pt right by a Transform effect.
#[test]
fn a_symbol_draws_its_art_with_its_effects() {
let mut d = Document::new(200.0, 100.0);
let mut art = stroked(&mut d, line((10.0, 10.0), (20.0, 10.0)), Color::BLACK);
art.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 30.0}),
visible: true,
});
let d = symbol_doc(art, |_| {});
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP1;PU564,1270;PD706,1270;PU;SP0;");
}
/// Art an effect moves onto the artboard from off it is drawn, for an instance on its own and
/// one in a group: the bounds that skip art off the artboard don't measure symbol effects.
#[test]
fn symbol_art_an_effect_moves_onto_the_artboard_is_drawn() {
let mut d = Document::new(200.0, 100.0);
let mut art = stroked(&mut d, line((-30.0, 10.0), (-20.0, 10.0)), Color::BLACK);
art.appearance.effects.push(vectorcraft_doc::Effect {
id: "distort.transform".into(),
params: serde_json::json!({"moveH": 40.0}),
visible: true,
});
let alone = symbol_doc(art.clone(), |_| {});
assert_eq!(text(&plot(&alone, &opts(&alone))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;");
let mut grouped = doc_with(|d| {
let n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
vec![Node::group(d.alloc_id(), vec![Arc::new(n)])]
});
grouped.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "s".into(), art: Arc::new(art.clone()) });
assert_eq!(text(&plot(&grouped, &opts(&grouped))), "IN;PU;SP1;PU141,1270;PD282,1270;PU;SP0;");
// Other art off the artboard is still skipped: a blend there doesn't spend the live art's
// limit (1,000 objects in tests) before the blend on the artboard.
let mut d = doc_with(|d| {
let off =
vec![stroked(d, line((-500.0, 10.0), (-500.0, 20.0)), Color::BLACK), stroked(d, line((-400.0, 10.0), (-400.0, 20.0)), Color::BLACK)];
let on = vec![stroked(d, line((10.0, 10.0), (10.0, 20.0)), Color::BLACK), stroked(d, line((30.0, 10.0), (30.0, 20.0)), Color::BLACK)];
vec![blend_of(d, off, 1_200), blend_of(d, on, 1)]
});
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "unused".into(), art: Arc::new(art) });
let out = plot(&d, &opts(&d));
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
assert_eq!(text(&out).matches("PD").count(), 3, "{}", text(&out));
}
/// A rectangle with `per_side` straight moves along each side.
fn fine_rect(x0: f64, y0: f64, x1: f64, y1: f64, per_side: u32) -> PathData {
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)];
let mut bp = BezPath::new();
bp.move_to(corners[0]);
for (k, a) in corners.iter().enumerate() {
let b = corners[(k + 1) % 4];
for i in 1..=per_side {
let t = f64::from(i) / f64::from(per_side);
bp.line_to((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t));
}
}
bp.close_path();
PathData::from_bezpath(&bp)
}
/// Nested clipping masks of 6,000 points each pass the 10,000 points masks may hold together
/// in tests: the inner one is left out with a warning, and the line is cut by the outer one only.
#[test]
fn masks_past_their_point_budget_are_left_out() {
let d = doc_with(|d| {
let clip_group = |d: &mut Document, mask: PathData, art: Node| {
let mask = Node::path(d.alloc_id(), mask, Appearance::basic(Paint::None, Paint::None, 1.0));
let mut group = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
group
};
let across = Node::path(d.alloc_id(), line((0.0, 50.0), (200.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
let inner = clip_group(d, fine_rect(50.0, 30.0, 150.0, 70.0, 1500), across);
vec![clip_group(d, fine_rect(10.0, 10.0, 190.0, 90.0, 1500), inner)]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == MASK_POINTS), "{:?}", out.warnings);
assert_eq!(text(&out), "IN;PU;SP1;PU141,706;PD2681,706;PU;SP0;");
}
/// A line through the 500 teeth of a comb-shaped mask crosses its edges about 1,000 times, and
/// each piece between crossings is tested against all 2,002 edges: that spends the clipping
/// budget, so the line is drawn whole with a warning.
#[test]
fn a_mask_crossed_many_times_spends_the_clipping_budget() {
let d = doc_with(|d| {
let mut pts = vec![];
for i in 0..500 {
let x = f64::from(i) * 0.4;
pts.extend([(x, 20.0), (x + 0.2, 20.0), (x + 0.2, 50.0), (x + 0.4, 50.0)]);
}
pts.extend([(200.0, 80.0), (0.0, 80.0)]);
let mut bp = BezPath::new();
bp.move_to(pts[0]);
pts[1..].iter().for_each(|&p| bp.line_to(p));
bp.close_path();
let comb = Node::path(d.alloc_id(), PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::None, 1.0));
let clipped = stroked(d, line((0.5, 35.0), (199.5, 35.0)), Color::BLACK);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(comb), Arc::new(clipped)]);
if let NodeKind::Group { clip, .. } = &mut group.kind {
*clip = true;
}
vec![group]
});
let out = plot(&d, &opts(&d));
assert!(out.warnings.iter().any(|w| w == CLIP_WORK), "{:?}", out.warnings);
assert_eq!(text(&out).matches("PD").count(), 1, "{}", text(&out));
}
#[test]
fn more_colors_than_pens_share_the_nearest_pen() {
let d = doc_with(|d| (0..300).map(|i| stroked(d, rect(1.0, 1.0, 5.0, 5.0), Color::rgb8((i % 256) as u8, (i / 256) as u8, 7))).collect());
let out = plot(&d, &opts(&d));
assert_eq!(out.pens.len(), MAX_PENS);
assert!(out.warnings.iter().any(|w| w == MANY_COLORS), "{:?}", out.warnings);
}
#[test]
fn sorting_draws_the_nearest_path_next() {
let d = doc_with(|d| {
vec![
stroked(d, line((150.0, 90.0), (150.0, 80.0)), Color::BLACK),
stroked(d, line((10.0, 80.0), (10.0, 90.0)), Color::BLACK),
stroked(d, line((80.0, 90.0), (80.0, 80.0)), Color::BLACK),
]
});
let unsorted = text(&plot(&d, &opts(&d)));
assert_eq!(unsorted, "IN;PU;SP1;PU2117,141;PD2117,282;PU141,282;PD141,141;PU1129,141;PD1129,282;PU;SP0;");
// From the origin: the left line drawn backwards from its lower end, then the middle and the
// right one from the end the pen is nearest.
let sorted = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) }));
assert_eq!(sorted, "IN;PU;SP1;PU141,141;PD141,282;PU1129,282;PD1129,141;PU2117,141;PD2117,282;PU;SP0;");
}
/// A closed path is as near as its nearest point, and starts there.
#[test]
fn sorting_measures_a_closed_path_by_its_nearest_point() {
let d = doc_with(|d| vec![stroked(d, rect(40.0, 60.0, 50.0, 70.0), Color::BLACK), stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK)]);
let t = text(&plot(&d, &HpglOptions { sort: true, ..opts(&d) }));
// The large square's bottom-left corner is nearer the origin than any point of the small one.
assert_eq!(t, "IN;PU;SP1;PU141,141;PD141,1270,2681,1270,2681,141,141,141;PU564,423;PD564,564,706,564,706,423,564,423;PU;SP0;");
}
#[test]
fn sorting_stops_at_its_budget_and_keeps_every_path() {
let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect());
let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) });
assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings);
assert_eq!(text(&out).matches("PD").count(), 600);
}
#[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<Point>, Option<Paint>)> {
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<Option<Paint>> = 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)
);
}
}
}
+3 -3
View File
@@ -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;
+2
View File
@@ -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;
@@ -536,6 +536,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, def
| "dxf"
| "emf"
| "wmf"
| "hpgl"
| "ai"
)
};
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -464,7 +464,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
}
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
// themselves: SVG only when it outlines or embeds fonts).
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf"))
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf" | "hpgl"))
&& let Some(w) = crate::cmd::fonts::substitution_warning(doc)
{
enc.warnings.push(w);
@@ -503,7 +503,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes();
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None };
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
if let Some(rect) = page_rect(f, p)? {
let mut q = super::export::without_artboards(p);
@@ -536,6 +536,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
"eps" => return super::eps::encode(doc, p),
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
"wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf),
"hpgl" => return super::hpgl::encode(doc, p, use_artboards),
"ai" => return super::save::encode_ai(C, f, doc, p),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
+13 -3
View File
@@ -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",
+119
View File
@@ -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; a coordinate outside -32,768 to 32,767 gets a warning) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)",
},
FormatOption {
name: "curveTolerance",
ty: "number",
default: "0.1",
description: "how far the straight moves a curve becomes may lie from it, in points (0.01 to 10)",
},
FormatOption {
name: "pens",
ty: "array",
default: "null",
description: "the pen table, pen 1's color first: up to 255 \"#rrggbb\" colors, each path drawn with the pen of the nearest color; null: a pen for each color in the order the art first draws in it, numbered alike in every file of one export",
},
FormatOption {
name: "origin",
ty: "string",
default: "\"bottomLeft\"",
description: "bottomLeft or center: where (0, 0) lies on the artboard (or the art's bounds)",
},
FormatOption {
name: "sortPaths",
ty: "boolean",
default: "false",
description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order",
},
];
/// The HP-GL options in `p` (the region is set per plot).
fn parse(p: &Value) -> Result<HpglOptions> {
let mut o = HpglOptions::default();
if let Some(s) = text_param(p, "hpglVersion", C)? {
let key = "hpglVersion";
o.version = Version::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Version::ALL.map(Version::id).join(", "))))?;
}
let x = number_or(p, "curveTolerance", DEFAULT_TOLERANCE, C)?;
if !TOLERANCE_RANGE.contains(&x) {
let (key, min, max) = ("curveTolerance", TOLERANCE_RANGE.start(), TOLERANCE_RANGE.end());
return Err(bad(C, format!("`{key}` must be between {min} and {max} (got {x})")));
}
o.tolerance = x;
match p.get("pens") {
None | Some(Value::Null) => {}
Some(v) => {
let colors: Option<Vec<[u8; 3]>> = v.as_array().filter(|a| (1..=MAX_PENS).contains(&a.len())).and_then(|a| a.iter().map(hex).collect());
o.pens = colors.ok_or_else(|| bad(C, "`pens` must be an array of 1 to 255 #rrggbb colors"))?;
}
}
if let Some(s) = text_param(p, "origin", C)? {
let key = "origin";
o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?;
}
o.sort = switch_or(p, "sortPaths", false, C)?;
Ok(o)
}
/// A `#rrggbb` color.
fn hex(v: &Value) -> Option<[u8; 3]> {
let s = v.as_str()?.strip_prefix('#').filter(|s| s.len() == 6 && s.bytes().all(|b| b.is_ascii_hexdigit()))?;
let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some([byte(0)?, byte(2)?, byte(4)?])
}
/// Encode `doc` as HP-GL: a plot of the chosen artboard (default the first), or with
/// `use_artboards` one plot per chosen artboard (default all), with one pen numbering for them all.
pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> Result<Encoded> {
let opts = parse(p)?;
let f = super::format("hpgl").ok_or_else(|| bad(C, "no HP-GL format"))?;
let pick: ArtboardPick = super::encode::options(f, p)?;
let n = doc.artboards.len();
let boards = match use_artboards {
Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()),
_ => vec![pick.one(n).map_err(|e| bad(C, e))?],
};
let regions = boards
.iter()
.map(|&b| doc.artboards.get(b).map(|a| a.rect).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))))
.collect::<Result<Vec<_>>>()?;
let plots = vectorcraft_cad::hpgl::export_regions(doc, &opts, &regions).map_err(|e| bad(C, e))?;
let mut enc = Encoded::default();
for (b, out) in boards.into_iter().zip(plots) {
enc.files.push((Some(b), out.bytes));
for w in out.warnings {
if !enc.warnings.contains(&w) {
enc.warnings.push(w);
}
}
}
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
Ok(enc)
}
+1 -1
View File
@@ -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 {
+2
View File
@@ -431,6 +431,7 @@ fn export_extensions_name_every_writable_format() {
"eps",
"emf",
"wmf",
"plt",
"tga",
"psd"
]
@@ -466,6 +467,7 @@ fn formats_query_lists_readers_writers_and_options() {
"eps",
"emf",
"wmf",
"hpgl",
"tga",
"psd"
]
+111 -4
View File
@@ -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<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// Two 100 pt artboards side by side, a 30 pt square on each, the second one red.
fn two_boards() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2})).unwrap();
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
let red = s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap()["id"].clone();
s.execute("paint.setStroke", &json!({"ids": [red], "color": "#ff0000"})).unwrap();
s
}
#[test]
fn export_writes_a_plot_of_one_artboard_or_each() {
let mut s = two_boards();
let r = s.execute("document.export", &json!({"format": "hpgl"})).unwrap();
assert_eq!(r["format"], "hpgl");
// The first artboard's square, 1016 units to the inch from its bottom-left corner.
assert_eq!(String::from_utf8(bytes(&r)).unwrap(), "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;");
let second = String::from_utf8(bytes(&s.execute("document.export", &json!({"format": "hpgl", "artboard": 1})).unwrap())).unwrap();
assert_eq!(second, "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;", "its own square, its own pen 1");
assert_eq!(bytes(&s.execute("document.serialize", &json!({"format": "hpgl"})).unwrap()), bytes(&r));
let r = s.execute("document.export", &json!({"format": "hpgl", "useArtboards": true})).unwrap();
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert!(files[1]["name"].as_str().unwrap().ends_with("-Artboard-2.plt"), "{files:?}");
// One pen numbering for every file: the red square is pen 2 in the second.
assert_eq!(String::from_utf8(bytes(&files[1])).unwrap(), "IN;PU;SP2;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;");
// The bounds of the art: both squares, the second pen red, read back as two paths.
let art = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false, "hpglVersion": "hpgl2"})).unwrap());
assert!(art.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;"), "{:?}", String::from_utf8_lossy(&art));
let back = Session::new().execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&art)})).unwrap();
assert_eq!(back["format"], "hpgl", "{back}");
// One file holds one artboard.
assert!(s.execute("document.export", &json!({"format": "hpgl", "artboards": [0, 1]})).is_err());
}
/// A plot read back opens with the same squares: as wide as the art drew them, a point at most
/// apart from the 30 pt sides plus the 0.35 mm pen.
#[test]
fn a_plot_reads_back_as_its_art() {
let mut s = two_boards();
let plot = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).unwrap());
let mut back = Session::new();
back.execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&plot)})).unwrap();
let doc = &back.doc().unwrap().doc;
let mut paths = 0;
doc.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. })));
assert_eq!(paths, 2);
let board = doc.artboards[0].rect;
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
let pen = 0.35 * 72.0 / 25.4;
assert!((board.width() - (second + 30.0 + pen)).abs() < 0.1, "{board:?}");
assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}");
}
#[test]
fn export_options_are_checked() {
let mut s = two_boards();
let refused = |s: &mut Session, p: Value| s.execute("document.export", &p).expect_err("refused").to_string();
let cases = [
(json!({"format": "hpgl", "hpglVersion": "hpgl3"}), "`hpglVersion` must be one of hpgl, hpgl2"),
(json!({"format": "hpgl", "hpglVersion": 2}), "`hpglVersion` must be a string"),
(json!({"format": "hpgl", "curveTolerance": 0}), "`curveTolerance` must be between 0.01 and 10 (got 0)"),
(json!({"format": "hpgl", "curveTolerance": "fine"}), "`curveTolerance` must be a number"),
(json!({"format": "hpgl", "pens": []}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(json!({"format": "hpgl", "pens": ["#ff0000", "red"]}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"),
(json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"),
];
for (p, want) in cases {
let e = refused(&mut s, p.clone());
assert!(e.contains(want), "{p}: {e}");
}
let many: Vec<String> = (0..256).map(|i| format!("#{:02x}0000", i % 256)).collect();
assert!(refused(&mut s, json!({"format": "hpgl", "pens": many})).contains("`pens`"));
// Given options take effect: the center as the origin, a pen table (black is pen 2), and the
// square starting at its corner nearest the origin.
let p = json!({"format": "hpgl", "origin": "CENTER", "pens": ["#ff0000", "#000000"], "curveTolerance": 10, "sortPaths": true});
let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap();
assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;");
}
/// 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}");
}
+1 -1
View File
@@ -485,7 +485,7 @@ fn making_and_restyling_type_reports_its_overflow() {
}
/// The formats whose exports draw type.
const DRAWING_FORMATS: [&str; 8] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf"];
const DRAWING_FORMATS: [&str; 9] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf", "hpgl"];
/// The warnings of `document.export {format, …options}` about type that doesn't fit its frame.
fn export_overflow(s: &mut Session, format: &str, options: Value) -> Vec<String> {
+1
View File
@@ -77,6 +77,7 @@ fn export_tool_lists_engine_formats_and_options() {
"eps",
"emf",
"wmf",
"hpgl",
"tga",
"psd"
])
+2 -2
View File
@@ -393,14 +393,14 @@ pub fn tool_definitions() -> Vec<Value> {
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::<Vec<_>>(), "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)"},
+3
View File
@@ -878,6 +878,9 @@ mod tests_eps;
#[cfg(test)]
mod tests_metafile;
#[cfg(test)]
mod tests_hpgl;
#[cfg(test)]
mod tests_tiff_bmp_tga;
+28
View File
@@ -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"]);
}
+1
View File
@@ -4410,6 +4410,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 първо завършете редактирането на мострата
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4801,6 +4801,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5654,6 +5654,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4887,6 +4887,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4511,6 +4511,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4504,6 +4504,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4534,6 +4534,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 ολοκληρώστε πρώτα την επεξεργασία του μοτίβου
Can't render this file because it is too large.
+1
View File
@@ -4579,6 +4579,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4795,6 +4795,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4568,6 +4568,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -7144,6 +7144,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 देखें)
Can't render this file because it is too large.
+1
View File
@@ -5264,6 +5264,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4511,6 +4511,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
Can't render this file because it contains an unexpected character in line 2383 and column 5.
+1
View File
@@ -4630,6 +4630,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4700,6 +4700,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 破損しています
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4339,6 +4339,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 ჯერ დაასრულეთ პატერნის რედაქტირება
Can't render this file because it is too large.
+1
View File
@@ -4627,6 +4627,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 손상됨
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4523,6 +4523,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4712,6 +4712,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4117,6 +4117,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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4616,6 +4616,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 сначала завершите редактирование узора
Can't render this file because it is too large.
+1
View File
@@ -4482,6 +4482,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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -4337,6 +4337,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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -4614,6 +4614,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 спочатку завершіть редагування візерунка
Can't render this file because it contains an unexpected character in line 182 and column 5.
+1
View File
@@ -4927,6 +4927,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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+1
View File
@@ -4398,6 +4398,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 已损坏
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2195,6 +2195,7 @@ file-filter Templates 範本
@msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖
@msg not an EMF or WMF file 不是 EMF 或 WMF 檔案
@msg not an HP-GL file 不是 HP-GL 檔案
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必須是由 1 到 255 個 #rrggbb 格式顏色組成的陣列
@msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取
@msg the EMF header is damaged EMF 檔頭已損毀
@msg damaged 已損毀
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -807,6 +807,9 @@ EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or
dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and
Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`.
HP-GL: Export As… lists it (`file.exportAs {format: "hpgl"}`); it has no options dialog, so `ui.dialog.confirm` asks
for the path and writes the `.plt` file with the default options (one per artboard with Use Artboards).
TIFF, BMP and Targa Options: Export As… with the TIFF, BMP or Targa format (`file.exportAs {format: "tiff"}`, then
the save dialog) opens `tiffOptions`, `bmpOptions` or `tgaOptions`: the raster rows of PNG Options (`ppi`,
`background`, `antiAlias`), then TIFF's `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK document starts on `cmyk`),
+1 -1
View File
@@ -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 `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) |
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open |
| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | no | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `tests_hpgl.rs`, `import_fuzz.rs` |
| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` |
| DWG | open, export | no | no | no open spec; DXF instead |
| EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) |
| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) |
+46 -2
View File
@@ -527,7 +527,7 @@ applied colors still join the list while it is hidden.
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |
| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. |
| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it, listed in the reply's `files`). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `hpgl` (a `.plt` plot file for plotters and cutters), `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF, WMF and HP-GL write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. |
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). In remote desktop mode, `run_command` and `invoke_menu` can both call `pathfinder.repeat` with `{}`. Successful panel operations, direct `object.pathfinder.*` commands, and `command.batch` steps (including Actions panel playback) set its history. Repeat applies it to the current selection using current Pathfinder Options. Trap, compound-shape edits, live effects and `action.batch` do not set history. With no history it returns "no Pathfinder operation applied yet". |
@@ -6499,13 +6499,57 @@ 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. An `"hpgl"` plot
with a coordinate outside −32,768 to 32,767 units (about 82 cm from the origin, the range of 16-bit numbers) is written
as it is, with a warning that older devices may not read it; this check is VectorCraft's own. `warnings` names
type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke
looks that are not drawn, art past the plotter's range, HP-GL coordinates past 16-bit numbers, colors past 255 pens,
and the limits on points, clipping work, symbols, live art, nesting depth and sorting.
```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
+1
View File
@@ -1518,6 +1518,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`).