From 74ea25cd6c4dffa4cd41f6ee9a76f7afd4225622 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 14:36:46 -0800 Subject: [PATCH 1/3] File formats: HP-GL (.plt) plot files open and place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open and File › Place refused plotter files: HP-GL/2 files (.plt, .hpgl, .hgl, .hpg) were not a format VectorCraft reads. The format table gets a read-only HP-GL format, read by a new vectorcraft_cad::hpgl module. Each line the pen draws becomes a stroked path in its pen's color and width, with the line type as a dash pattern and the line attributes as caps and joins. Circles, arcs, wedges and Bézier curves are drawn as curves. Filled and edged rectangles, wedges and polygons (polygon mode) become filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Plotter units (1016 to the inch, y up) become points, and the artboard holds what the art paints. Place brings the art in as one group. Older HP-GL files are read by the HP-GL/2 rules for the instructions the two share. A file is recognized by its first instructions, after any printer escape sequences and PJL lines, so a plot file also opens under an extension no other format reads. VectorCraft's own rules where the device decides: before the first SP the pen is pen 1, and the hard-clip limits are 11 x 8.5 in, which P2 takes when the file sets no scaling points. Labels, symbol-mode symbols and raster images are not drawn, the input window does not clip, hatched fills are filled solid, and later pages are drawn over the first; the warnings list each. Objects, path points and pen widths are capped, and the import fuzzer covers random, mutated and garbage plot files. An importer plug-in that lists .plt, .hpgl, .hgl or .hpg now opens those files only when document.open names it with `importer`. Docs: docs/mcp.md, docs/file-format-parity.md, docs/target-app-parity.md, docs/releasing.md. --- crates/cad/Cargo.toml | 2 +- crates/cad/src/hpgl.rs | 183 +++ crates/cad/src/hpgl/lex.rs | 428 +++++++ crates/cad/src/hpgl/plot.rs | 1331 ++++++++++++++++++++ crates/cad/src/hpgl/tests.rs | 612 +++++++++ crates/cad/src/lib.rs | 6 +- crates/engine/src/cmd/fileio.rs | 4 +- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/formats.rs | 13 +- crates/engine/src/cmd/fileio/load.rs | 15 + crates/engine/src/cmd/fileio/tests_hpgl.rs | 94 ++ crates/engine/src/cmd/place.rs | 6 +- crates/engine/tests/import_fuzz.rs | 69 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/file-format-parity.md | 1 + docs/mcp.md | 29 +- docs/releasing.md | 2 +- docs/target-app-parity.md | 3 +- 44 files changed, 2815 insertions(+), 12 deletions(-) create mode 100644 crates/cad/src/hpgl.rs create mode 100644 crates/cad/src/hpgl/lex.rs create mode 100644 crates/cad/src/hpgl/plot.rs create mode 100644 crates/cad/src/hpgl/tests.rs create mode 100644 crates/engine/src/cmd/fileio/tests_hpgl.rs diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 2ea6dea4f..3e262b076 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-cad" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018)" +description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader" [lints] workspace = true diff --git a/crates/cad/src/hpgl.rs b/crates/cad/src/hpgl.rs new file mode 100644 index 000000000..5b47aa445 --- /dev/null +++ b/crates/cad/src/hpgl.rs @@ -0,0 +1,183 @@ +//! 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 { + let mut lex = Lexer::new(bytes); + let mut terminator = lex::ETX; + let mut first = 0; + for n in 0..8 { + if n > 0 && lex.pos() > first + 4096 { + return true; + } + let Some(op) = lex.op(true) else { return n > 0 && lex.done() }; + if n == 0 { + first = lex.pos(); + } + if n == 0 && !FIRST.contains(&op) { + return false; + } + let known = PLAYED.contains(&op) || IGNORED.contains(&op); + 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; + } + } + } + } + true +} + +/// Read an HP-GL file. +pub fn import(bytes: &[u8]) -> Result { + if !is_hpgl(bytes) { + 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 = 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; diff --git a/crates/cad/src/hpgl/lex.rs b/crates/cad/src/hpgl/lex.rs new file mode 100644 index 000000000..5025d07c6 --- /dev/null +++ b/crates/cad/src/hpgl/lex.rs @@ -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 { + 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) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let (base, low, high): (u64, std::ops::RangeInclusive, std::ops::RangeInclusive) = + 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 }); + } + } + } +} diff --git a/crates/cad/src/hpgl/plot.rs b/crates/cad/src/hpgl/plot.rs new file mode 100644 index 000000000..2ee70da75 --- /dev/null +++ b/crates/cad/src/hpgl/plot.rs @@ -0,0 +1,1331 @@ +//! The HP-GL/2 instructions played on a model of the plotter: the coordinate system (scaling +//! points, user units, rotation), the pen (position, up or down, number, width, color), the line +//! and fill attributes and the polygon buffer. What the pen draws is collected as shapes in +//! rotated plotter units, y up. + +use std::collections::HashMap; + +use kurbo::{Affine, BezPath, PathEl, Point, Shape as _, Vec2}; +use vectorcraft_doc::{LineCap, LineJoin}; +use vectorcraft_geom::FillRule; + +use super::lex::{ETX, Lexer, Pe, PeItem}; + +/// Plotter units per millimeter (0.025 mm each). +pub(super) const PLU_PER_MM: f64 = 40.0; +/// The hard-clip limits taken for P1 and P2 when the file sets none (plotter units): 11 × 8.5 in. +pub(super) const HARD_CLIP: Point = Point::new(11176.0, 8636.0); +/// The pen width after IN, in millimeters. +const DEFAULT_WIDTH_MM: f64 = 0.35; +/// The default line pattern length, in percent of the P1-P2 diagonal. +const DEFAULT_PATTERN: f64 = 4.0; +/// How far from the origin art may lie (plotter units, twice the range the format defines). +pub(super) const LIMIT: f64 = 16_777_216.0; +/// The widest pen (plotter units): 409.6 mm, the least a device must take before it draws its own +/// widest pen instead. +const MAX_WIDTH: f64 = 16_384.0; +/// The most objects an import makes. +const MAX_SHAPES: usize = 1 << 20; +/// The most path elements an import makes. +const MAX_ELEMENTS: usize = if cfg!(test) { 200_000 } else { 4_000_000 }; +/// The most pens a palette holds. +const MAX_PENS: i64 = 32_768; + +pub(super) const LABELS: &str = "labels were left out"; +pub(super) const SYMBOLS: &str = "symbols drawn at plotted points (symbol mode) were left out"; +pub(super) const RASTER: &str = "raster images in the file were left out"; +pub(super) const HATCHES: &str = "hatched and patterned fills are filled solid"; +pub(super) const WINDOW: &str = "the input window doesn't clip the art"; +pub(super) const PAGES: &str = "the art of later pages is drawn over the first page"; +pub(super) const FAR_AWAY: &str = "objects far outside the drawing were left out"; +pub(super) const TOO_MANY: &str = "the file has more than 1,048,576 objects: the rest are left out"; +pub(super) const TOO_MANY_POINTS: &str = "the file has more than 4,000,000 path points: the rest are left out"; + +/// The instructions polygon mode plays (the others are ignored there). +const POLYGON_MODE: &[[u8; 2]] = + &[*b"AA", *b"AR", *b"AT", *b"BP", *b"BR", *b"BZ", *b"CI", *b"CO", *b"DF", *b"IN", *b"PA", *b"PD", *b"PE", *b"PM", *b"PR", *b"PU", *b"RT"]; + +/// The colors of pens 0 to 7 until PC sets others. +const PALETTE: [[u8; 3]; 8] = [[255, 255, 255], [0, 0, 0], [255, 0, 0], [0, 255, 0], [255, 255, 0], [0, 0, 255], [255, 0, 255], [0, 255, 255]]; + +/// Line types 1 to 8: pen-down and pen-up lengths in percent of the pattern length. +const LINE_TYPES: [&[f64]; 8] = [ + &[0.0, 100.0], + &[50.0, 50.0], + &[70.0, 30.0], + &[80.0, 10.0, 0.0, 10.0], + &[70.0, 10.0, 10.0, 10.0], + &[50.0, 10.0, 10.0, 10.0, 10.0, 10.0], + &[70.0, 10.0, 0.0, 10.0, 0.0, 10.0], + &[50.0, 10.0, 0.0, 10.0, 10.0, 10.0, 0.0, 10.0], +]; + +/// A stroke as the pen draws it (lengths in plotter units). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Stroke { + pub color: [u8; 3], + pub width: f64, + pub cap: LineCap, + pub join: LineJoin, + pub miter: f64, + pub dash: Option, +} + +/// A line type as dashes (lengths in plotter units). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct LinePattern { + /// Dash, gap, dash, gap…: an even number of lengths. + pub pattern: Vec, + /// How far into the pattern each line starts. + pub offset: f64, + /// The pattern stretches to fit each line, half its first dash at each end (adaptive line + /// types). + pub adaptive: bool, +} + +impl LinePattern { + /// The pattern of `percent` (pen-down and pen-up stretches, in turn, starting pen down) of + /// `length`. A pen-down stretch of zero is a dot, a vector one plotter unit long that the gap + /// after it gives up. An odd count repeats with its last pen-down stretch running into the + /// first one, which the pattern then starts within. + fn new(percent: &[f64], length: f64, adaptive: bool) -> Self { + let mut pattern: Vec = percent.iter().map(|p| p / 100.0 * length).collect(); + for pair in pattern.chunks_mut(2) { + if let [dash, rest @ ..] = pair + && *dash == 0.0 + { + *dash = 1.0; + if let Some(gap) = rest.first_mut() { + *gap = (*gap - 1.0).max(0.0); + } + } + } + let mut offset = 0.0; + if pattern.len() % 2 == 1 { + match pattern.len() { + // One pen-down stretch: a solid line. + 1 => pattern.push(0.0), + _ => { + let last = pattern.pop().unwrap_or(0.0); + if let Some(first) = pattern.first_mut() { + *first += last; + } + offset = last; + } + } + } + Self { pattern, offset, adaptive } + } +} + +/// What the plot draws: an outline with a fill, a stroke or both. +#[derive(Clone, Debug)] +pub(super) struct Shape { + pub path: BezPath, + pub fill: Option<([u8; 3], FillRule)>, + pub stroke: Option, +} + +/// A piece of a line the pen moves along, by its end. +#[derive(Clone, Copy, Debug)] +enum Piece { + Line(Point), + Curve(Point, Point, Point), +} + +impl Piece { + fn end(self) -> Point { + match self { + Self::Line(p) | Self::Curve(_, _, p) => p, + } + } + + fn push(self, bp: &mut BezPath) { + match self { + Self::Line(p) => bp.line_to(p), + Self::Curve(a, b, c) => bp.curve_to(a, b, c), + } + } +} + +/// User units: an SC instruction in force. +#[derive(Clone, Copy, Debug)] +enum Scaling { + /// The user ranges mapped onto P1 and P2; isotropic with the left and bottom percentages. + Fit { x0: f64, x1: f64, y0: f64, y1: f64, iso: Option<(f64, f64)> }, + /// P1's user coordinates and the plotter units per user unit. + Factor { x0: f64, fx: f64, y0: f64, fy: f64 }, +} + +/// How areas are filled. +#[derive(Clone, Copy, Debug, PartialEq)] +enum FillKind { + Solid, + /// The pen's color at this percentage, on white. + Shade(f64), + /// Hatching and patterns, filled solid. + Pattern, +} + +/// The polygon buffer: its outline for filling (every edge) and for edging (pen-down edges only). +#[derive(Clone, Debug, Default)] +struct Poly { + fill: BezPath, + edge: BezPath, + /// A subpolygon is open, from `start`; its last point. + open: bool, + start: Point, + last: Point, + /// The open subpolygon's points, and where its elements start in `fill` and `edge`. + points: usize, + marks: (usize, usize), + /// Where the edge outline ends (`None`: it has to move before drawing). + edge_at: Option, + /// After PM1 or a circle: the next move starts a subpolygon. + fresh: bool, +} + +impl Poly { + fn add(&mut self, a: Point, pieces: &[Piece], down: bool) { + let Some(end) = pieces.last().map(|p| p.end()) else { return }; + // The move after PM1 goes to the next subpolygon's first point with the pen up, whatever + // the pen's state (an arc's first chord aside): it is neither filled nor edged. + if std::mem::take(&mut self.fresh) && matches!(pieces, [Piece::Line(_)]) { + (self.start, self.last) = (end, end); + return; + } + if !self.open { + self.marks = (self.fill.elements().len(), self.edge.elements().len()); + self.fill.move_to(a); + (self.start, self.open, self.points) = (a, true, 1); + } + pieces.iter().for_each(|p| p.push(&mut self.fill)); + self.points += pieces.len(); + if down { + if self.edge_at != Some(a) { + self.edge.move_to(a); + } + pieces.iter().for_each(|p| p.push(&mut self.edge)); + self.edge_at = Some(end); + } else { + self.edge_at = None; + } + self.last = end; + } + + /// Close the open subpolygon, adding a point at its start when it ends elsewhere; the edge to + /// that point is drawn when `down`. A subpolygon of fewer than three points (that one + /// included) is neither filled nor edged. → The point added. + fn close(&mut self, down: bool) -> Option { + if !self.open { + return None; + } + let closure = self.last != self.start; + if self.points + usize::from(closure) < 3 { + self.fill.truncate(self.marks.0); + self.edge.truncate(self.marks.1); + } else { + if closure && down { + if self.edge_at != Some(self.last) { + self.edge.move_to(self.last); + } + self.edge.line_to(self.start); + } + self.fill.close_path(); + } + (self.open, self.edge_at) = (false, None); + closure.then_some(self.start) + } + + /// A closed outline of its own (a circle): the open subpolygon is closed with the pen up + /// first, and the next move starts a subpolygon. + fn closed(&mut self, path: &BezPath) { + self.close(false); + self.fill.extend(path.iter()); + self.edge.extend(path.iter()); + self.fresh = true; + } +} + +/// The line drawn so far with the pen down, not yet a shape. +struct Run { + path: BezPath, + stroke: Stroke, + start: Point, + end: Point, +} + +pub(super) struct Plot { + pub shapes: Vec, + pub warnings: Vec, + /// A budget ran out: nothing more is drawn. + pub full: bool, + elements: usize, + // The coordinate system. + p1: Point, + p2: Point, + scaling: Option, + /// User units to plotter units (identity when scaling is off). + user: Affine, + /// The RO rotation (rotated plotter units to where the art lies) and its angle. + rot: Affine, + ro: u16, + // The pen. + relative: bool, + down: bool, + /// The pen's position, in plotter units before rotation. + pos: Point, + /// The pen SP selected (`None`: none yet, which draws with pen 1). + pen: Option, + pens: i64, + colors: HashMap, + ranges: [(f64, f64); 3], + width: f64, + widths: HashMap, + relative_widths: bool, + // Line and fill attributes. + line_type: Option, + saved_line_type: Option, + pattern: f64, + pattern_mm: bool, + user_types: [Option>; 8], + cap: LineCap, + join: LineJoin, + miter: f64, + fill: FillKind, + // Polygons. + poly: Option, + buffer: Option, + run: Option, + terminator: u8, + /// Instructions of kinds the reader doesn't know. + pub unknown: usize, + /// How many shapes there were at the first page advance. + page_end: Option, +} + +impl Plot { + pub fn new() -> Self { + let mut p = Self { + shapes: vec![], + warnings: vec![], + full: false, + elements: 0, + p1: Point::ZERO, + p2: HARD_CLIP, + scaling: None, + user: Affine::IDENTITY, + rot: Affine::IDENTITY, + ro: 0, + relative: false, + down: false, + pos: Point::ZERO, + pen: None, + pens: 8, + colors: HashMap::new(), + ranges: [(0.0, 255.0); 3], + width: DEFAULT_WIDTH_MM * PLU_PER_MM, + widths: HashMap::new(), + relative_widths: false, + line_type: None, + saved_line_type: None, + pattern: DEFAULT_PATTERN, + pattern_mm: false, + user_types: Default::default(), + cap: LineCap::Butt, + join: LineJoin::Miter, + miter: 5.0, + fill: FillKind::Solid, + poly: None, + buffer: None, + run: None, + terminator: ETX, + unknown: 0, + page_end: None, + }; + p.init(); + p + } + + pub fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + // ---------- defaults ---------- + + /// DF: the line and fill attributes, scaling, plotting mode and polygon mode as they start. + fn defaults(&mut self) { + self.flush(); + self.relative = false; + (self.line_type, self.saved_line_type, self.pattern, self.pattern_mm) = (None, None, DEFAULT_PATTERN, false); + self.user_types = Default::default(); + (self.cap, self.join, self.miter) = (LineCap::Butt, LineJoin::Miter, 5.0); + self.fill = FillKind::Solid; + (self.poly, self.buffer) = (None, None); + self.scaling = None; + self.ranges = [(0.0, 255.0); 3]; + self.terminator = ETX; + self.update_user(); + } + + /// IN: DF, and the pen up at the origin, default widths, palette and scaling points, no + /// rotation. The selected pen stays. + fn init(&mut self) { + self.defaults(); + (self.pos, self.down) = (Point::ZERO, false); + (self.width, self.relative_widths) = (DEFAULT_WIDTH_MM * PLU_PER_MM, false); + self.widths.clear(); + (self.rot, self.ro) = (Affine::IDENTITY, 0); + (self.p1, self.p2) = (Point::ZERO, HARD_CLIP); + self.pens = 8; + self.colors.clear(); + self.update_user(); + } + + // ---------- coordinates ---------- + + /// User units to plotter units for the scaling in force; scaling that maps everything onto one + /// point is ignored. + fn update_user(&mut self) { + let (p1, p2) = (self.p1, self.p2); + let user = match self.scaling { + None => Affine::IDENTITY, + Some(Scaling::Factor { x0, fx, y0, fy }) => { + Affine::translate(p1.to_vec2()) * Affine::scale_non_uniform(fx, fy) * Affine::translate((-x0, -y0)) + } + Some(Scaling::Fit { x0, x1, y0, y1, iso }) => { + let (w, h, ux, uy) = (p2.x - p1.x, p2.y - p1.y, x1 - x0, y1 - y0); + let (mut sx, mut sy, mut off) = (w / ux, h / uy, Vec2::ZERO); + if let Some((left, bottom)) = iso { + let s = sx.abs().min(sy.abs()); + let (extra_x, extra_y) = (w.abs() - ux.abs() * s, h.abs() - uy.abs() * s); + off = Vec2::new(extra_x * left / 100.0 * w.signum(), extra_y * bottom / 100.0 * h.signum()); + (sx, sy) = (s * sx.signum(), s * sy.signum()); + } + Affine::translate(p1.to_vec2() + off) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-x0, -y0)) + } + }; + let det = user.determinant(); + self.user = if det.is_finite() && det != 0.0 && user.as_coeffs().iter().all(|c| c.is_finite()) { user } else { Affine::IDENTITY }; + } + + /// A point in current units, in plotter units. + fn to_plu(&self, x: f64, y: f64) -> Point { + self.user * Point::new(x, y) + } + + /// An increment in current units, in plotter units. + fn delta(&self, x: f64, y: f64) -> Vec2 { + self.to_plu(x, y) - self.to_plu(0.0, 0.0) + } + + /// The pen's position in current units. + fn pos_user(&self) -> Point { + self.user.inverse() * self.pos + } + + /// Plotter units (before rotation) to where the art lies. + fn dev(&self, p: Point) -> Point { + self.rot * p + } + + /// User units to where the art lies. + fn user_dev(&self) -> Affine { + self.rot * self.user + } + + /// The far corner of the hard-clip limits in the current orientation (their sides swap at 90 + /// and 270 degrees). + fn hard_clip(&self) -> Point { + if self.ro % 180 == 90 { Point::new(HARD_CLIP.y, HARD_CLIP.x) } else { HARD_CLIP } + } + + /// RO: plotter units rotated `angle` degrees (0, 90, 180 or 270) to where the art lies, shifted + /// so the hard-clip limits stay where they were. + fn rotation(angle: u16) -> Affine { + let (w, h) = (HARD_CLIP.x, HARD_CLIP.y); + match angle { + 90 => Affine::new([0.0, 1.0, -1.0, 0.0, w, 0.0]), + 180 => Affine::new([-1.0, 0.0, 0.0, -1.0, w, h]), + 270 => Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]), + _ => Affine::IDENTITY, + } + } + + fn diagonal(&self) -> f64 { + (self.p2 - self.p1).hypot().max(1.0) + } + + // ---------- pens and attributes ---------- + + /// The palette entry the selected pen draws with (`None`: pen 0, no pen). + fn pen_index(&self) -> Option { + let n = self.pen.unwrap_or(1); + if n <= 0 { + return None; + } + Some((n - 1) % (self.pens - 1).max(1) + 1) + } + + fn color(&self, i: i64) -> [u8; 3] { + let default = usize::try_from(i).ok().and_then(|i| PALETTE.get(i)).copied().unwrap_or([0; 3]); + self.colors.get(&i).copied().unwrap_or(default) + } + + /// The stroke the selected pen draws (`None` for no pen). + fn stroke(&self) -> Option { + let i = self.pen_index()?; + let width = self.widths.get(&i).copied().unwrap_or(self.width).clamp(0.0, MAX_WIDTH); + let dash = self.line_type.filter(|k| *k != 0).map(|k| { + let index = k.unsigned_abs().clamp(1, 8) as usize - 1; + let length = if self.pattern_mm { self.pattern * PLU_PER_MM } else { self.pattern / 100.0 * self.diagonal() }; + match &self.user_types[index] { + Some(pct) => LinePattern::new(pct, length, k < 0), + None => LinePattern::new(LINE_TYPES[index], length, k < 0), + } + }); + Some(Stroke { color: self.color(i), width, cap: self.cap, join: self.join, miter: self.miter, dash }) + } + + /// The color the selected pen fills with (`None` for no pen). + fn fill_color(&mut self) -> Option<[u8; 3]> { + let c = self.color(self.pen_index()?); + match self.fill { + FillKind::Solid => Some(c), + FillKind::Pattern => { + self.warn(HATCHES); + Some(c) + } + FillKind::Shade(level) => { + let t = (level / 100.0).clamp(0.0, 1.0); + Some(c.map(|v| (255.0 - (255.0 - f64::from(v)) * t).round() as u8)) + } + } + } + + // ---------- output ---------- + + /// Count `n` path elements against the budget → whether they fit. + fn spend(&mut self, n: usize) -> bool { + self.elements += n; + if self.elements > MAX_ELEMENTS { + self.warn(TOO_MANY_POINTS); + self.full = true; + } + !self.full + } + + fn push(&mut self, shape: Shape) { + if shape.path.elements().len() < 2 { + return; + } + if self.shapes.len() >= MAX_SHAPES { + self.warn(TOO_MANY); + self.full = true; + return; + } + let b = shape.path.bounding_box(); + if ![b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) { + return self.warn(FAR_AWAY); + } + self.shapes.push(shape); + } + + /// End the line being drawn. + fn flush(&mut self) { + let Some(run) = self.run.take() else { return }; + let mut els = run.path.into_elements(); + // Vectors of no length alone draw a dot: a vector one plotter unit long. + if els.len() == 1 { + els.push(PathEl::LineTo(run.start + Vec2::new(1.0, 0.0))); + } + // A line that ends where it started is closed, so its ends join. + if els.len() > 3 && run.end == run.start { + if matches!(els.last(), Some(PathEl::LineTo(p)) if *p == run.start) { + els.pop(); + } + els.push(PathEl::ClosePath); + } + self.push(Shape { path: BezPath::from_vec(els), fill: None, stroke: Some(run.stroke) }); + } + + /// A dot (a vector one plotter unit long) at `at`, drawn with the selected pen. + fn dot(&mut self, at: Point) { + if let Some(stroke) = self.stroke() { + let mut path = BezPath::new(); + path.move_to(at); + path.line_to(at + Vec2::new(1.0, 0.0)); + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + } + + /// The pen moves from `a` along `pieces` (where the art lies), drawing when `down`; in polygon + /// mode the move goes into the polygon buffer. + fn trace(&mut self, a: Point, pieces: &[Piece], down: bool) { + // Only what goes into a path counts against the budget: outside polygon mode, moves with the + // pen up or with no pen (SP0) make nothing. + let draws = down && self.pen_index().is_some(); + if pieces.is_empty() || self.full || (!draws && self.poly.is_none()) || !self.spend(pieces.len()) { + return; + } + if let Some(poly) = &mut self.poly { + return poly.add(a, pieces, down); + } + let Some(stroke) = self.stroke() else { return }; + if self.line_type == Some(0) { + // Line type 0 draws a dot at each point given (an arc's or a curve's end). + self.flush(); + if let Some(p) = pieces.last() { + self.dot(p.end()); + } + return; + } + if self.run.as_ref().is_some_and(|r| r.stroke != stroke || r.end != a) { + self.flush(); + } + let run = self.run.get_or_insert_with(|| { + let mut path = BezPath::new(); + path.move_to(a); + Run { path, stroke, start: a, end: a } + }); + for p in pieces { + // A vector of no length adds nothing to a line (a line of nothing else is a dot). + if matches!(p, Piece::Line(b) if *b == run.end) { + continue; + } + p.push(&mut run.path); + run.end = p.end(); + } + } + + /// Move the pen to `target` (plotter units), drawing when it is down. + fn line_to(&mut self, target: Point) { + if !(target.x.is_finite() && target.y.is_finite()) { + return; + } + let (a, b) = (self.dev(self.pos), self.dev(target)); + let down = self.down; + self.trace(a, &[Piece::Line(b)], down); + self.pos = target; + } + + /// The curves of an arc around `center` (user units) from `start` through `sweep` degrees, + /// where the art lies, and its end (user units). `None` when it has no radius or sweep. + fn arc(&self, center: Point, start: Point, sweep: f64) -> Option<(Vec, Point)> { + let r = (start - center).hypot(); + let sweep = sweep.clamp(-32768.0, 32767.0).to_radians(); + if !(r > 0.0 && r.is_finite() && sweep != 0.0 && sweep.is_finite()) { + return None; + } + let a0 = (start - center).atan2(); + let arc = kurbo::Arc { center, radii: Vec2::new(r, r), start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; + let m = self.user_dev(); + let pieces = arc + .append_iter(r * 1e-6) + .filter_map(|el| match el { + PathEl::CurveTo(a, b, c) => Some(Piece::Curve(m * a, m * b, m * c)), + PathEl::LineTo(p) => Some(Piece::Line(m * p)), + _ => None, + }) + .collect(); + Some((pieces, center + Vec2::from_angle(a0 + sweep) * r)) + } + + /// An arc from the pen around `center` (user units), drawn with the pen as it is. + fn arc_from_pen(&mut self, center: Point, sweep: f64) { + let start = self.pos_user(); + match self.arc(center, start, sweep) { + Some((pieces, end)) => { + let (a, down) = (self.dev(self.pos), self.down); + self.trace(a, &pieces, down); + self.pos = self.user * end; + } + None => { + let at = self.dev(self.pos); + let down = self.down; + self.trace(at, &[Piece::Line(at)], down); + } + } + } + + /// A closed outline through `pieces` from `a` (where the art lies). + fn outline(a: Point, pieces: &[Piece]) -> BezPath { + let mut path = BezPath::new(); + path.move_to(a); + pieces.iter().for_each(|p| p.push(&mut path)); + path.close_path(); + path + } + + /// A circle of `radius` (current units) around the pen, starting at 0 degrees (180 for a + /// negative radius), where the art lies. + fn circle(&self, radius: f64) -> Option { + let c = self.pos_user(); + let start = c + Vec2::new(radius, 0.0); + let (pieces, _) = self.arc(c, start, 360.0)?; + Some(Self::outline(self.user_dev() * start, &pieces)) + } + + /// A wedge of `radius` (current units) around the pen, from `start` through `sweep` degrees. + fn wedge(&self, radius: f64, start: f64, sweep: f64) -> Option { + let c = self.pos_user(); + let reference = if radius < 0.0 { 180.0 } else { 0.0 }; + let sweep = sweep.clamp(-360.0, 360.0); + let first = c + Vec2::from_angle((reference + start.clamp(-32768.0, 32767.0) % 360.0).to_radians()) * radius.abs(); + let m = self.user_dev(); + if sweep.abs() >= 360.0 { + let (pieces, _) = self.arc(c, first, sweep)?; + return Some(Self::outline(m * first, &pieces)); + } + let mut pieces = vec![Piece::Line(m * first)]; + if let Some((arc, _)) = self.arc(c, first, sweep) { + pieces.extend(arc); + } + (radius != 0.0).then(|| Self::outline(m * c, &pieces)) + } + + /// The rectangle from the pen to `corner` (plotter units), where the art lies. + fn rect(&self, corner: Point) -> BezPath { + let (a, b) = (self.pos_user(), self.user.inverse() * corner); + let m = self.user_dev(); + let pieces = [Point::new(b.x, a.y), b, Point::new(a.x, b.y)].map(|p| Piece::Line(m * p)); + Self::outline(m * a, &pieces) + } + + /// `path` as the polygon buffer, filled (`fill`) or edged. + fn buffer_and_draw(&mut self, path: Option, fill: bool) { + self.flush(); + if self.poly.is_some() { + return; + } + let Some(path) = path else { + return self.dot(self.dev(self.pos)); + }; + if !self.spend(path.elements().len()) { + return; + } + let mut poly = Poly::default(); + poly.closed(&path); + self.buffer = Some(poly); + if fill { self.fill_polygon(FillRule::EvenOdd) } else { self.edge_polygon() } + } + + /// FP: the polygon buffer filled (each fill is a copy of the buffer, counted against the + /// budget). + fn fill_polygon(&mut self, rule: FillRule) { + self.flush(); + let n = self.buffer.as_ref().map_or(0, |b| b.fill.elements().len()); + if n == 0 || !self.spend(n) { + return; + } + let Some(c) = self.fill_color() else { return }; + let Some(path) = self.buffer.as_ref().map(|b| b.fill.clone()) else { return }; + self.push(Shape { path, fill: Some((c, rule)), stroke: None }); + } + + /// EP: the pen-down edges of the polygon buffer stroked (counted as FP's are). + fn edge_polygon(&mut self) { + self.flush(); + let n = self.buffer.as_ref().map_or(0, |b| b.edge.elements().len()); + if n == 0 || !self.spend(n) { + return; + } + let Some(stroke) = self.stroke() else { return }; + let Some(path) = self.buffer.as_ref().map(|b| b.edge.clone()) else { return }; + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + + // ---------- instructions ---------- + + /// Read and play the instruction `op`. + pub fn op(&mut self, op: [u8; 2], lex: &mut Lexer) { + if self.poly.is_some() && !POLYGON_MODE.contains(&op) { + return self.skip_op(op, lex); + } + match &op { + b"IN" | b"BP" | b"DF" | b"PU" | b"PD" | b"PA" | b"PR" | b"SP" => self.setup_op(op, lex), + b"PW" | b"WU" | b"NP" | b"PC" | b"CR" => self.pen_op(op, lex), + b"LT" | b"UL" => self.line_type_op(op, lex), + b"LA" | b"FT" | b"SC" => self.attribute_op(op, lex), + b"IP" | b"IR" | b"RO" | b"CI" => self.frame_op(op, lex), + b"AA" | b"AR" | b"AT" | b"RT" | b"BZ" | b"BR" => self.curve_op(op, lex), + b"EA" | b"ER" | b"RA" | b"RR" | b"EW" | b"WG" | b"PM" | b"FP" | b"EP" | b"PE" => self.area_op(op, lex), + _ => self.other_op(op, lex), + } + } + + /// An instruction polygon mode ignores: its parameters (or text) are read. + fn skip_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + match &op { + b"LB" | b"BL" | b"WD" => lex.label(self.terminator), + b"DT" => { + lex.char(); + lex.params(); + } + b"SM" => { + lex.char(); + } + _ => lex.params(), + } + } + + /// Initialization, pen moves and pen selection. + fn setup_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"IN" | b"BP" => { + lex.params(); + self.init(); + } + b"DF" => { + lex.params(); + self.defaults(); + } + b"PU" | b"PD" => { + self.down = op[1] == b'D'; + self.points(lex); + } + b"PA" | b"PR" => { + self.relative = op[1] == b'R'; + self.points(lex); + } + b"SP" => { + let n = lex.numbers(&mut v); + self.flush(); + self.pen = Some(if n == 0 { 0 } else { v[0].round().clamp(-1.0, 1e9) as i64 }); + } + _ => {} + } + } + + /// Pen widths, the palette and its colors. + fn pen_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"PW" => { + let n = lex.numbers(&mut v); + self.flush(); + let width = |s: &Self, w: f64| if s.relative_widths { w / 100.0 * s.diagonal() } else { w * PLU_PER_MM }; + let default = if self.relative_widths { 0.1 } else { DEFAULT_WIDTH_MM }; + let w = width(self, if n == 0 { default } else { v[0].clamp(0.0, 32767.0) }); + if n >= 2 { + let pen = v[1].round().clamp(0.0, 1e9) as i64; + if pen > 0 && pen < self.pens.max(8) { + self.widths.insert(pen, w); + } + } else { + self.width = w; + self.widths.clear(); + } + } + b"WU" => { + let n = lex.numbers(&mut v); + self.flush(); + self.relative_widths = n > 0 && v[0].round() == 1.0; + self.width = if self.relative_widths { 0.1 / 100.0 * self.diagonal() } else { DEFAULT_WIDTH_MM * PLU_PER_MM }; + self.widths.clear(); + } + b"NP" => { + let n = lex.numbers(&mut v); + self.flush(); + if n == 0 { + self.pens = 8; + } else if v[0] >= 2.0 { + self.pens = i64::from((v[0].min(MAX_PENS as f64).ceil() as u32).next_power_of_two()).min(MAX_PENS); + } + } + b"PC" => { + let n = lex.numbers(&mut v); + self.flush(); + let pen = v[0].round().clamp(0.0, 1e9) as i64; + match n { + 0 => self.colors.clear(), + 1 => { + self.colors.remove(&pen); + } + // Two or three parameters: the instruction is ignored. + 2 | 3 => {} + _ if pen < MAX_PENS => { + let channel = |i: usize| { + let (b, w) = self.ranges[i]; + let t = if w == b { 0.0 } else { ((v[i + 1] - b) / (w - b)).clamp(0.0, 1.0) }; + (t * 255.0).round() as u8 + }; + let c = [channel(0), channel(1), channel(2)]; + self.colors.insert(pen, c); + } + _ => {} + } + } + b"CR" => { + let n = lex.numbers(&mut v); + if n == 0 { + self.ranges = [(0.0, 255.0); 3]; + } else if n >= 6 { + self.ranges = [(v[0], v[1]), (v[2], v[3]), (v[4], v[5])]; + } + } + _ => {} + } + } + + /// Line types and their patterns. + fn line_type_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"LT" => { + let n = lex.numbers(&mut v); + self.flush(); + if n == 0 { + if self.line_type.is_some() { + self.saved_line_type = self.line_type; + } + self.line_type = None; + return; + } + let k = v[0].round().clamp(-100.0, 100.0) as i32; + if k == 99 { + if self.line_type.is_none() { + self.line_type = self.saved_line_type; + } + return; + } + if !(-8..=8).contains(&k) { + return; + } + // A pattern length of 0 or less, or a mode other than 0 and 1, makes the instruction ignored. + if (n >= 2 && !(v[1] > 0.0 && v[1] <= 32767.0)) || (n >= 3 && !matches!(v[2].round() as i32, 0 | 1)) { + return; + } + self.line_type = Some(k); + if n >= 2 { + self.pattern = v[1]; + } + if n >= 3 { + self.pattern_mm = v[2].round() == 1.0; + } + } + b"UL" => { + let mut gaps = [0.0f64; 21]; + let n = lex.numbers(&mut gaps); + self.flush(); + let index = gaps[0].round().abs(); + if n == 0 { + self.user_types = Default::default(); + } else if (1.0..=8.0).contains(&index) { + let slot = index as usize - 1; + let values = &gaps[1..n]; + let sum: f64 = values.iter().sum(); + self.user_types[slot] = if n == 1 { + None + } else if values.iter().all(|g| *g >= 0.0) && sum > 0.0 { + Some(values.iter().map(|g| g / sum * 100.0).collect()) + } else { + return; + }; + } + } + _ => {} + } + } + + /// Line ends and joins, the fill type and scaling. + fn attribute_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"LA" => { + let n = lex.numbers(&mut v[..6]); + self.flush(); + if n == 0 { + (self.cap, self.join, self.miter) = (LineCap::Butt, LineJoin::Miter, 5.0); + } + for pair in v[..n - n % 2].chunks(2) { + let value = pair[1].round() as i32; + match (pair[0].round() as i32, value) { + (1, 1) => self.cap = LineCap::Butt, + (1, 2) => self.cap = LineCap::Square, + (1, 3 | 4) => self.cap = LineCap::Round, + (2, 1 | 2) => self.join = LineJoin::Miter, + (2, 4) => self.join = LineJoin::Round, + (2, 3 | 5 | 6) => self.join = LineJoin::Bevel, + (3, _) => self.miter = pair[1].clamp(1.0, 32767.0), + _ => {} + } + } + } + b"FT" => { + let n = lex.numbers(&mut v[..3]); + let kind = if n == 0 { 1 } else { v[0].round() as i32 }; + self.fill = match kind { + 1 | 2 => FillKind::Solid, + 10 => FillKind::Shade(if n >= 2 { v[1] } else { 100.0 }), + 3 | 4 | 11 | 21 | 22 => FillKind::Pattern, + _ => self.fill, + }; + } + b"SC" => { + let n = lex.numbers(&mut v[..7]); + self.flush(); + let kind = if n >= 5 { v[4].round() as i32 } else { 0 }; + self.scaling = match (n, kind) { + (0, _) => None, + (5, 2) if v[1] != 0.0 && v[3] != 0.0 => Some(Scaling::Factor { x0: v[0], fx: v[1], y0: v[2], fy: v[3] }), + (4..=7, 0 | 1) if v[0] != v[1] && v[2] != v[3] => { + let iso = (kind == 1).then(|| if n >= 7 { (v[5].clamp(0.0, 100.0), v[6].clamp(0.0, 100.0)) } else { (50.0, 50.0) }); + Some(Scaling::Fit { x0: v[0], x1: v[1], y0: v[2], y1: v[3], iso }) + } + _ => return, + }; + self.update_user(); + } + _ => {} + } + } + + /// The scaling points, the rotation and circles. + fn frame_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"IP" | b"IR" => { + let n = lex.numbers(&mut v[..4]); + self.flush(); + // IR's percentages and the defaults are of the hard-clip limits in the current + // orientation. Scaling points past the art's range are held at its edge, so lengths + // taken from them stay finite. + let clip = self.hard_clip(); + let at = |x: f64, y: f64| { + let p = if op[1] == b'R' { Point::new(x / 100.0 * clip.x, y / 100.0 * clip.y) } else { Point::new(x, y) }; + Point::new(p.x.clamp(-LIMIT, LIMIT), p.y.clamp(-LIMIT, LIMIT)) + }; + match n { + 0 => (self.p1, self.p2) = (Point::ZERO, clip), + 2 | 3 => { + let p1 = at(v[0], v[1]); + (self.p1, self.p2) = (p1, p1 + (self.p2 - self.p1)); + } + 4 => { + let (p1, mut p2) = (at(v[0], v[1]), at(v[2], v[3])); + // A coordinate of P2 equal to P1's is one plotter unit more. + if p2.x == p1.x { + p2.x += 1.0; + } + if p2.y == p1.y { + p2.y += 1.0; + } + (self.p1, self.p2) = (p1, p2); + } + _ => {} + } + self.update_user(); + } + b"RO" => { + let n = lex.numbers(&mut v[..1]); + self.flush(); + let angle = if n == 0 { 0.0 } else { v[0].round().rem_euclid(360.0) }; + if [0.0, 90.0, 180.0, 270.0].contains(&angle) { + let ro = angle as u16; + let rot = Self::rotation(ro); + // The pen keeps its place on the page, its coordinates changing with the axes; + // P1 and P2 keep their coordinates, and the polygon buffer turns with the axes. + let turn = rot * self.rot.inverse(); + self.pos = rot.inverse() * (self.rot * self.pos); + if let Some(b) = &mut self.buffer { + b.fill.apply_affine(turn); + b.edge.apply_affine(turn); + } + (self.rot, self.ro) = (rot, ro); + } + } + b"CI" => { + let n = lex.numbers(&mut v[..2]); + if n == 0 { + return; + } + let path = self.circle(v[0]); + // In polygon mode the circle is a subpolygon of its own; the pen stays at its + // center. + if self.poly.is_some() { + if let Some(path) = path + && self.spend(path.elements().len()) + && let Some(poly) = &mut self.poly + { + poly.closed(&path); + } + return; + } + self.flush(); + // Line type 0 draws a dot at the center. + if self.line_type == Some(0) { + return self.dot(self.dev(self.pos)); + } + let Some(path) = path else { return self.dot(self.dev(self.pos)) }; + if self.spend(path.elements().len()) + && let Some(stroke) = self.stroke() + { + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + } + _ => {} + } + } + + /// Arcs and Bézier curves from the pen. + fn curve_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"AA" | b"AR" => { + if lex.numbers(&mut v[..4]) < 3 { + return; + } + let center = if op[1] == b'R' { self.pos_user() + Vec2::new(v[0], v[1]) } else { Point::new(v[0], v[1]) }; + self.arc_from_pen(center, v[2]); + } + b"AT" | b"RT" => { + if lex.numbers(&mut v[..5]) < 4 { + return; + } + let a = self.pos_user(); + let off = if op[0] == b'R' { a.to_vec2() } else { Vec2::ZERO }; + let (b, c) = (Point::new(v[0], v[1]) + off, Point::new(v[2], v[3]) + off); + self.three_point_arc(a, b, c); + } + b"BZ" | b"BR" => { + while let Some(x) = lex.number() { + let mut p = [x, 0.0, 0.0, 0.0, 0.0, 0.0]; + for slot in &mut p[1..] { + let Some(n) = lex.number() else { return }; + *slot = n; + } + let a = self.pos_user(); + let off = if op[1] == b'R' { a.to_vec2() } else { Vec2::ZERO }; + let [c1, c2, end] = [0, 2, 4].map(|i| Point::new(p[i], p[i + 1]) + off); + let m = self.user_dev(); + let (from, down) = (self.dev(self.pos), self.down); + self.trace(from, &[Piece::Curve(m * c1, m * c2, m * end)], down); + self.pos = self.user * end; + } + } + _ => {} + } + } + + /// Rectangles, wedges, polygon mode and encoded polylines. + fn area_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"EA" | b"ER" | b"RA" | b"RR" => { + if lex.numbers(&mut v[..2]) < 2 { + return; + } + let corner = if op[1] == b'R' { self.pos + self.delta(v[0], v[1]) } else { self.to_plu(v[0], v[1]) }; + let path = self.rect(corner); + self.buffer_and_draw(Some(path), op[0] == b'R'); + } + b"EW" | b"WG" => { + if lex.numbers(&mut v[..4]) < 3 { + return; + } + let path = self.wedge(v[0], v[1], v[2]); + self.buffer_and_draw(path, op[0] == b'W'); + } + b"PM" => { + let n = lex.numbers(&mut v[..1]); + self.flush(); + // The point that closes a subpolygon moves the pen there: PM1 closes it with the + // pen up, PM2 with the pen as it is. + let mut closure = None; + match if n == 0 { 0 } else { v[0].round() as i32 } { + 0 if self.poly.is_none() => { + let at = self.dev(self.pos); + self.poly = Some(Poly { start: at, last: at, ..Poly::default() }); + self.buffer = None; + } + 1 => { + if let Some(p) = &mut self.poly { + closure = p.close(false); + p.fresh = true; + } + } + 2 => { + if let Some(mut p) = self.poly.take() { + closure = p.close(self.down); + self.buffer = Some(p); + } + } + _ => {} + } + if let Some(at) = closure { + self.pos = self.rot.inverse() * at; + } + } + b"FP" => { + let n = lex.numbers(&mut v[..1]); + self.fill_polygon(if n > 0 && v[0].round() == 1.0 { FillRule::NonZero } else { FillRule::EvenOdd }); + } + b"EP" => { + lex.params(); + self.edge_polygon(); + } + b"PE" => self.polyline_encoded(lex), + _ => {} + } + } + + /// Labels, comments, pages, the input window, and instructions that draw nothing. + fn other_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 4]; + match &op { + b"LB" | b"BL" | b"WD" => { + lex.label(self.terminator); + self.warn(LABELS); + } + b"DT" => { + self.terminator = lex.char().unwrap_or(ETX); + lex.params(); + } + b"SM" => { + // The symbol character is read so it isn't taken for an instruction; symbols + // aren't drawn. + if lex.char().is_some() { + self.warn(SYMBOLS); + } + } + b"CO" => { + lex.quoted(); + } + b"PG" | b"FR" => { + lex.params(); + self.flush(); + self.page_end.get_or_insert(self.shapes.len()); + // The next page starts with the pen up at the origin (FR also empties the + // polygon buffer). + (self.pos, self.down) = (Point::ZERO, false); + if op[0] == b'F' { + self.buffer = None; + } + } + b"IW" => { + if lex.numbers(&mut v) == 4 { + self.warn(WINDOW); + } + } + _ if super::IGNORED.contains(&op) => lex.params(), + _ => { + lex.params(); + self.unknown += 1; + } + } + } + + /// The coordinate pairs of PU, PD, PA and PR, plotted in the current mode. + fn points(&mut self, lex: &mut Lexer) { + while let Some(x) = lex.number() { + let Some(y) = lex.number() else { return }; + let target = if self.relative { self.pos + self.delta(x, y) } else { self.to_plu(x, y) }; + self.line_to(target); + if self.full { + return; + } + } + } + + /// AT and RT: the arc from `a` through `b` to `c` (user units). + fn three_point_arc(&mut self, a: Point, b: Point, c: Point) { + let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + let center = if a == c { + // Back to the start: the circle whose diameter ends at the start and the middle point. + Some(a.midpoint(b)) + } else if d.abs() > 1e-12 * (b - a).hypot2().max((c - a).hypot2()).max(1.0) { + let (a2, b2, c2) = (a.to_vec2().hypot2(), b.to_vec2().hypot2(), c.to_vec2().hypot2()); + Some(Point::new( + (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d, + (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d, + )) + } else { + None + }; + let Some(center) = center.filter(|p| p.x.is_finite() && p.y.is_finite()) else { + // Points in a line: a line to the end. + return self.line_to(self.user * c); + }; + let tau = std::f64::consts::TAU; + let angle = |p: Point| (p - center).atan2(); + let sweep = if a == c { + tau + } else { + let (s, t) = ((angle(c) - angle(a)).rem_euclid(tau), (angle(b) - angle(a)).rem_euclid(tau)); + if t < s { s } else { s - tau } + }; + self.arc_from_pen(center, sweep.to_degrees()); + if a != c { + self.pos = self.user * c; + } + } + + /// PE: encoded pen moves, pen selections and rectangles. + fn polyline_encoded(&mut self, lex: &mut Lexer) { + let mut st = Pe::default(); + let (mut rect, mut corner) = (false, false); + while let Some(item) = lex.pe(&mut st) { + if self.full { + continue; + } + match item { + PeItem::Pen(n) => { + if self.poly.is_none() { + self.flush(); + self.pen = Some(n.clamp(-1, 1_000_000_000)); + } + } + PeItem::Rect => (rect, corner) = (true, false), + PeItem::Point { x, y, up, absolute } => { + if up { + rect = false; + } + let target = if absolute { self.to_plu(x, y) } else { self.pos + self.delta(x, y) }; + if rect && corner { + // The second pair of a rectangle: its opposite corner, relative. It is filled + // as RR fills one, so polygon mode, which doesn't allow RR, ignores it. + let path = self.rect(self.pos + self.delta(x, y)); + self.buffer_and_draw(Some(path), true); + corner = false; + } else if rect { + self.down = false; + self.line_to(target); + corner = true; + } else { + self.down = !up; + self.line_to(target); + } + } + } + } + if rect { + self.down = false; + } + } + + /// The plot's end: the last line drawn, and notes on what wasn't read. + pub fn finish(mut self, raster: bool) -> Self { + self.flush(); + if let Some(n) = self.page_end + && self.shapes.len() > n + { + self.warn(PAGES); + } + if raster { + self.warn(RASTER); + } + if self.unknown > 0 { + let n = self.unknown; + self.warnings.push(format!( + "{n} instruction{} of kinds VectorCraft doesn't read {} skipped", + if n == 1 { "" } else { "s" }, + if n == 1 { "was" } else { "were" } + )); + } + self + } +} diff --git a/crates/cad/src/hpgl/tests.rs b/crates/cad/src/hpgl/tests.rs new file mode 100644 index 000000000..d3be0c87e --- /dev/null +++ b/crates/cad/src/hpgl/tests.rs @@ -0,0 +1,612 @@ +//! 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 { + 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 { + 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 { + 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 { + 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 = 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 = 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 = 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")); +} + +#[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::() - 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![ + // 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::>()].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); + } +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 603521160..525e9b673 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -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; diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 04a443ed4..de575411a 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -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…). @@ -204,6 +204,8 @@ mod tests_eps; #[cfg(test)] mod tests_dxfimport; #[cfg(test)] +mod tests_hpgl; +#[cfg(test)] mod tests_metafile; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 81e8168d0..9e98c9781 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 23aeb83bb..c340585c9 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -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", @@ -551,6 +553,10 @@ pub const OPEN_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "plt", + "hpgl", + "hgl", + "hpg", "eps", "epsf", "ps", @@ -564,9 +570,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 @@ -640,6 +647,10 @@ pub const PLACE_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "plt", + "hpgl", + "hgl", + "hpg", "eps", "epsf", "ps", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 58fff3cad..f3f0295cb 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -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 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; diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs new file mode 100644 index 000000000..d453d9178 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -0,0 +1,94 @@ +//! 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}"); +} + +#[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))); +} diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 1ded346d8..3d4c7d5ef 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -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 @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; 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 ), @@ -261,7 +261,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result 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 { + 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::()), 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::(), 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 diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c..4216ca42d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4352,6 +4352,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 първо завършете редактирането на мострата diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95cea..558eb2ce0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4743,6 +4743,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 diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e0..8f480c071 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5576,6 +5576,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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fa..60d3d2f1b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4825,6 +4825,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 diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6b..fb8272772 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4453,6 +4453,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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81..fff265b0d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4434,6 +4434,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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be..4b2825f67 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4472,6 +4472,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 ολοκληρώστε πρώτα την επεξεργασία του μοτίβου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae3..1bcdd1d8f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4517,6 +4517,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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f60..5edf1c9ae 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4733,6 +4733,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b..561d06ce3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4506,6 +4506,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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779..697247e73 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7048,6 +7048,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 देखें) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339b..21b51c8ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5202,6 +5202,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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97..b7a26ad2e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4453,6 +4453,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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b5..28c0975ed 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4560,6 +4560,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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcad..c42a696af 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4631,6 +4631,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 破損しています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c42..be66dfde5 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4277,6 +4277,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 ჯერ დაასრულეთ პატერნის რედაქტირება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2..48a83f341 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4558,6 +4558,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 손상됨 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc..76ef5f669 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4465,6 +4465,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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4..294d2fc9c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4650,6 +4650,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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda..d7725fc19 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4060,6 +4060,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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb542582..8725ae84d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4554,6 +4554,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 сначала завершите редактирование узора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de..8a393f2aa 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4421,6 +4421,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 diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc..69cad6417 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4275,6 +4275,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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b..0bf186a8c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4544,6 +4544,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 спочатку завершіть редагування візерунка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a6..20650c4b1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4857,6 +4857,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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe..8ef6c2521 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4344,6 +4344,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 已损坏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e770..a203b191e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2162,6 +2162,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 已損毀 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d42e770df..14d725b30 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 ``, 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 (merged image, every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c..46b17ff36 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -490,7 +490,7 @@ opens the picker through `run_command` with the remote backend. | `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 Photoshop file as its merged image; 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 Photoshop file as its merged image; 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. | @@ -6049,6 +6049,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 diff --git a/docs/releasing.md b/docs/releasing.md index 64c177618..39e408924 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -164,7 +164,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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f74..fcac50e91 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -687,7 +687,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`. @@ -1466,6 +1466,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`). From 64988aaa49612e2509a0280579372c9235d4f843 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:27:49 -0800 Subject: [PATCH 2/3] HP-GL: a .plt file opens when its first instruction is SI, DI or another known one A file read as HP-GL by its extension no longer has to start with an instruction plot files usually start with. Any instruction the reader knows, or one of another set that a semicolon ends, may come first; the rest of the check still refuses files that are not HP-GL. Telling HP-GL apart by content alone keeps the stricter check. --- crates/cad/src/hpgl.rs | 14 +++++++++++--- crates/cad/src/hpgl/tests.rs | 13 +++++++++++++ crates/engine/src/cmd/fileio/tests_hpgl.rs | 10 ++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/cad/src/hpgl.rs b/crates/cad/src/hpgl.rs index 5b47aa445..de2e6480b 100644 --- a/crates/cad/src/hpgl.rs +++ b/crates/cad/src/hpgl.rs @@ -66,6 +66,13 @@ const FIRST: &[[u8; 2]] = &[ /// 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`). +fn holds_instructions(bytes: &[u8], strict: bool) -> bool { let mut lex = Lexer::new(bytes); let mut terminator = lex::ETX; let mut first = 0; @@ -77,7 +84,7 @@ pub fn is_hpgl(bytes: &[u8]) -> bool { if n == 0 { first = lex.pos(); } - if n == 0 && !FIRST.contains(&op) { + if strict && n == 0 && !FIRST.contains(&op) { return false; } let known = PLAYED.contains(&op) || IGNORED.contains(&op); @@ -110,9 +117,10 @@ pub fn is_hpgl(bytes: &[u8]) -> bool { true } -/// Read an HP-GL file. +/// 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. pub fn import(bytes: &[u8]) -> Result<Imported, String> { - if !is_hpgl(bytes) { + if !holds_instructions(bytes, false) { return Err("not an HP-GL file".into()); } let mut lex = Lexer::new(bytes); diff --git a/crates/cad/src/hpgl/tests.rs b/crates/cad/src/hpgl/tests.rs index d3be0c87e..873e2d8fb 100644 --- a/crates/cad/src/hpgl/tests.rs +++ b/crates/cad/src/hpgl/tests.rs @@ -440,6 +440,19 @@ fn only_plot_files_are_taken_for_hpgl() { 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)); + } +} + #[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. diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index d453d9178..271f7b86e 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -79,6 +79,16 @@ fn files_that_are_not_hpgl_are_refused() { 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"]; From 925300c15993685471dc80c43797fd7df18c4f61 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:05:05 -0800 Subject: [PATCH 3/3] HP-GL: a .plt file needs at least one instruction the reader knows A file read as HP-GL by its extension that holds only instructions of another set (`GC5;`) is now refused as "not an HP-GL file" instead of opening as an empty plot. At least one of the instructions the check reads must be one the reader knows. --- crates/cad/src/hpgl.rs | 14 +++++++++----- crates/cad/src/hpgl/tests.rs | 5 +++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/cad/src/hpgl.rs b/crates/cad/src/hpgl.rs index de2e6480b..5016e301f 100644 --- a/crates/cad/src/hpgl.rs +++ b/crates/cad/src/hpgl.rs @@ -71,16 +71,18 @@ pub fn is_hpgl(bytes: &[u8]) -> bool { /// 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`). +/// 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 true; + return any_known; } - let Some(op) = lex.op(true) else { return n > 0 && lex.done() }; + let Some(op) = lex.op(true) else { return n > 0 && any_known && lex.done() }; if n == 0 { first = lex.pos(); } @@ -88,6 +90,7 @@ fn holds_instructions(bytes: &[u8], strict: bool) -> bool { return false; } let known = PLAYED.contains(&op) || IGNORED.contains(&op); + any_known |= known; match &op { b"CO" => { if !lex.comment() { @@ -114,11 +117,12 @@ fn holds_instructions(bytes: &[u8], strict: bool) -> bool { } } } - true + 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. +/// 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()); diff --git a/crates/cad/src/hpgl/tests.rs b/crates/cad/src/hpgl/tests.rs index 873e2d8fb..9a350707a 100644 --- a/crates/cad/src/hpgl/tests.rs +++ b/crates/cad/src/hpgl/tests.rs @@ -451,6 +451,11 @@ fn a_plot_file_may_start_with_any_instruction() { 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]