Merge pull request #6726 from dotCooCoo/feat/hpgl-import

File formats: HP-GL (.plt) plot files open and place
This commit is contained in:
BFlat
2026-11-12 11:42:46 +07:00
committed by GitHub
44 changed files with 2855 additions and 12 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)"
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader"
[lints]
workspace = true
+195
View File
@@ -0,0 +1,195 @@
//! HP-GL import: a plotter file (HP-GL/2, `.plt`, `.hpgl`, `.hgl`, `.hpg`) as a document. Older
//! HP-GL files are read by the HP-GL/2 rules for the instructions the two share.
//!
//! The pen's moves become paths: each run drawn with the pen down is one stroked path in the
//! selected pen's color and width (0.35 mm unless PW sets another), with the line type as a dash
//! pattern and the line attributes as caps and joins. Circles, arcs, wedges, rectangles and Bézier
//! curves are drawn as curves (the chord angle isn't used). Filled rectangles, wedges and polygons
//! (polygon mode, FP) become filled paths, edged ones stroked paths. Plotter units (1016 to the
//! inch, y up) become points, through user units (SC, IP, IR) and the rotation (RO). The artboard
//! is the bounds of what the art paints, its strokes included.
//!
//! VectorCraft's own rules where the device decides: before the first SP the pen is pen 1 (a
//! device draws nothing until SP), and the hard-clip limits are 11 × 8.5 in from the origin, so
//! P2 lies there when the file sets no scaling points. SP0 draws nothing. Labels, symbol mode's symbols and raster
//! images (HP RTL) aren't drawn, the input window doesn't clip, hatched fills are filled solid,
//! 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.
//!
//! - `lex`: the bytes as instructions, numbers, labels and PE data.
//! - `plot`: the instructions played on the pen, as shapes.
mod lex;
mod plot;
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, LayerColor, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, PathData, Rect};
use crate::Imported;
use lex::Lexer;
use plot::Plot;
/// Points per plotter unit.
const PT_PER_PLU: f64 = 72.0 / 1016.0;
/// The weight of a zero-width pen (one device dot), in points.
const THINNEST: f64 = 0.25;
/// Instructions the reader knows and draws nothing for (their parameters are read and dropped).
const IGNORED: &[[u8; 2]] = &[
*b"AC", *b"AD", *b"AF", *b"AH", *b"AP", *b"AS", *b"CA", *b"CF", *b"CM", *b"CP", *b"CS", *b"CT", *b"CV", *b"DC", *b"DI", *b"DL", *b"DP", *b"DR",
*b"DS", *b"DV", *b"EC", *b"ES", *b"FI", *b"FN", *b"FS", *b"GM", *b"IM", *b"IV", *b"KY", *b"LM", *b"LO", *b"MC", *b"MG", *b"MT", *b"NR", *b"OA",
*b"OC", *b"OD", *b"OE", *b"OF", *b"OG", *b"OH", *b"OI", *b"OL", *b"OP", *b"OS", *b"OT", *b"OW", *b"PB", *b"PP", *b"PS", *b"PT", *b"QL", *b"RF",
*b"RP", *b"SA", *b"SB", *b"SD", *b"SG", *b"SI", *b"SL", *b"SR", *b"SS", *b"ST", *b"SV", *b"TD", *b"TL", *b"TR", *b"UC", *b"VA", *b"VN", *b"VS",
*b"XT", *b"YT",
];
/// Instructions the reader plays.
const PLAYED: &[[u8; 2]] = &[
*b"AA", *b"AR", *b"AT", *b"BL", *b"BP", *b"BR", *b"BZ", *b"CI", *b"CO", *b"CR", *b"DF", *b"DT", *b"EA", *b"EP", *b"ER", *b"EW", *b"FP", *b"FR",
*b"FT", *b"IN", *b"IP", *b"IR", *b"IW", *b"LA", *b"LB", *b"LT", *b"NP", *b"PA", *b"PC", *b"PD", *b"PE", *b"PG", *b"PM", *b"PR", *b"PU", *b"PW",
*b"RA", *b"RO", *b"RR", *b"RT", *b"SC", *b"SM", *b"SP", *b"UL", *b"WD", *b"WG", *b"WU",
];
/// Instructions a plot file starts with.
const FIRST: &[[u8; 2]] = &[
*b"IN", *b"DF", *b"BP", *b"CO", *b"PS", *b"SP", *b"PU", *b"PD", *b"PA", *b"PR", *b"IP", *b"IR", *b"SC", *b"NP", *b"PC", *b"PW", *b"WU", *b"RO",
*b"QL", *b"MC", *b"PP", *b"TR", *b"CR", *b"LT", *b"LA", *b"FT", *b"VS", *b"FS", *b"AC", *b"PG", *b"PE", *b"DT",
];
/// Does `bytes` start like an HP-GL file? After any printer escapes and PJL lines (however long),
/// it must hold nothing but instructions the reader knows, the first one of those a plot file
/// starts with, up to the eighth instruction or 4 KB past the first. Their parameters must be
/// numbers, but for a comment's quoted string and the text, characters and encoded data of LB, BL,
/// WD, DT, SM and PE. An instruction the reader doesn't know counts when its numbers end with a
/// semicolon.
pub fn is_hpgl(bytes: &[u8]) -> bool {
holds_instructions(bytes, true)
}
/// Does `bytes` hold HP-GL instructions as [`is_hpgl`] checks? When `strict` is false, any of them
/// may come first: a file picked by its extension may start with one a plot file seldom starts
/// with (`SI`, `DI`). Either way, at least one of the instructions checked must be one the reader
/// knows.
fn holds_instructions(bytes: &[u8], strict: bool) -> bool {
let mut lex = Lexer::new(bytes);
let mut terminator = lex::ETX;
let mut first = 0;
let mut any_known = false;
for n in 0..8 {
if n > 0 && lex.pos() > first + 4096 {
return any_known;
}
let Some(op) = lex.op(true) else { return n > 0 && any_known && lex.done() };
if n == 0 {
first = lex.pos();
}
if strict && n == 0 && !FIRST.contains(&op) {
return false;
}
let known = PLAYED.contains(&op) || IGNORED.contains(&op);
any_known |= known;
match &op {
b"CO" => {
if !lex.comment() {
return false;
}
}
b"LB" | b"BL" | b"WD" => lex.label(terminator),
b"DT" => {
terminator = lex.char().unwrap_or(lex::ETX);
lex.params();
}
b"SM" => {
// The symbol character is read so it isn't taken for an instruction.
let _ = lex.char();
}
b"PE" => lex.skip_pe(),
_ if known => lex.params(),
// An instruction the reader doesn't know (of an older or a device's own set) counts
// when numbers and a semicolon follow it.
_ => {
if !lex.closed_params() {
return false;
}
}
}
}
any_known
}
/// Read an HP-GL file. It may start with any instruction the reader knows, or one of another set
/// that a semicolon ends; the rest of [`is_hpgl`]'s check applies, and at least one instruction the
/// reader knows must be among those it checks.
pub fn import(bytes: &[u8]) -> Result<Imported, String> {
if !holds_instructions(bytes, false) {
return Err("not an HP-GL file".into());
}
let mut lex = Lexer::new(bytes);
let mut plot = Plot::new();
while !plot.full
&& let Some(op) = lex.op(false)
{
plot.op(op, &mut lex);
}
Ok(build(plot.finish(lex.raster)))
}
fn rgb(c: [u8; 3]) -> Paint {
Paint::solid(Color::rgb8(c[0], c[1], c[2]))
}
/// The plot's shapes as paths on one layer, the artboard round what they paint.
fn build(plot: Plot) -> Imported {
// Plotter units, y up, to points, y down.
let flip = Affine::scale_non_uniform(PT_PER_PLU, -PT_PER_PLU);
let mut nodes: Vec<Node> = plot.shapes.iter().map(|s| node(s, flip)).collect();
// The strokes' caps and miter joins included.
let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite());
let frame = nodes.iter().filter_map(Node::visual_bounds).filter(finite).reduce(|a, b| a.union(b)).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
let mut doc = Document::new(frame.width().max(1.0), frame.height().max(1.0));
doc.layers.clear();
let shift = Affine::translate(-frame.origin().to_vec2());
for n in &mut nodes {
n.id = doc.alloc_id();
if let NodeKind::Path { path, .. } = &mut n.kind {
path.transform(shift);
}
}
let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
if let Some(c) = layer.children_mut() {
*c = nodes.into_iter().map(Arc::new).collect();
}
doc.layers = vec![Arc::new(layer)];
Imported { document: doc, warnings: plot.warnings }
}
/// A shape as a path, its plotter units through `to_points`.
fn node(s: &plot::Shape, to_points: Affine) -> Node {
let mut path = s.path.clone();
path.apply_affine(to_points);
let mut items = vec![];
if let Some((c, _)) = s.fill {
items.push(AppearanceItem::Fill(FillLayer::new(rgb(c))));
}
if let Some(st) = &s.stroke {
let mut layer = StrokeLayer::new(rgb(st.color), (st.width * PT_PER_PLU).max(THINNEST));
(layer.cap, layer.join, layer.miter_limit) = (st.cap, st.join, st.miter);
layer.dash = st.dash.as_ref().map(|d| Dash {
pattern: d.pattern.iter().map(|v| v * PT_PER_PLU).collect(),
offset: d.offset * PT_PER_PLU,
align_corners: d.adaptive,
});
items.push(AppearanceItem::Stroke(layer));
}
let mut n = Node::path(NodeId(0), PathData::from_bezpath(&path), Appearance { items, ..Appearance::default() });
if let (NodeKind::Path { rule, .. }, Some((_, r))) = (&mut n.kind, s.fill) {
*rule = r;
}
n
}
#[cfg(test)]
mod tests;
+428
View File
@@ -0,0 +1,428 @@
//! The bytes of an HP-GL file as instructions: two-letter mnemonics and the parameters that follow
//! them, read one at a time as the plot asks for them. Printer escape sequences (PCL, HP RTL, the
//! HP-GL device-control escapes) and PJL lines around and between the instructions are skipped,
//! with the data their escapes carry.
/// The end-of-text character, the default label terminator.
pub(super) const ETX: u8 = 3;
const ESC: u8 = 0x1b;
/// The most digits of a number that count: longer ones are read as their first digits.
const MAX_DIGITS: usize = 18;
/// What the bytes being read hold.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Mode {
/// HP-GL instructions.
Hpgl,
/// PCL or HP RTL: escape sequences and their data, nothing to draw.
Pcl,
/// After a Universal Exit Language: PJL lines until a language starts.
Pjl,
}
/// One item of a PE (Polyline Encoded) instruction.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum PeItem {
/// Select pen.
Pen(i64),
/// Rectangle mode: the following coordinates come in fours.
Rect,
/// A coordinate pair, after a pen-up flag (`up`) and an absolute flag (`absolute`).
Point { x: f64, y: f64, up: bool, absolute: bool },
}
/// The state of one PE instruction being read.
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct Pe {
seven_bit: bool,
fraction_bits: u32,
up: bool,
absolute: bool,
}
pub(super) struct Lexer<'a> {
b: &'a [u8],
at: usize,
mode: Mode,
/// The file holds HP RTL or PCL raster image data.
pub raster: bool,
}
/// Is `c` a byte that separates numeric parameters (or white space between instructions)?
fn separator(c: u8) -> bool {
matches!(c, b' ' | b',' | b'\t' | b'\r' | b'\n' | 0)
}
impl<'a> Lexer<'a> {
pub fn new(b: &'a [u8]) -> Self {
Self { b, at: 0, mode: Mode::Hpgl, raster: false }
}
fn peek(&self) -> Option<u8> {
self.b.get(self.at).copied()
}
/// Whether everything has been read.
pub fn done(&self) -> bool {
self.at >= self.b.len()
}
/// How many bytes have been read.
pub fn pos(&self) -> usize {
self.at
}
/// The next instruction's mnemonic, in upper case; `strict`: only white space, semicolons and
/// escape sequences may come before it (anything else gives `None`, as the end does).
pub fn op(&mut self, strict: bool) -> Option<[u8; 2]> {
loop {
let c = self.peek()?;
if c == ESC {
self.escape();
continue;
}
match self.mode {
Mode::Pcl => {
self.at += 1;
continue;
}
Mode::Pjl => {
if separator(c) {
self.at += 1;
} else if c == b'@' {
self.pjl_line();
} else {
self.mode = Mode::Hpgl;
}
continue;
}
Mode::Hpgl => {}
}
let next = self.b.get(self.at + 1).copied();
if c.is_ascii_alphabetic()
&& let Some(n) = next.filter(u8::is_ascii_alphabetic)
{
self.at += 2;
return Some([c.to_ascii_uppercase(), n.to_ascii_uppercase()]);
}
if strict && !(separator(c) || c == b';' || c < 0x20) {
return None;
}
self.at += 1;
}
}
/// One `@PJL` line: an `ENTER LANGUAGE` command picks what follows.
fn pjl_line(&mut self) {
let rest = &self.b[self.at..];
let end = rest.iter().position(|&c| c == b'\n').map_or(rest.len(), |n| n + 1);
// A command names its language within its first 256 bytes; longer lines aren't copied.
let line = String::from_utf8_lossy(&rest[..end.min(256)]).to_ascii_uppercase();
if let Some(lang) = line.split_once("LANGUAGE").map(|(_, l)| l.trim_start_matches([' ', '=']).trim()) {
self.mode = if lang.starts_with("HPGL") { Mode::Hpgl } else { Mode::Pcl };
}
self.at += end;
}
/// An escape sequence at the current byte, with the data it carries.
fn escape(&mut self) {
self.at += 1;
let Some(c) = self.peek() else { return };
self.at += 1;
match c {
// Universal Exit Language (`ESC%-12345X`) and the language switches (`ESC%#B` to HP-GL,
// `ESC%#A` to PCL or HP RTL).
b'%' => {
let (_, end) = self.value();
match end {
Some(b'X') => self.mode = Mode::Pjl,
Some(b'B') => self.mode = Mode::Hpgl,
Some(b'A') => self.mode = Mode::Pcl,
_ => {}
}
}
// Reset: PCL follows, until a language switch.
b'E' => self.mode = Mode::Pcl,
// HP-GL device control: `ESC.` and a letter, with parameters up to a colon.
b'.' => {
let Some(kind) = self.peek() else { return };
self.at += 1;
if !b"()YZBEJKLORUX".contains(&kind) {
let rest = &self.b[self.at..self.b.len().min(self.at + 80)];
if let Some(n) = rest.iter().position(|&c| c == b':')
&& rest[..n].iter().all(|c| c.is_ascii_digit() || matches!(c, b';' | b' ' | b','))
{
self.at += n + 1;
}
}
}
// Parameterized PCL: a group character, then values each ending in a letter (upper case
// ends the sequence); `W`, `*b…V` (raster data by plane) and `&p…X` (transparent print
// data) are followed by that many bytes of data. Without a group character (`ESC(8U`,
// `ESC)0N`), one value and the letter ending it.
0x21..=0x2f => {
let Some(group) = self.peek().filter(|g| (0x60..=0x7e).contains(g)) else {
self.value();
return;
};
self.at += 1;
loop {
let (v, end) = self.value();
let Some(end) = end else { return };
let upper = end.to_ascii_uppercase();
if upper == b'W' || (c, group, upper) == (b'*', b'b', b'V') || (c, group, upper) == (b'&', b'p', b'X') {
let n = v.max(0.0).min((self.b.len() - self.at) as f64) as usize;
if (c, group) == (b'*', b'b') && n > 0 {
self.raster = true;
}
self.at += n;
}
if (c, group, end) == (b'*', b'r', b'A') {
self.raster = true;
}
if !end.is_ascii_lowercase() {
return;
}
}
}
// A two-character escape.
_ => {}
}
}
/// A PCL value (`[+-]digits[.digits]`, any part missing) and the character that ends it; `None`
/// when that is not a letter or `@` (which is then left unread).
fn value(&mut self) -> (f64, Option<u8>) {
let v = self.number_here().unwrap_or(0.0);
match self.peek() {
Some(c) if c.is_ascii_alphabetic() || c == b'@' => {
self.at += 1;
(v, Some(c))
}
_ => (v, None),
}
}
/// A number starting at the current byte (`[+-]digits[.digits]`), `None` when it has no digit
/// (its sign and point are read all the same).
fn number_here(&mut self) -> Option<f64> {
let mut neg = false;
if let Some(c @ (b'+' | b'-')) = self.peek() {
neg = c == b'-';
self.at += 1;
}
let (mut v, mut digits, mut scale) = (0.0f64, 0usize, 1.0f64);
let mut point = false;
while let Some(c) = self.peek() {
if c.is_ascii_digit() {
if digits < MAX_DIGITS {
if point {
scale /= 10.0;
v += f64::from(c - b'0') * scale;
} else {
v = v * 10.0 + f64::from(c - b'0');
}
} else if !point {
// Past the digits that count, an integer part keeps its size.
v *= 10.0;
}
digits += 1;
} else if c == b'.' && !point {
point = true;
} else {
break;
}
self.at += 1;
}
let v = v.min(f64::MAX);
(digits > 0).then_some(if neg { -v } else { v })
}
/// The next numeric parameter of the instruction being read, or `None` at its end (a semicolon
/// ending it is read).
pub fn number(&mut self) -> Option<f64> {
loop {
let c = self.peek()?;
if separator(c) {
self.at += 1;
} else if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.') {
if let Some(v) = self.number_here() {
return Some(v);
}
} else {
if c == b';' {
self.at += 1;
}
return None;
}
}
}
/// Up to `out.len()` numeric parameters into `out` (the rest are read and dropped) → how many.
pub fn numbers(&mut self, out: &mut [f64]) -> usize {
let mut n = 0;
while let Some(v) = self.number() {
if let Some(slot) = out.get_mut(n) {
*slot = v;
}
n += 1;
}
n.min(out.len())
}
/// Read and drop the parameters of the instruction being read: numbers and quoted strings.
pub fn params(&mut self) {
loop {
while self.number().is_some() {}
if self.peek() != Some(b'"') {
return;
}
self.quoted();
}
}
/// Read and drop numeric parameters → whether a semicolon ended them (an instruction the reader
/// doesn't know, written out in full).
pub fn closed_params(&mut self) -> bool {
while self.number().is_some() {}
self.at > 0 && self.b.get(self.at - 1) == Some(&b';')
}
/// A comment (CO): its quoted string or nothing, then the terminator or the end → whether it
/// is one.
pub fn comment(&mut self) -> bool {
while self.peek().is_some_and(separator) {
self.at += 1;
}
match self.peek() {
Some(b'"') => self.quoted(),
Some(b';') => {
self.at += 1;
true
}
None => true,
Some(_) => false,
}
}
/// The text of a label, up to and including `terminator` (or the end).
pub fn label(&mut self, terminator: u8) {
let rest = &self.b[self.at..];
self.at += rest.iter().position(|&c| c == terminator).map_or(rest.len(), |n| n + 1);
}
/// A quoted string (CO): the parameters up to its closing quote, then the terminator → whether
/// there was one.
pub fn quoted(&mut self) -> bool {
while self.peek().is_some_and(separator) {
self.at += 1;
}
let found = self.peek() == Some(b'"');
if found {
self.at += 1;
let rest = &self.b[self.at..];
self.at += rest.iter().position(|&c| c == b'"').map_or(rest.len(), |n| n + 1);
}
if self.peek() == Some(b';') {
self.at += 1;
}
found
}
/// The character right after the mnemonic (DT, SM), `None` when it is a semicolon or the end
/// (the semicolon is read).
pub fn char(&mut self) -> Option<u8> {
let c = self.peek()?;
self.at += 1;
(c != b';').then_some(c)
}
/// The next item of a PE instruction, `None` at its end (its semicolon is read).
pub fn pe(&mut self, st: &mut Pe) -> Option<PeItem> {
loop {
let raw = self.peek()?;
let c = raw & 0x7f;
// Spaces, deletes, control characters and bytes 128 to 160 and 255 are ignored.
if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 {
self.at += 1;
continue;
}
match c {
b';' => {
self.at += 1;
return None;
}
// Flags keep their meaning with the eighth bit set; digits are 63 and up (191 and up).
b':' | b'<' | b'>' | b'=' | b'7' | b'9' if raw < 63 || (0xb7..191).contains(&raw) => {
self.at += 1;
match c {
b':' => return self.pe_value(st).map(PeItem::Pen).or_else(|| self.end_pe()),
b'<' => st.up = true,
b'>' => match self.pe_value(st) {
Some(n) => st.fraction_bits = n.clamp(0, 52) as u32,
None => return self.end_pe(),
},
b'=' => st.absolute = true,
b'7' => st.seven_bit = true,
_ => return Some(PeItem::Rect),
}
}
_ => {
let scale = 0.5f64.powi(st.fraction_bits as i32);
let (Some(x), Some(y)) = (self.pe_value(st), self.pe_value(st)) else { return self.end_pe() };
let item = PeItem::Point { x: x as f64 * scale, y: y as f64 * scale, up: st.up, absolute: st.absolute };
(st.up, st.absolute) = (false, false);
return Some(item);
}
}
}
}
/// The rest of a PE instruction that can't be decoded: see [`Lexer::skip_pe`].
fn end_pe(&mut self) -> Option<PeItem> {
self.skip_pe();
None
}
/// The data of a PE instruction, up to and including its semicolon, unread: its data is never
/// read as instructions.
pub fn skip_pe(&mut self) {
let rest = &self.b[self.at..];
self.at += rest.iter().position(|&c| c == b';').map_or(rest.len(), |n| n + 1);
}
/// One encoded PE number, `None` when the instruction ends inside it (or a byte that is no
/// digit comes first, which is read).
fn pe_value(&mut self, st: &Pe) -> Option<i64> {
let (base, low, high): (u64, std::ops::RangeInclusive<u8>, std::ops::RangeInclusive<u8>) =
if st.seven_bit { (32, 63..=94, 95..=126) } else { (64, 63..=126, 191..=254) };
let (mut v, mut place) = (0u64, 1u64);
loop {
let raw = self.peek()?;
let c = if st.seven_bit { raw & 0x7f } else { raw };
if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 {
self.at += 1;
continue;
}
if c == b';' {
return None;
}
self.at += 1;
let (digit, last) = if low.contains(&c) {
(u64::from(c - 63), false)
} else if high.contains(&c) {
(u64::from(c - high.start()), true)
} else {
return None;
};
v = v.saturating_add(digit.saturating_mul(place));
place = place.saturating_mul(base);
if last {
// The low bit is the sign.
let n = i64::try_from(v >> 1).unwrap_or(i64::MAX);
return Some(if v & 1 == 1 { -n } else { n });
}
}
}
}
File diff suppressed because it is too large Load Diff
+630
View File
@@ -0,0 +1,630 @@
//! HP-GL import: units and the y axis, pens, runs, curves, filled and edged areas, polygon mode,
//! line types, scaling, rotation, encoded polylines, escapes around the instructions, what is
//! skipped with a warning, which files are taken to be HP-GL, and hostile files.
use kurbo::{BezPath, PathEl, Point, Shape as _};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::FillRule;
use super::plot::{FAR_AWAY, HATCHES, LABELS, PAGES, RASTER, SYMBOLS, TOO_MANY_POINTS, WINDOW};
use super::*;
/// Points per millimeter.
const MM: f64 = 72.0 / 25.4;
fn plot(s: &str) -> Imported {
plot_bytes(s.as_bytes())
}
fn plot_bytes(b: &[u8]) -> Imported {
match import(b) {
Ok(i) => i,
Err(e) => panic!("{e}: {:?}", String::from_utf8_lossy(b)),
}
}
/// Every object, in paint order.
fn leaves(doc: &Document) -> Vec<Node> {
let mut out = vec![];
doc.walk(|n| {
if n.children().is_none() {
out.push(n.clone());
}
});
out
}
fn bez(n: &Node) -> BezPath {
match &n.kind {
NodeKind::Path { path, .. } => path.to_bezpath(),
k => panic!("a path, got {k:?}"),
}
}
fn stroke(n: &Node) -> StrokeLayer {
n.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }).expect("a stroke")
}
fn fill(n: &Node) -> Option<Paint> {
n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None })
}
fn rule(n: &Node) -> FillRule {
match &n.kind {
NodeKind::Path { rule, .. } => *rule,
k => panic!("a path, got {k:?}"),
}
}
fn rgb(r: u8, g: u8, b: u8) -> Paint {
Paint::solid(Color::rgb8(r, g, b))
}
/// The end points of `bp`'s elements.
fn ends(bp: &BezPath) -> Vec<Point> {
bp.elements().iter().filter_map(|e| e.end_point()).collect()
}
fn near(a: Point, b: Point) -> bool {
(a - b).hypot() < 1e-6
}
fn assert_points(got: &[Point], want: &[(f64, f64)]) {
assert_eq!(got.len(), want.len(), "{got:?}");
for (g, w) in got.iter().zip(want) {
assert!(near(*g, Point::new(w.0, w.1)), "{got:?} != {want:?}");
}
}
/// A PE number as the reference guide's encoding writes it (base 64, or base 32 for 7-bit data).
fn pe_number(v: i64, seven_bit: bool) -> Vec<u8> {
let mut n = if v >= 0 { 2 * v as u64 } else { 2 * v.unsigned_abs() + 1 };
let (base, high) = if seven_bit { (32, 95) } else { (64, 191) };
let mut out = vec![];
while n >= base {
out.push(63 + (n % base) as u8);
n /= base;
}
out.push(high + n as u8);
out
}
#[test]
fn plotter_units_become_points_with_y_down() {
// An inch right and an inch up, with the thinnest pen (0.25 pt): the artboard reaches 0.125 pt
// past the line at the corner's miter, and stops at the line's butt ends.
let i = plot("IN;SP1;PW0;PU0,0;PD1016,0,1016,1016;PU;");
let doc = &i.document;
let r = doc.artboards[0].rect;
assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}");
let shapes = leaves(doc);
assert_eq!(shapes.len(), 1);
assert_points(&ends(&bez(&shapes[0])), &[(0.0, 72.0), (72.0, 72.0), (72.0, 0.0)]);
let s = stroke(&shapes[0]);
assert_eq!((s.width, s.paint.clone(), s.cap, s.join, s.miter_limit), (0.25, rgb(0, 0, 0), LineCap::Butt, LineJoin::Miter, 5.0));
assert!(fill(&shapes[0]).is_none() && i.warnings.is_empty(), "{:?}", i.warnings);
// The artboard holds what the strokes paint, the tip of a sharp miter join included.
let i = plot("IN;SP1;PW2;PD2032,0,0,1000;");
let r = i.document.artboards[0].rect;
let painted = leaves(&i.document)[0].visual_bounds().unwrap();
let path = bez(&leaves(&i.document)[0]).bounding_box();
assert!(painted.x1 - path.x1 > 2.0 * MM, "the miter reaches past half the width: {painted:?} {path:?}");
let inside = r.x0 <= painted.x0 + 1e-9 && r.y0 <= painted.y0 + 1e-9 && r.x1 >= painted.x1 - 1e-9 && r.y1 >= painted.y1 - 1e-9;
assert!(inside, "{r:?} {painted:?}");
}
#[test]
fn pens_pick_colors_and_widths() {
// Before any SP the pen is pen 1; SP0 draws nothing; pens past the palette wrap round it.
let i = plot("IN;PD0,0,100,0;PU;SP2;PD0,100,100,100;PU;SP0;PD0,200,100,200;PU;SP9;PD0,300,100,300;PU;SP3;PD0,400,100,400;PU;");
let colors: Vec<Paint> = leaves(&i.document).iter().map(|n| stroke(n).paint).collect();
assert_eq!(colors, [rgb(0, 0, 0), rgb(255, 0, 0), rgb(255, 0, 0), rgb(0, 255, 0)]);
// PC gives a pen a color in the color range; PC with the pen alone gives back its default.
let i = plot("IN;CR0,1,0,1,0,1;PC3,0,0,1;SP3;PD0,0,10,0;PU;PC3;PD0,10,10,10;PU;");
let colors: Vec<Paint> = leaves(&i.document).iter().map(|n| stroke(n).paint).collect();
assert_eq!(colors, [rgb(0, 0, 255), rgb(0, 255, 0)]);
// PW in millimeters for every pen, or for one; IN gives back 0.35 mm.
let i = plot("IN;SP1;PW1;PD0,0,10,0;PU;PW2,2;SP2;PD0,10,10,10;PU;SP1;PD0,20,10,20;PU;IN;SP1;PD0,30,10,30;");
let widths: Vec<f64> = leaves(&i.document).iter().map(|n| stroke(n).width).collect();
let want = [MM, 2.0 * MM, MM, 0.35 * MM];
assert!(widths.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{widths:?}");
}
#[test]
fn each_run_with_the_pen_down_is_one_path() {
// Relative moves; a run that comes back to its start is closed; a pen-up move elsewhere starts
// another path, and one back to where the line ended continues it.
let i = plot("IN;SP1;PW0;PA0,0;PR;PD1016,0,0,1016,-1016,0,0,-1016;PU2032,0;PD0,1016;PU0,1016;PU0,-1016;PD508,0;PU0,500;PD0,500;");
let shapes = leaves(&i.document);
assert_eq!(shapes.len(), 3, "{shapes:#?}");
let NodeKind::Path { path: square, .. } = &shapes[0].kind else { panic!("a path") };
assert_eq!((square.subpaths.len(), square.subpaths[0].anchors.len(), square.subpaths[0].closed), (1, 4, true));
assert_eq!(ends(&bez(&shapes[1])).len(), 3, "the run went on from where it ended");
assert_eq!(ends(&bez(&shapes[2])).len(), 2);
// A pen-down move of no length draws a dot.
let i = plot("IN;SP1;PA500,500;PD;PA500,500;PU;");
let shapes = leaves(&i.document);
assert_eq!(shapes.len(), 1);
let b = bez(&shapes[0]).bounding_box();
assert!((b.width() - 72.0 / 1016.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn circles_arcs_and_curves_are_drawn_as_curves() {
// A circle of an inch round the pen, which stays at its center (the next line starts there).
let i = plot("IN;SP1;PW0;PA2032,2032;CI1016;PD3048,2032;");
let shapes = leaves(&i.document);
assert_eq!(shapes.len(), 2);
let c = bez(&shapes[0]);
assert!(c.elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))) && matches!(c.elements().last(), Some(PathEl::ClosePath)));
// The curves stay within a thousandth of a point of the circle.
let b = c.bounding_box();
assert!((b.width() - 144.0).abs() < 1e-3 && (b.height() - 144.0).abs() < 1e-3, "{b:?}");
let line = ends(&bez(&shapes[1]));
assert!((line[0] - b.center()).hypot() < 1e-3, "{line:?} {b:?}");
// AA: a quarter turn counterclockwise round (0, 0) from (1016, 0) ends at (0, 1016), and the
// pen goes on from there. AR takes its center relative to the pen.
for s in ["IN;SP1;PW0;PA1016,0;PD;AA0,0,90;PA0,2032;", "IN;SP1;PW0;PA1016,0;PD;AR-1016,0,90;PA0,2032;"] {
let shapes = leaves(&plot(s).document);
assert_eq!(shapes.len(), 1, "{s}");
let p = bez(&shapes[0]);
let pts = ends(&p);
// y down: (1016, 0) is at the bottom right, the arc's end on the left a quarter turn up.
let (start, end) = (pts[0], pts[pts.len() - 2]);
assert!(near(start, Point::new(72.125, 144.0)) && near(end, Point::new(0.125, 72.0)), "{s}: {pts:?}");
assert!(near(*pts.last().unwrap(), Point::new(0.125, 0.0)), "{s}: {pts:?}");
}
// AT: the arc from the pen through a point to an end; points in a line give a line.
let shapes = leaves(&plot("IN;SP1;PW0;PA1016,0;PD;AT0,1016,-1016,0;").document);
let pts = ends(&bez(&shapes[0]));
let b = bez(&shapes[0]).bounding_box();
assert!((b.width() - 144.0).abs() < 1e-6 && (b.height() - 72.0).abs() < 1e-6, "a half circle: {b:?}");
assert!(near(pts[0], Point::new(144.125, 72.125)) && near(*pts.last().unwrap(), Point::new(0.125, 72.125)), "{pts:?}");
let shapes = leaves(&plot("IN;SP1;PW0;PA0,0;PD;AT508,0,1016,0;").document);
assert_eq!(ends(&bez(&shapes[0])).len(), 2);
// BZ and BR: cubic Béziers, BR's points relative to each curve's start.
for s in [
"IN;SP1;PW0;PA0,0;PD;BZ0,1016,1016,1016,1016,0,1016,-1016,2032,-1016,2032,0;",
"IN;SP1;PW0;PA0,0;PD;BR0,1016,1016,1016,1016,0,0,-1016,1016,-1016,1016,0;",
] {
let shapes = leaves(&plot(s).document);
let els = bez(&shapes[0]).elements().to_vec();
assert_eq!(els.iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2, "{s}");
// The curves reach 762 plotter units (three quarters of their control points) up and down.
assert!(near(els.last().and_then(PathEl::end_point).unwrap(), Point::new(144.125, 54.125)), "{s}: {els:?}");
}
}
#[test]
fn rectangles_wedges_and_polygons_fill_and_edge() {
// RA fills, EA edges; neither moves the pen.
let i = plot("IN;SP2;PW0;PA0,0;RA1016,1016;EA1016,1016;PD508,0;");
let shapes = leaves(&i.document);
assert_eq!(shapes.len(), 3);
assert_eq!((fill(&shapes[0]), shapes[0].appearance.items.len()), (Some(rgb(255, 0, 0)), 1));
assert!(fill(&shapes[1]).is_none() && stroke(&shapes[1]).paint == rgb(255, 0, 0));
assert!(near(ends(&bez(&shapes[2]))[0], Point::new(0.125, 72.125)), "the line starts where the pen was");
// WG fills a wedge, EW edges one; a whole turn is a circle.
let i = plot("IN;SP1;PA0,0;WG1016,0,90;EW1016,0,360;");
let shapes = leaves(&i.document);
let wedge = bez(&shapes[0]);
assert!(fill(&shapes[0]).is_some() && ends(&wedge).len() >= 3);
let ring = bez(&shapes[1]).bounding_box();
assert!((ring.width() - 144.0).abs() < 1e-6);
// Polygon mode: two squares, one inside the other; FP fills even-odd by default, FP1
// non-zero; EP edges only what was drawn with the pen down.
let poly = "IN;SP1;PW0;PA0,0;PM0;PD3048,0,3048,3048,0,3048,0,0;PM1;PU1016,1016;PD2032,1016;PU2032,2032;PD1016,2032,1016,1016;PM2;";
let i = plot(&format!("{poly}FP;FP1;EP;"));
let shapes = leaves(&i.document);
assert_eq!(shapes.len(), 3);
assert_eq!((rule(&shapes[0]), rule(&shapes[1])), (FillRule::EvenOdd, FillRule::NonZero));
let filled = bez(&shapes[0]);
assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 2);
let edged = bez(&shapes[2]);
assert_eq!(edged.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(..))).count(), 3, "the pen-up side isn't edged: {edged:?}");
// Nothing is drawn in polygon mode itself.
assert!(leaves(&plot(poly).document).is_empty());
// A circle in polygon mode is a subpolygon of its own; CI and RA after PM2 work as before.
let i = plot("IN;SP1;PA2032,2032;PM0;CI1016;PM2;FP;");
assert_eq!(leaves(&i.document).len(), 1);
// Fill types: shading mixes the pen with white; hatching fills solid with a warning.
let i = plot("IN;SP5;PA0,0;FT10,50;RA100,100;FT3,10,45;RA200,200;");
let shapes = leaves(&i.document);
assert_eq!((fill(&shapes[0]), fill(&shapes[1])), (Some(rgb(128, 128, 255)), Some(rgb(0, 0, 255))));
assert_eq!(i.warnings, [HATCHES]);
}
#[test]
fn line_types_become_dash_patterns() {
// A 10 mm pattern of line type 2 is 5 mm on, 5 mm off.
let i = plot("IN;SP1;LT2,10,1;PD0,0,4000,0;");
let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed");
assert!(d.pattern.iter().all(|v| (v - 5.0 * MM).abs() < 1e-9) && d.pattern.len() == 2 && !d.align_corners, "{d:?}");
// The default length is 4% of the P1-P2 diagonal; IP sets P1 and P2.
let i = plot("IN;IP0,0,3000,4000;SP1;LT3;PD0,0,4000,0;");
let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed");
let total = 200.0 * 72.0 / 1016.0;
assert!((d.pattern[0] - 0.7 * total).abs() < 1e-9 && (d.pattern[1] - 0.3 * total).abs() < 1e-9, "{d:?}");
// Adaptive types stretch to each line; a zero length is a dot one plotter unit long.
let i = plot("IN;SP1;LT-4,10,1;PD0,0,4000,0;");
let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed");
assert!(d.align_corners && (d.pattern[2] - 72.0 / 1016.0).abs() < 1e-12, "{d:?}");
// UL redefines a type; LT; draws solid and LT99 gives the type back.
let i = plot("IN;SP1;UL2,1,3;LT2,10,1;PD0,0,4000,0;PU;LT;PD0,100,4000,100;PU;LT99;PD0,200,4000,200;");
let shapes = leaves(&i.document);
let d = stroke(&shapes[0]).dash.expect("dashed");
assert!((d.pattern[0] - 2.5 * MM).abs() < 1e-9 && (d.pattern[1] - 7.5 * MM).abs() < 1e-9, "{d:?}");
assert!(stroke(&shapes[1]).dash.is_none() && stroke(&shapes[2]).dash.is_some());
// Line type 0 draws a dot at each point.
let i = plot("IN;SP1;LT0;PD0,0,1000,0,2000,0;");
assert_eq!(leaves(&i.document).len(), 3);
// LA sets the caps, joins and miter limit.
let s = stroke(&leaves(&plot("IN;SP1;LA1,4,2,4,3,9;PD0,0,100,0;").document)[0]);
assert_eq!((s.cap, s.join, s.miter_limit), (LineCap::Round, LineJoin::Round, 9.0));
}
#[test]
fn scaling_maps_user_units_onto_p1_and_p2() {
// Anisotropic: 100 user units across an inch.
let size = |s: &str| {
let shapes = leaves(&plot(s).document);
bez(&shapes[0]).bounding_box().size()
};
let s = size("IN;IP0,0,1016,2032;SC0,100,0,100;SP1;PD0,0,100,100;");
assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 144.0).abs() < 1e-9, "{s:?}");
// Isotropic: the smaller of the two scales on both axes.
let s = size("IN;IP0,0,1016,2032;SC0,100,0,100,1;SP1;PU0,0;PD100,100;");
assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}");
// Point factor: 10 plotter units per user unit.
let s = size("IN;SC0,10,0,10,2;SP1;PD0,0,101.6,50.8;");
assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 36.0).abs() < 1e-9, "{s:?}");
// Without IP, P2 lies 11 × 8.5 in from the origin.
let s = size("IN;SC0,11,0,8.5;SP1;PD0,0,1,1;");
assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}");
// SC; goes back to plotter units; scaling that makes no area is ignored.
let s = size("IN;SC0,1,0,1;SC;SC0,0,0,1;SP1;PD0,0,1016,1016;");
assert!((s.width - 72.0).abs() < 1e-9, "{s:?}");
// RO turns the coordinate system: x runs up.
let shapes = leaves(&plot("IN;RO90;SP1;PW0;PA0,0;PD1016,0;").document);
let b = bez(&shapes[0]).bounding_box();
assert!(b.width() < 1e-9 && (b.height() - 72.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn polyline_encoded_data_is_read() {
let mut f = b"IN;SP1;PW0;PE".to_vec();
// Pen 2, a pen-up move to the absolute (1016, 0), then a relative line an inch up.
f.push(b':');
f.extend(pe_number(2, false));
f.extend(b"<=");
f.extend(pe_number(1016, false));
f.extend(pe_number(0, false));
f.extend(pe_number(0, false));
f.extend(pe_number(1016, false));
f.push(b';');
let i = plot_bytes(&f);
let shapes = leaves(&i.document);
assert_eq!(shapes.len(), 1);
assert_eq!(stroke(&shapes[0]).paint, rgb(255, 0, 0));
assert_points(&ends(&bez(&shapes[0])), &[(0.125, 72.0), (0.125, 0.0)]);
// 7-bit data with two fractional bits: 4064 / 4 = 1016.
let mut f = b"IN;SP1;PW0;PE7>".to_vec();
f.extend(pe_number(2, true));
f.extend(pe_number(-4064, true));
f.extend(pe_number(0, true));
f.push(b';');
let b = bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box();
assert!((b.width() - 72.0).abs() < 1e-9, "{b:?}");
// Data that can't be decoded ends the instruction at its semicolon: none of it is read as
// instructions.
let shapes = leaves(&plot("IN;SP1;PE#PD5000,5000;PD1016,0;").document);
assert_eq!(shapes.len(), 1);
assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9);
// Rectangle mode: a pen-up move, then a filled rectangle relative to it.
let mut f = b"IN;SP3;PE9".to_vec();
for v in [100, 100, 1016, 508] {
f.extend(pe_number(v, false));
}
f.push(b';');
let shapes = leaves(&plot_bytes(&f).document);
assert_eq!(fill(&shapes[0]), Some(rgb(0, 255, 0)));
let b = bez(&shapes[0]).bounding_box();
assert!((b.width() - 72.0).abs() < 1e-9 && (b.height() - 36.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn printer_escapes_and_job_lines_are_skipped() {
let mut f = b"\x1b%-12345X@PJL JOB\r\n@PJL ENTER LANGUAGE=PCL\r\n\x1bE\x1b%0BIN;SP1;PW0;PD0,0,1016,0;PU;\x1b%0A".to_vec();
// An HP RTL raster image whose data spells out instructions.
f.extend(b"\x1b*r1A\x1b*b11WPD9999,9999\x1b*rC");
f.extend(b"\x1b%1BPD1016,1016;\x1b%-12345X@PJL EOJ\r\n");
assert!(is_hpgl(&f));
let i = plot_bytes(&f);
let shapes = leaves(&i.document);
// The pen went up and came down where the line ended, so the line goes on.
assert_eq!(shapes.len(), 1, "{shapes:#?}");
assert_eq!(ends(&bez(&shapes[0])).len(), 3);
assert_eq!(i.warnings, [RASTER], "{:?}", i.warnings);
let r = i.document.artboards[0].rect;
assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}");
// The device-control escapes of older plotters.
let f = b"\x1b.(\x1b.I81;;17:\x1b.N;19:IN;SP1;PD0,0,1016,0;\x1b.)";
assert!(is_hpgl(f));
assert_eq!(leaves(&plot_bytes(f).document).len(), 1);
}
#[test]
fn labels_comments_and_unknown_instructions_are_skipped() {
// A label's text, up to its terminator (ETX, or the one DT sets), isn't read as instructions.
let i = plot("IN;SP1;LBPD9999,9999\x03DT*,1;LBPU0,0;PD5000,5000*CO\"PD8000,8000\";PD1016,0;PU;ZZ1,2;QQ;");
let shapes = leaves(&i.document);
assert_eq!(shapes.len(), 1);
assert_eq!(i.warnings, [LABELS.to_string(), "2 instructions of kinds VectorCraft doesn't read were skipped".to_string()]);
// Symbol mode's symbols aren't drawn, with a warning; its lines are.
let i = plot("IN;SP1;SM*;PD100,0;SM;");
assert_eq!((i.warnings.clone(), leaves(&i.document).len()), (vec![SYMBOLS.to_string()], 1));
// Later pages and the input window are warned about.
let i = plot("IN;IW0,0,10,10;SP1;PD100,0;PU;PG;PD0,100;PG;");
assert_eq!(i.warnings, [WINDOW, PAGES]);
// A page advance raises the pen at the origin: nothing joins the pages.
let i = plot("IN;SP1;PD100,0;PG;PA200,0;PD300,0;");
let shapes = leaves(&i.document);
assert_eq!(shapes.len(), 2);
assert_eq!(ends(&bez(&shapes[1])).len(), 2, "the second page's line starts at (200, 0)");
// A page advance at the end changes nothing.
assert!(plot("IN;SP1;PD100,0;PG;").warnings.is_empty());
// A file that draws nothing opens as a letter-size page.
let i = plot("IN;SP1;PU100,100;");
assert!(leaves(&i.document).is_empty() && i.document.artboards[0].rect.size() == kurbo::Size::new(612.0, 792.0));
}
#[test]
fn only_plot_files_are_taken_for_hpgl() {
for f in [
&b"IN;SP1;PU0,0;PD100,100;"[..],
b"in;sp1;pu0,0;pd100,100;",
b";IN;\r\nSP1;\r\nPA100,100;",
b"\x03IN;",
b"SP1PD0,0,10,10PU",
b"IN",
b"CO\"made by hand\";IN;",
b"IN;SP1;DT*;LBhello world*PA0,0;SMx;PA1,1;SM;",
b"IN;SP1;PE<=\xbf\xbf;PU;",
b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\nIN;SP1;",
] {
assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
}
for f in [
&b""[..],
b"IN THE BEGINNING",
b"set terminal png\nplot sin(x)\n",
b"Paris, 10 to 12",
b"SPDX-License-Identifier: MIT",
b"CONTENTS",
b"CO \"a comment\";hello world",
b"DT*;LBtext*nope",
b"\x89PNG\r\n\x1a\n",
b"IN;SP1;PD0,0;hello world",
b"\x1b%-12345X@PJL ENTER LANGUAGE=PCL\n\x1bE\x1b*r1A",
] {
assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f));
}
// A comment needs no string; instructions of an older or a device's own set count when a
// semicolon ends them.
for f in [&b"CO;IN;SP1;PD0,0,10,10;"[..], b"IN;CO;SP1;PD0,0,10,10;", b"IN;SP1;VA;VS10;PU0,0;PD1016,0;", b"IN;GC5;SP1;PD0,0,10,0;"] {
assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
}
assert!(!is_hpgl(b"IN;GC5") && !is_hpgl(b"IN;Hello, world;"));
// A printer prolog of any length comes before the first instruction.
let long = [&b"\x1b%-12345X"[..], &b"@PJL COMMENT a job line of some length\r\n".repeat(400), b"\x1bE\x1b%0BIN;SP1;PD100,0;"].concat();
assert!(long.len() > 16_000 && is_hpgl(&long));
assert_eq!(leaves(&plot_bytes(&long).document).len(), 1);
// Instructions of any kind may follow the first eight, garbage included.
assert!(is_hpgl(b"IN;SP1;PU;PD;PU;PD;PU;PD;hello world"));
}
/// A file read as HP-GL (picked by its extension) may start with any instruction the reader knows,
/// or one of another set that a semicolon ends; its content alone doesn't make it HP-GL.
#[test]
fn a_plot_file_may_start_with_any_instruction() {
for f in [&b"SI0.2,0.3;SP1;PA0,0;PD1016,0;"[..], b"DI1,0;SP1;PA0,0;PD1016,0;", b"GC5;SP1;PA0,0;PD1016,0;"] {
assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f));
}
for f in [&b"GC5"[..], b"SIhello world", b"set terminal png\n"] {
assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f));
}
// Instructions of another set alone, however many or long, are not taken for HP-GL.
let long = [&b"GC"[..], "1".repeat(5000).as_bytes(), b";ZZ1;"].concat();
for f in [&b"GC5;"[..], b"GC5;ZZ1;", b"GC5;ZZ1;YY2;XX3;WW4;VV5;UU6;TT7;", &long] {
assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f));
}
}
#[test]
fn escapes_that_carry_values_or_data_are_skipped_whole() {
// A reset leaves HP-GL until a language switch; symbol-set escapes carry a value and a letter.
for f in [
&b"\x1bE\x1b(8U\x1b%1BIN;SP1;PD0,0,1016,0;"[..],
b"\x1bE\x1b(10U\x1b)0N\x1b%1BIN;SP1;PD0,0,1016,0;",
b"\x1bEHello\x1b%1BIN;SP1;PD0,0,1016,0;",
] {
assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f));
}
// A letter left over from an escape doesn't join the next mnemonic.
let i = plot_bytes(b"IN;SP1;\x1b(8UPD0,0,1016,0;");
assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{:?}", i.warnings);
// Raster data by plane and transparent print data are skipped by their byte counts, however
// they read.
let line = |data_escape: &[u8]| {
let data = b"\x1b%1BPD0,5000;";
let mut f = b"IN;SP1;PW0;PD0,0,0,1016;\x1b%0A".to_vec();
f.extend(data_escape);
f.extend(data.len().to_string().as_bytes());
f.extend(if data_escape.ends_with(b"b") { &b"V"[..] } else { b"X" });
f.extend(data);
f.extend(b"\x1b*rC\x1b%1BPD0,0;");
bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box()
};
for escape in [&b"\x1b*r1A\x1b*b"[..], b"\x1b&p"] {
let b = line(escape);
assert!((b.height() - 72.0).abs() < 1e-9, "{:?}: {b:?}", String::from_utf8_lossy(escape));
}
}
#[test]
fn polygon_mode_follows_its_own_rules() {
// The point after a circle starts a new subpolygon: the triangle after it doesn't reach back
// to the circle's center, filled or edged.
let i = plot("IN;SP1;PW0;PA1000,1000;PM0;CI200;PD2000,1000,2000,2000,1000,2000;PM2;FP;EP;");
let shapes = leaves(&i.document);
assert_eq!(shapes.len(), 2);
for s in &shapes {
let p = bez(s);
let first = p.elements().iter().position(|e| matches!(e, PathEl::ClosePath)).unwrap_or(p.elements().len());
let circle = BezPath::from_vec(p.elements()[..first].to_vec()).bounding_box();
let center = circle.center();
assert!(ends(&p).iter().all(|e| !near(*e, center)), "{p:?}");
}
// PM1 closes with the pen up: EP draws two sides of the triangle, not the third; PM2 closes
// with the pen as it is.
let sides = |s: &str| {
let p = bez(&leaves(&plot(s).document)[0]);
p.elements().iter().filter(|e| matches!(e, PathEl::LineTo(..))).count()
};
assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PM2;EP;"), 2);
assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM2;EP;"), 3);
// The closing point moves the pen: a relative move after PM1 starts from the start.
let i = plot("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PU;PR0,2000;PD1000,0,0,1000;PM2;FP;");
let b = bez(&leaves(&i.document)[0]).bounding_box();
assert!((b.height() - 3000.0 * PT_PER_PLU).abs() < 1e-9, "{b:?}");
// A subpolygon of fewer than three points (the closing point counted) is neither filled nor
// edged.
assert!(leaves(&plot("IN;SP1;PA0,0;PM0;PD0,0;PM2;FP;EP;").document).is_empty());
let i = plot("IN;SP1;PA0,0;PM0;PR0,0;PM1;PA;PD1000,0,0,1000,0,0;PM2;FP;");
let filled = bez(&leaves(&i.document)[0]);
assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 1, "{filled:?}");
// Polygon mode ignores the instructions it doesn't allow: pens, scaling and labels.
let i = plot("IN;SP1;PA0,0;PM0;SP2;SC0,1,0,1;LBPD9999,9999\x03PD1016,0,1016,1016;PM2;FP;");
let shapes = leaves(&i.document);
assert_eq!((shapes.len(), fill(&shapes[0])), (1, Some(rgb(0, 0, 0))));
assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9 && i.warnings.is_empty(), "{:?}", i.warnings);
}
#[test]
fn rotation_keeps_the_pen_and_turns_the_defaults() {
// After RO90, IP's default P2 is the rotated hard-clip corner: the user square spans 8.5 in
// across its rotated x axis and 11 in up it, which lie up and across the page.
let i = plot("IN;RO90;IP;SC0,100,0,100;SP1;PW0;PA0,0;PD100,0,100,100,0,100,0,0;");
let b = bez(&leaves(&i.document)[0]).bounding_box();
assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}");
// IR's percentages are of the rotated limits too.
let i = plot("IN;RO90;IR0,0,100,100;SC0,1,0,1;SP1;PW0;PA0,0;PD1,1;");
let b = bez(&leaves(&i.document)[0]).bounding_box();
assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}");
// The pen stays where it was on the page: a line after RO starts where the one before ended.
let i = plot("IN;SP1;PW0;PD0,0,1016,0;PU;RO90;PD;PR1016,0;");
let all = leaves(&i.document).iter().map(|n| bez(n).bounding_box()).reduce(|a, b| a.union(b)).unwrap();
assert!((all.width() - 72.0).abs() < 1e-9 && (all.height() - 72.0).abs() < 1e-9, "{all:?}");
}
#[test]
fn line_type_zero_and_odd_patterns_follow_the_reference() {
// Line type 0: one dot for an arc (at its end) and one for a circle (at its center).
for s in ["IN;SP1;LT0;PA0,0;PD;AA1016,0,180;", "IN;SP1;LT0;PA0,0;CI500;"] {
let shapes = leaves(&plot(s).document);
assert_eq!(shapes.len(), 1, "{s}");
assert!(bez(&shapes[0]).bounding_box().width() < 0.1, "{s}");
}
// An odd number of gaps repeats with the last pen-down stretch running into the first.
let i = plot("IN;SP1;UL3,50,25,25;LT3,10,1;PD0,0,4000,0;");
let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed");
assert!((d.pattern[0] - 7.5 * MM).abs() < 1e-9 && (d.pattern[1] - 2.5 * MM).abs() < 1e-9 && d.pattern.len() == 2, "{d:?}");
assert!((d.offset - 2.5 * MM).abs() < 1e-9, "{d:?}");
// One pen-down stretch is a solid line; a dot keeps the pattern's length.
let d = stroke(&leaves(&plot("IN;SP1;UL3,100;LT3,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed");
assert_eq!(d.pattern[1], 0.0);
let d = stroke(&leaves(&plot("IN;SP1;LT4,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed");
assert!((d.pattern.iter().sum::<f64>() - 10.0 * MM).abs() < 1e-9, "{d:?}");
}
#[test]
fn pen_colors_ignore_two_or_three_parameters() {
let i = plot("IN;PC3,0,0,255;PC3,255;PC3,255,0;SP3;PD0,0,10,0;");
assert_eq!(stroke(&leaves(&i.document)[0]).paint, rgb(0, 0, 255));
}
#[test]
fn hostile_files_import_without_panics() {
let mut cases: Vec<Vec<u8>> = vec![
// Coordinates past any plotter's range, and numbers with too many digits.
b"IN;SP1;PD99999999999999999999999999999,0;PU0,0;PD1016,1016;".to_vec(),
b"IN;SP1;PD0.000000000000000000000000001,1e999,--,++,..;".to_vec(),
// Unterminated labels, comments and encoded polylines.
b"IN;SP1;LBno end".to_vec(),
b"IN;CO\"no end".to_vec(),
b"IN;SP1;PE<=\xff\xff\xff\xfe\xfe".to_vec(),
b"IN;SP1;PE>\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\xfe\x40\xc0;".to_vec(),
// Arcs and wedges of every degenerate kind, with huge sweeps.
b"IN;SP1;PD;AA0,0,32767;AR0,0,90;AT0,0,0,0;RT1,1,2,2;CI0;CI-5;WG0,0,0;EW5,99999,-99999;WG-5,0,360;CI99999999999999;".to_vec(),
// Scaling onto one point, rotations the format doesn't define, huge palettes and pens.
b"IN;IP5,5,5,5;SC0,1,0,1,1;SP1;PD1,1;SC0,0,0,0,2;RO45;NP99999999;SP99999999999;PC99999999,1,2,3;PW99999,99999;PD2,2;".to_vec(),
// Polygon mode left open, PM1 and PM2 without PM0, FP and EP with nothing buffered.
b"IN;SP1;FP;EP;PM1;PM2;PM0;PD10,10;PM1;PM1;PM1;CI5;PE<\xc0\xc0;".to_vec(),
// Escapes cut short, and data lengths past the end.
b"\x1b%-12345X@PJL".to_vec(),
b"IN;SP1;PD0,0;\x1b*b99999999999W".to_vec(),
b"IN;\x1b".to_vec(),
b"IN;\x1b.".to_vec(),
];
// A job line with no end.
cases.push([&b"\x1b%-12345X@PJL "[..], &b"x".repeat(1 << 20)].concat());
// Bytes of every value after a valid start.
cases.push([&b"IN;SP1;PD"[..], &(0..=255u8).collect::<Vec<_>>()].concat());
for f in &cases {
let r = std::panic::catch_unwind(|| import(f));
assert!(r.is_ok(), "{:?}", String::from_utf8_lossy(f));
}
let far = import(&cases[0]).unwrap();
assert!(far.warnings.iter().any(|w| w == FAR_AWAY), "{:?}", far.warnings);
assert_eq!(leaves(&far.document).len(), 1, "the line in range is kept");
// Past the budget for path points, the rest is left out with a warning; pen-up moves, which
// make no path, don't count.
let many = format!("IN;SP1;PR;PD{}", "1,0,".repeat(300_000));
let i = plot(&many);
assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", i.warnings);
// Scaling points past any range give relative pen widths no wider than the widest pen.
let i = plot("IN;IP-99999999999999999999,0,99999999999999999999,0;WU1;PW32767;SP1;PD100,0;");
let r = i.document.artboards[0].rect;
assert!(r.width().is_finite() && r.height() <= 16384.0 * 72.0 / 1016.0 + 1e-6, "{r:?}");
assert!((stroke(&leaves(&i.document)[0]).width - 16384.0 * 72.0 / 1016.0).abs() < 1e-9);
// Each fill of the polygon buffer is a copy of it, and counts.
let big = format!("IN;SP1;PM0;PR;PD{}PM2;{}", "1,1,".repeat(1000), "FP;EP;".repeat(1000));
let i = plot(&big);
assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS) && leaves(&i.document).len() < 400, "{:?}", i.warnings);
for pen in ["SP1;PU", "SP0;PD"] {
let moves = format!("IN;{pen}{};SP1;PU0,0;PD1016,0;", "1,0,".repeat(300_000));
let i = plot(&moves);
assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{pen}: {:?}", i.warnings);
}
}
+5 -1
View File
@@ -1,4 +1,5 @@
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader.
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an
//! HP-GL/2 plot file reader.
//!
//! [`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
@@ -11,13 +12,16 @@
//! 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`].
//!
//! - `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.
mod aci;
pub mod hpgl;
pub mod import;
mod scene;
mod writer;
+3 -1
View File
@@ -2,7 +2,7 @@
//! save, export and serialize by path or bytes (base64), the format table, `command.batch` and
//! `action.batch` (the Actions panel menu's Batch…).
//!
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images).
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, HP-GL, raster images).
//! - `action_batch`: play an action on every file in a folder or every data set.
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
@@ -205,6 +205,8 @@ mod tests_eps;
#[cfg(test)]
mod tests_dxfimport;
#[cfg(test)]
mod tests_hpgl;
#[cfg(test)]
mod tests_metafile;
#[cfg(test)]
File diff suppressed because one or more lines are too long
+12 -1
View File
@@ -479,6 +479,8 @@ 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),
Format {
id: "tga",
label: "Targa",
@@ -552,6 +554,10 @@ pub const OPEN_EXTS: &[&str] = &[
"dxf",
"emf",
"wmf",
"plt",
"hpgl",
"hgl",
"hpg",
"eps",
"epsf",
"ps",
@@ -565,9 +571,10 @@ pub const OPEN_EXTS: &[&str] = &[
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
/// The packaging declares no type for HP-GL plot files either.
pub const UNASSOCIATED_EXTS: &[&str] = &[
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
"heic", "heif", "jxl",
"heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg",
];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
@@ -641,6 +648,10 @@ pub const PLACE_EXTS: &[&str] = &[
"dxf",
"emf",
"wmf",
"plt",
"hpgl",
"hgl",
"hpg",
"eps",
"epsf",
"ps",
+15
View File
@@ -134,6 +134,17 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) {
return format("svg");
}
// HP-GL has no signature: a file that starts with plotter instructions, named as one or with an
// extension no other format (text, the formats VectorCraft names as unsupported, and the files
// File › Open takes in other ways included) reads.
let ext = super::extension(name);
let other = by_name.is_some_and(|f| f.id != "hpgl")
|| super::TEXT_EXTS.contains(&ext.as_str())
|| super::unsupported(&ext).is_some()
|| super::opens_as_other(&ext);
if !other && vectorcraft_cad::hpgl::is_hpgl(bytes) {
return format("hpgl");
}
by_name
}
@@ -487,6 +498,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let (doc, warnings) = super::metafile::import(bytes)?;
(doc, warnings, false)
}
"hpgl" => {
let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?;
(i.document, i.warnings, false)
}
"affinity" => {
let i = super::affinity::import(&title, bytes)?;
preview_only = i.preview_only;
+104
View File
@@ -0,0 +1,104 @@
//! 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.
use serde_json::json;
use vectorcraft_doc::NodeKind;
use super::*;
fn b64(bytes: &[u8]) -> String {
vectorcraft_format::base64_encode(bytes)
}
/// An inch-wide red square and a black diagonal, with the thinnest pen.
const PLOT: &[u8] = b"IN;SP2;PW0;PA0,0;PD1016,0,1016,1016,0,1016,0,0;PU;SP1;PD1016,1016;PU;";
#[test]
fn document_open_reads_a_plot_file() {
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(PLOT)})).unwrap();
assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("hpgl"), Some("part.plt")), "{r}");
let st = s.doc().unwrap();
let board = st.doc.artboards[0].rect;
assert!((board.width() - 72.25).abs() < 1e-9 && (board.height() - 72.25).abs() < 1e-9, "{board:?}");
let mut paths = vec![];
st.doc.walk(|n| {
if let NodeKind::Path { .. } = n.kind {
paths.push(n.clone());
}
});
assert_eq!(paths.len(), 2);
// Opened as artwork: Save asks for a name.
assert!(st.path.is_none());
// A plot file opens by its content in any of its names, and under a name no format reads.
for name in ["drawing.prn", "PART.HPGL", "part.hgl", "part.hpg"] {
let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(PLOT)})).unwrap();
assert_eq!(r["format"].as_str(), Some("hpgl"), "{name}: {r}");
}
}
#[test]
fn file_place_places_a_plot_file_as_one_group() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let p = json!({"name": "part.plt", "dataBase64": b64(PLOT), "thumbnail": 16});
let info = s.execute("file.place.info", &p).unwrap();
assert_eq!(info["format"], "hpgl", "{info}");
assert!((info["width"].as_f64().unwrap() - 72.25).abs() < 1e-9, "{info}");
let r = s.execute("file.place", &p).unwrap();
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("hpgl"), Some(1)), "{r}");
let doc = &s.doc().unwrap().doc;
let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
let NodeKind::Group { children, clip, .. } = &group.kind else { panic!("a group, got {:?}", group.kind) };
assert_eq!((children.len(), *clip), (2, false), "the art's bounds, not clipped");
}
#[test]
fn files_that_are_not_hpgl_are_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
let before = s.doc().unwrap().doc.artboards[0].rect.size();
let script = b"set terminal png\nplot sin(x)\n";
let e = s.execute("document.open", &json!({"name": "graph.plt", "dataBase64": b64(script)})).expect_err("refused");
assert!(e.to_string().contains("not an HP-GL file"), "{e}");
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), before);
assert!(s.execute("file.place", &json!({"name": "graph.plt", "dataBase64": b64(script)})).is_err());
// Text that isn't a plot opens by its name, not as HP-GL.
assert!(detect("notes.txt", b"IN THE BEGINNING").is_none_or(|f| f.id != "hpgl"));
// Only a plot file's own names, and names no other format reads, are read by their content:
// text, other formats' files and the formats named as unsupported (a DWG drawing starts with
// `AC` and digits) aren't, even when they start like a plot.
for name in ["notes.txt", "notes.svg", "picture.tga", "drawing.dxf", "site.dwg", "old.pct"] {
for bytes in [&b"IN"[..], b"in 5;", b"AC1032\0\0", PLOT] {
assert!(detect(name, bytes).is_none_or(|f| f.id != "hpgl"), "{name}: {:?}", String::from_utf8_lossy(bytes));
}
}
let text = s.execute("file.place", &json!({"name": "notes.txt", "dataBase64": b64(b"IN 5; PA 10;")})).unwrap();
assert_eq!(text["format"], "txt", "{text}");
}
/// A plot file named as one opens when its first instruction is one a plot file seldom starts with;
/// under another name it isn't taken for HP-GL.
#[test]
fn a_plot_file_by_its_name_may_start_with_any_instruction() {
let plot = b"SI0.2,0.3;SP1;PA0,0;PD1016,0;";
let r = Session::new().execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(plot)})).unwrap();
assert_eq!(r["format"].as_str(), Some("hpgl"), "{r}");
assert!(detect("drawing.prn", plot).is_none_or(|f| f.id != "hpgl"));
}
#[test]
fn the_dialogs_and_the_format_list_name_hpgl() {
let exts = ["plt", "hpgl", "hgl", "hpg"];
for e in exts {
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("hpgl"), "{e}");
}
let filter = ("HP-GL", &exts[..]);
assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter));
let r = Session::new().execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone();
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)));
}
+3 -3
View File
@@ -5,7 +5,7 @@
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with
//! `link` ([`super::links`]).
//! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options
//! (`dxf`, [`fileio::dxfimport`]).
//! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`).
//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by
//! its art (`crop: "bounding"`).
//! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
has_doc,
place
),
@@ -264,7 +264,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
let layers = std::mem::take(&mut doc.layers);
let symbols = doc.symbol_boxes();
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf" | "hpgl") {
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
// default, as Illustrator places one, it is its art's bounds.
+68 -1
View File
@@ -1,4 +1,4 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF, DXF and HP-GL input,
//! mutated raster images (Photoshop documents and JPEG XL images among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset
//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions),
//! must load as an error or as a document that then renders and exports, without a panic; nor may
@@ -2935,6 +2935,73 @@ proptest! {
}
}
// ---------- HP-GL plot files: opened and placed ----------
/// A plot file with every kind of instruction the reader plays, printer escapes around it.
const HPGL_SAMPLE: &[u8] = b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\r\n\x1bE\x1b%0BIN;BP1,\"job\";CO\"sample\";NP16;PC9,10,20,30;CR0,100,0,100,0,100;\
SP1;PW0.5;WU0;LA1,4,2,4,3,7;LT2,5,1;PA0,0;PD1000,0,1000,1000;PU;LT;PR;PD-500,0,0,-500;PA2000,2000;CI300,5;AA2000,2500,120;AR0,100,-90;\
AT2500,2500,3000,2000;RT100,100,200,0;BZ0,100,100,100,100,0;BR10,10,20,10,30,0;UL3,1,2,1;LT-3,2;EA3000,3000;ER100,-100;FT10,40;RA3500,3500;\
RR200,200;WG400,30,120;EW-400,0,360;PM0;PD4000,4000,4500,4000,4500,4500;PM1;PU4100,4100;PD4200,4100,4200,4200;PM2;FP1;EP;FT3,10,45;FP;\
IP0,0,10160,7620;SC0,100,0,100,1,20,80;PU10,10;PD20,20;SC0,10,0,10,2;PD5,5;SC;RO90;PD100,100;RO;IR10,10,90,90;IW0,0,5000,5000;\
DT*,1;LBlabel*SMx;PA0,0;SM;PE:\xc1<=\xbf\xc0\xc2\xc2;LB\x03PG;\x1b%0A\x1b*r1A\x1b*b4W\x01\x02\x03\x04\x1b*rC\x1b%1BPD1,1;\x1b%-12345X";
/// The sample the mutations start from opens as a plot with art.
#[test]
fn the_hpgl_sample_opens() {
let l = vectorcraft_engine::cmd::fileio::load("x.plt", HPGL_SAMPLE).unwrap();
assert_eq!(l.format.id, "hpgl");
let mut paths = 0;
l.doc.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings);
}
/// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers.
fn arb_hpgl_instruction() -> impl Strategy<Value = String> {
let op = prop::sample::select(vec![
"IN", "DF", "PU", "PD", "PA", "PR", "SP", "PW", "WU", "NP", "PC", "CR", "LT", "UL", "LA", "FT", "SC", "IP", "IR", "RO", "CI", "AA", "AR",
"AT", "RT", "BZ", "BR", "EA", "ER", "RA", "RR", "EW", "WG", "PM", "FP", "EP", "LB", "DT", "SM", "CO", "PG", "IW", "PE", "ZZ",
]);
let number = prop_oneof![
Just("0".to_string()),
Just("-1".to_string()),
(-3000i32..3000).prop_map(|v| v.to_string()),
Just("99999999999999999999999".to_string()),
Just("0.000001".to_string()),
Just("32767".to_string()),
Just("-32768".to_string()),
Just("360".to_string()),
];
(op, prop::collection::vec(number, 0..8)).prop_map(|(o, n)| format!("{o}{};", n.join(",")))
}
proptest! {
#![proptest_config(config())]
#[test]
fn random_hpgl_instructions_never_panic(instructions in prop::collection::vec(arb_hpgl_instruction(), 0..40)) {
let bytes = format!("IN;SP1;{}", instructions.concat()).into_bytes();
survive_metafile("HP-GL instructions", "x.plt", &bytes)?;
}
#[test]
fn mutated_hpgl_files_never_panic(cut in 0usize..1200, edits in prop::collection::vec((0usize..1200, any::<u8>()), 0..16)) {
let mut bytes = HPGL_SAMPLE.to_vec();
let n = bytes.len();
for &(at, b) in &edits {
bytes[at % n] = b;
}
bytes.truncate(cut.max(4));
survive_metafile("mutated HP-GL", "x.plt", &bytes)?;
}
#[test]
fn hpgl_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..400)) {
let mut b = b"IN;SP1;PD".to_vec();
b.extend(bytes);
survive_metafile("HP-GL garbage", "x.hpgl", &b)?;
}
}
// ---------- print presets files ----------
/// Import print presets from `data`, then lay out (and print, when short) the rich document with
+1
View File
@@ -4376,6 +4376,7 @@ axis Both И двете
@msg not a stored central record не е съхранен централен запис
@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 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
@@ -4767,6 +4767,7 @@ imageTrace Outline Obris
@msg not a stored central record nije sačuvani centralni zapis
@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 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
@@ -5616,6 +5616,7 @@ digits Malayalam Malaiàlam
@msg not a VectorCraft file: {0} no és un fitxer de VectorCraft: {0}
@msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`)
@msg not an EMF or WMF file no és un fitxer EMF o WMF
@msg not an HP-GL file no és un fitxer HP-GL
@msg not 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
@@ -4850,6 +4850,7 @@ imageTrace Outline Obrys
@msg not a stored central record nejde o uložený centrální záznam
@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 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
@@ -4477,6 +4477,7 @@ imageTrace Outline Kontur
@msg not a stored central record ikke en gemt central post
@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 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
@@ -4459,6 +4459,7 @@ theme Appearance Erscheinungsbild
@msg not a stored central record kein gespeicherter zentraler Eintrag
@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 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
@@ -4497,6 +4497,7 @@ digits Malayalam Μαλαγιάλαμ
@msg not a stored central record δεν είναι αποθηκευμένη κεντρική εγγραφή
@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 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
@@ -4542,6 +4542,7 @@ axis Both Ambos
@msg not a stored central record no es un registro central almacenado
@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 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
@@ -4758,6 +4758,7 @@ imageTrace Outline Ääriviiva
@msg not a stored central record ei tallennettu keskushakemistotietue
@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 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
@@ -4531,6 +4531,7 @@ axis Both Les deux
@msg not a stored central record ce n’est pas un enregistrement central stocké
@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 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
@@ -7092,6 +7092,7 @@ antiAlias Sharp शार्प
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए
@msg not an action set file एक एक्शन सेट फ़ाइल नहीं
@msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल
@msg not an HP-GL file HP-GL फ़ाइल नहीं है
@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
@@ -5227,6 +5227,7 @@ imageTrace Outline Körvonal
@msg not a VectorCraft shortcut set (missing `overrides`) nem VectorCraft-billentyűparancskészlet (hiányzik: `overrides`)
@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 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
@@ -4477,6 +4477,7 @@ imageTrace Outline Kontur
@msg not a stored central record bukan rekaman pusat yang tersimpan
@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 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 2350 and column 5.
+1
View File
@@ -4585,6 +4585,7 @@ axis Both Entrambi
@msg not a stored central record non è un record centrale archiviato
@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 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
@@ -4655,6 +4655,7 @@ swatch Bright Violet ビビッドバイオレット
@msg the image's pixels don't match its size 画像のピクセル数がサイズと一致しません
@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 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
@@ -4302,6 +4302,7 @@ font-technology Variable ცვლადი
@msg not a stored central record ეს არ არის შენახული ცენტრალური ჩანაწერი
@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 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
@@ -4582,6 +4582,7 @@ theme Appearance 외관
@msg the image's pixels don't match its size 이미지의 픽셀이 크기와 맞지 않습니다
@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 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
@@ -4489,6 +4489,7 @@ imageTrace Outline Contour
@msg not a stored central record geen opgeslagen centraal record
@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 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
@@ -4675,6 +4675,7 @@ imageTrace Outline Kontur
@msg not a stored central record to nie jest zapisany rekord centralny
@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 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
@@ -4081,6 +4081,7 @@ axis Both Ambos
@msg not a stored central record não é um registro central armazenado
@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 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
@@ -4579,6 +4579,7 @@ axis Both Оба
@msg not a stored central record не является сохранённой центральной записью
@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 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
@@ -4446,6 +4446,7 @@ axis Both Båda
@msg not a stored central record inte en lagrad central post
@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 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
@@ -4300,6 +4300,7 @@ axis Both Her İkisi
@msg not a stored central record saklanan merkezi bir kayıt değil
@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 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
@@ -4569,6 +4569,7 @@ axis Both Обидві
@msg not a stored central record не є збереженим центральним записом
@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 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
@@ -4882,6 +4882,7 @@ theme Appearance Giao diện
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace không phải bộ phím tắt: văn bản không bắt đầu bằng một tên có dấu gạch chéo và một dấu ngoặc nhọn mở
@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 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
@@ -4361,6 +4361,7 @@ imageTrace Outline 轮廓
@msg the image's pixels don't match its size 图像的像素与其尺寸不符
@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 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
@@ -2173,6 +2173,7 @@ file-filter Templates 範本
@msg the image's pixels don't match its size 影像的像素與其尺寸不符
@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 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.
+1
View File
@@ -291,6 +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). CNC preset and VCarve QA remain open |
| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | no | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `tests_hpgl.rs`, `import_fuzz.rs` |
| 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) |
+28 -1
View File
@@ -508,7 +508,7 @@ applied colors still join the list while it is hidden.
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. UI commands such as `view.*` and `window.*` are in `command_list` with `ui: true`; running them needs the desktop app (`--connect`). |
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
| `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`, 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. |
| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. |
| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `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. |
@@ -6272,6 +6272,33 @@ clipboard service offers it (the Windows desktop service doesn't read metafiles
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}}
```
## HP-GL import
`document.open` and `file.place` read HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`) into one layer, with the
artboard round the art. A file whose extension no other format reads opens as HP-GL when it starts with plotter
instructions. Older HP-GL files are read by the HP-GL/2 rules for the instructions the two share. The reader follows
the [HP-GL/2 and HP RTL Reference Guide](https://support.hp.com/sg-en/document/c01121263).
Plotter units (1016 to the inch, y up) become points through the scaling the file sets (`IP`, `IR`, `SC`) and its
rotation (`RO`). Each line drawn with the pen down is one stroked path in the pen's color (`SP`, `PC`, `NP`, `CR`)
and width (`PW`, `WU`; 0.35 mm by default), with the line type (`LT`, `UL`) as a dash pattern and the line attributes
(`LA`) as caps and joins. Circles, arcs, wedges and Bézier curves (`CI`, `AA`, `AR`, `AT`, `RT`, `EW`, `WG`, `BZ`,
`BR`) are drawn as curves. Filled rectangles, wedges and polygons (`RA`, `RR`, `WG`, `PM` with `FP`) become filled
paths, and edged ones (`EA`, `ER`, `EW`, `EP`) become stroked paths. Encoded polylines (`PE`) are read, and printer
escape sequences and PJL lines around the instructions are skipped.
Two rules are VectorCraft's own: before the first `SP` the pen is pen 1, and the hard-clip limits are 11 × 8.5 in
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.
```json
{"name":"open_file","arguments":{"path":"/tmp/part.plt"}}
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/part.plt","at":[300,200]}}}
```
## TIFF, BMP and Targa export
`document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one
+1 -1
View File
@@ -170,7 +170,7 @@ to the target triple, so host build scripts aren't affected.
AppStream metainfo, the MIME type for VectorCraft documents, the icons and the licence files.
**Document types.** The desktop entry's `MimeType` lists every format File › Open reads but PSD,
PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000 and HEIC/HEIF, so file managers list VectorCraft under Open With for them.
PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF and HP-GL, so file managers list VectorCraft under Open With for them.
`ai.storyteller.vectorcraft.mime.xml` declares the types that shared-mime-info lacks: VectorCraft
documents and templates, `.ait` templates and Affinity files (`application/x-affinity`). An `.af`
file gets the Affinity type by its content, and `.afdesign` and `.afpub` files by their extension.
+2 -1
View File
@@ -691,7 +691,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests/distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo.
- **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests/panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/).
- **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending.
- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF and JPEG XL, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types.
- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF, JPEG XL and HP-GL, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types.
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
- **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`.
@@ -1493,6 +1493,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`).
- 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`).
- 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`).