mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 20:08:42 +00:00
File formats: HP-GL (.plt) plot files open and place
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.
This commit is contained in:
@@ -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)]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -134,6 +134,17 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) {
|
||||
return format("svg");
|
||||
}
|
||||
// HP-GL has no signature: a file that starts with plotter instructions, named as one or with an
|
||||
// extension no other format (text, the formats VectorCraft names as unsupported, and the files
|
||||
// File › Open takes in other ways included) reads.
|
||||
let ext = super::extension(name);
|
||||
let other = by_name.is_some_and(|f| f.id != "hpgl")
|
||||
|| super::TEXT_EXTS.contains(&ext.as_str())
|
||||
|| super::unsupported(&ext).is_some()
|
||||
|| super::opens_as_other(&ext);
|
||||
if !other && vectorcraft_cad::hpgl::is_hpgl(bytes) {
|
||||
return format("hpgl");
|
||||
}
|
||||
by_name
|
||||
}
|
||||
|
||||
@@ -487,6 +498,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let (doc, warnings) = super::metafile::import(bytes)?;
|
||||
(doc, warnings, false)
|
||||
}
|
||||
"hpgl" => {
|
||||
let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?;
|
||||
(i.document, i.warnings, false)
|
||||
}
|
||||
"affinity" => {
|
||||
let i = super::affinity::import(&title, bytes)?;
|
||||
preview_only = i.preview_only;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -261,7 +261,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
|
||||
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
let symbols = doc.symbol_boxes();
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf" | "hpgl") {
|
||||
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
|
||||
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
|
||||
// default, as Illustrator places one, it is its art's bounds.
|
||||
|
||||
Reference in New Issue
Block a user