diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index b38abd005c..3e20c69a98 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -15,6 +15,8 @@ //! Coordinates are plotter units (1016 to the inch, y up) from the region's bottom-left corner or //! its center, rounded to whole units. Art past ±8,388,607 units (2²³ − 1, the smallest range //! the HP-GL/2 reference asks a device to take for encoded polylines) from the origin is cut off. +//! An HP-GL plot with a coordinate outside −32,768 to 32,767 (16-bit numbers, the range older +//! devices read) gets a warning; HP-GL/2's encoded polylines take the wider range. //! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with //! `sort`, each next path the one that starts nearest where the pen stopped. With `inside_first`, //! a pen draws a path only after every path of its own that lies inside it (a letter's counter @@ -43,6 +45,8 @@ use clip::Region; const PLU_PER_PT: f64 = 1016.0 / 72.0; /// The largest coordinate written, plotter units. pub const MAX_COORD: i64 = (1 << 23) - 1; +/// The coordinates an HP-GL plot keeps to without a warning: 16-bit numbers, plotter units. +const CLASSIC_RANGE: std::ops::RangeInclusive = -32_768..=32_767; /// The curve tolerance a path is flattened to by default, points. pub const DEFAULT_TOLERANCE: f64 = 0.1; /// The smallest and largest curve tolerance, points. @@ -72,6 +76,7 @@ const TOO_MANY_POINTS: &str = "the export is too large: the art past its first 1 const CLIP_WORK: &str = "clipping masks are too complex to apply in full: the art past that point is drawn whole"; const MASK_POINTS: &str = "clipping masks past 10 million points together are left out: the art they clip is drawn unclipped by them"; const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from the origin is cut off"; +const PAST_CLASSIC_RANGE: &str = "the plot has coordinates past 32,767 plotter units (about 82 cm) from the origin, which older HP-GL devices may not read: HP-GL/2 takes a wider range"; const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color"; const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order"; const UNSORTED_INSIDE: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order, each after the paths inside it"; @@ -144,9 +149,11 @@ impl Origin { #[derive(Clone, Debug)] pub struct HpglOptions { pub version: Version, - /// How far a flattened curve may lie from the curve, points ([`TOLERANCE_RANGE`]). + /// How far a flattened curve may lie from the curve, points ([`TOLERANCE_RANGE`]; a value + /// outside it is an error). pub tolerance: f64, - /// The pen table: pen 1's color first. Empty: a pen for each color the art draws in. + /// The pen table: pen 1's color first, up to [`MAX_PENS`] (more is an error). Empty: a pen for + /// each color the art draws in. pub pens: Vec<[u8; 3]>, pub origin: Origin, /// Draw each pen's paths nearest first, instead of in paint order. @@ -196,8 +203,13 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if regions.iter().any(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { return Err("the export region is empty".into()); } - let tolerance = - if opts.tolerance.is_finite() { opts.tolerance.clamp(*TOLERANCE_RANGE.start(), *TOLERANCE_RANGE.end()) } else { DEFAULT_TOLERANCE }; + if !TOLERANCE_RANGE.contains(&opts.tolerance) { + return Err("the curve tolerance must be from 0.01 to 10 points".into()); + } + if opts.pens.len() > MAX_PENS { + return Err("the pen table holds more than 255 pens".into()); + } + let tolerance = opts.tolerance; // Layers the plot leaves out go first: baking can wrap one in art that would be drawn (its // crop marks). let shown = plotted(&doc.layers, false).map(|layers| { @@ -274,6 +286,9 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); } } + if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { + warn(&mut warnings, PAST_CLASSIC_RANGE); + } let bytes = match opts.version { Version::Hpgl => plain(&by_pen), Version::Hpgl2 => encoded(&by_pen, &pens), diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 6360cebdaf..c29ef67532 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -441,6 +441,22 @@ fn an_empty_or_zero_length_plot_has_no_moves() { assert!(export(&d, &nan).is_err()); } +/// A tolerance outside its range and a pen table of more than 255 pens are errors, not changed. +#[test] +fn bad_options_are_errors() { + let d = three(); + for tolerance in [0.0, 0.009, 10.01, f64::NAN, f64::INFINITY] { + let o = HpglOptions { tolerance, ..opts(&d) }; + assert_eq!(export(&d, &o).err().as_deref(), Some("the curve tolerance must be from 0.01 to 10 points"), "{tolerance}"); + } + for tolerance in [0.01, 10.0] { + assert!(export(&d, &HpglOptions { tolerance, ..opts(&d) }).is_ok(), "{tolerance}"); + } + let o = HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS + 1], ..opts(&d) }; + assert_eq!(export(&d, &o).err().as_deref(), Some("the pen table holds more than 255 pens")); + assert!(export(&d, &HpglOptions { pens: vec![[0, 0, 0]; MAX_PENS], ..opts(&d) }).is_ok()); +} + #[test] fn art_past_the_plotter_range_is_cut_off() { let d = doc_with(|d| vec![stroked(d, line((0.0, 50.0), (1e9, 50.0)), Color::BLACK)]); @@ -454,6 +470,27 @@ fn art_past_the_plotter_range_is_cut_off() { assert_eq!(text(&out), "IN;PU;SP1;PU0,706;PD141,706;PU;SP0;"); } +/// HP-GL past 16-bit coordinates is written as it is, with a warning; HP-GL/2 gets none. +#[test] +fn hpgl_past_16_bit_coordinates_warns() { + let region = Rect::new(-2400.0, 0.0, 2400.0, 100.0); + // Past 32,767 from the bottom-left corner, within the range from the center. + let d = doc_with(|d| vec![stroked(d, line((-2000.0, 50.0), (2000.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region, ..HpglOptions::default() }); + assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]); + assert_eq!(text(&out), "IN;PU;SP1;PU5644,706;PD62089,706;PU;SP0;"); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(text(&out), "IN;PU;SP1;PU-28222,0;PD28222,0;PU;SP0;"); + // Past −32,768 and 32,767 from the center. + let d = doc_with(|d| vec![stroked(d, line((-2400.0, 50.0), (2400.0, 50.0)), Color::BLACK)]); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, ..HpglOptions::default() }); + assert_eq!(out.warnings, [PAST_CLASSIC_RANGE]); + assert_eq!(text(&out), "IN;PU;SP1;PU-33867,0;PD33867,0;PU;SP0;"); + let out = plot(&d, &HpglOptions { region, origin: Origin::Center, version: Version::Hpgl2, ..HpglOptions::default() }); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); +} + #[test] fn a_plot_stops_at_its_point_budget() { let d = doc_with(|d| (0..MAX_POINTS / 4).map(|i| stroked(d, rect(1.0, 1.0, 2.0 + (i % 50) as f64, 3.0), Color::BLACK)).collect()); diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs index 154c9bce1d..ff6e63fbd7 100644 --- a/crates/engine/src/cmd/fileio/hpgl.rs +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -22,7 +22,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ name: "hpglVersion", ty: "string", default: "\"hpgl\"", - description: "hpgl (IN, SP, PU and PD only) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)", + description: "hpgl (IN, SP, PU and PD only; a coordinate outside -32,768 to 32,767 gets a warning) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)", }, FormatOption { name: "curveTolerance", diff --git a/docs/mcp.md b/docs/mcp.md index 4dc867afbf..2e07170b62 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6425,10 +6425,12 @@ VectorCraft's own. `hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP;` and each path `PUx,y;PDx,y,…;`, and ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's -paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. `warnings` names +paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. An `"hpgl"` plot +with a coordinate outside −32,768 to 32,767 units (about 82 cm from the origin, the range of 16-bit numbers) is written +as it is, with a warning that older devices may not read it; this check is VectorCraft's own. `warnings` names type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke -looks that are not drawn, art past the plotter's range, colors past 255 pens, and the limits on points, clipping -work, symbols, live art, nesting depth and sorting. Sorting and finding which paths lie inside which share one +looks that are not drawn, art past the plotter's range, HP-GL coordinates past 16-bit numbers, colors past 255 pens, +and the limits on points, clipping work, symbols, live art, nesting depth and sorting. Sorting and finding which paths lie inside which share one work budget for the plot, 500 million box and point comparisons. When sorting runs out of it, the paths not yet drawn keep paint order, each after the paths inside it with `insideFirst`. When it runs out while the writer is finding which paths lie inside which, the paths of that pen and of the later pens keep paint order. Both cases add