- Zig 98.7%
- Shell 0.9%
- Nix 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Adds `zig build bench`, measuring the scanner, vectors, labels, circles, hatching, PNG encoding and the real sample corpus. The synthetic cases are shaped like the corpus, which took two attempts: a first version flung lines across the whole page and so measured long-line rasterization, and a second drew short segments but never lifted the pen, so all 200 000 of them accumulated into a single subpath. That second mistake was the useful one. Profiling puts about 80% of the time inside z2d rasterizing and tessellating, with the interpreter never above 1.2% and parsing running at around 300 MB/s. So the lever is how much work reaches the rasterizer, and stroking is super-linear in the size of a path: forty thousand points in one unbroken polyline cost seven times as much as the same points in short runs, and some CAD exports do emit polylines that long. Options.max_subpath_nodes cuts a run at 32 points, which recovers that and renders the corpus about a third faster — 110 ms to 80 ms. Plotter pens are round, so the round cap at each cut covers the round join it replaces and the line stays continuous. Not quite exactly, though: the two strokes overlap at the cut, so anti-aliased pixels there are painted twice and come out slightly darker, across the corpus four hundredths of one percent of all colour bytes. Setting the option to 0, or passing --exact, gives output byte-identical to never cutting, which is how the change was checked for being otherwise inert. Dashed lines are never cut, since a dash pattern carries along a whole vector. The opposite optimisation was tried first and is recorded in a comment so it is not tried again: batching many small subpaths into one path to save on stroke calls is much slower, for the same super-linear reason, and made labels two and a half times worse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012yN2S1mgwqpaGvs5GaKEa6 |
||
| examples | ||
| LICENSES | ||
| src | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
hp7475a
A virtual HP 7475A pen plotter for Zig 0.16.
Write HP-GL to a std.Io.Writer and get back a picture of what the pen would
have drawn. The instruction stream is interpreted the way HP's six-pen A/B-size
desktop plotter interprets it — pen selection, scaling points, soft clipping,
line types, arcs, and labels drawn with a built-in single-stroke font — and the
resulting pen strokes are rasterized with z2d
and written out as a PNG.
Using it as a library
const hp7475a = @import("hp7475a");
var buffer: [4096]u8 = undefined;
var plotter: hp7475a.Plotter = try .init(gpa, io, &buffer, .{ .dpi = 300 });
defer plotter.deinit();
const w = plotter.writer();
try w.writeAll("IN;SP1;SC0,100,0,70;");
try w.writeAll("PA10,10;PD90,10,90,60,10,60,10,10;PU;");
try w.writeAll("PA20,35;LBhello\x03");
try w.flush();
// `writePng` finishes the plot and hands you the bytes. Where they go is
// yours to decide: a file, a socket, an HTTP response, a buffer.
const file = try std.Io.Dir.cwd().createFile(io, "plot.png", .{});
defer file.close(io);
var out_buffer: [64 * 1024]u8 = undefined;
var out = file.writer(io, &out_buffer);
try plotter.writePng(&out.interface);
try out.interface.flush();
One plotter can draw many plots. clear() erases the page back to fresh
media and drops any stroke the pen had not finished, leaving the pen, the
position and every mode alone — it changes the paper rather than resetting the
machine, which is also why IN does not do it. A real plotter cannot unprint
what it has already drawn.
Because the plotter is just a writer, anything that can write can drive it:
std.fmt through writer.print, a std.Io.Reader piped in with
streamRemaining, or a serial port capture replayed byte for byte. Instructions
are free to straddle write boundaries; the scanner resumes where it left off.
Plotter must not be copied once it is in use — the writer finds its way back
with @fieldParentPtr — and buffer must outlive it. Pass &.{} for an
unbuffered plotter if you would rather not think about it.
Errors surface from the writer as error.WriteFailed, since that is all a
std.Io.Writer can say. plotter.check() returns what actually went wrong.
Add it to your build with:
zig fetch --save git+https://github.com/…/hp7475a
const hp7475a = b.dependency("hp7475a", .{
.target = target,
.optimize = optimize,
}).module("hp7475a");
Using it from the command line
$ zig build
$ ./zig-out/bin/hp7475a --dpi 150 -o plot.png examples/demo.hgl
plot.png: 70 instructions, 0 ignored, 0 unrecognized
With no file argument it reads standard input, and -o - writes the PNG to
standard output, so it drops into a pipeline at either end. --help lists the rest of the
options: paper size, media, initial pen, and pen width.
A note on PNG encoding
writePng uses this project's own encoder, in src/png.zig, rather than
z2d's. z2d can currently only export straight to a named file:
png_exporter.writeToPNGFile opens the file itself and the chunk writers
underneath it are private, so there is no way to ask it for the bytes.
The encoder is a deliberate stopgap — 8-bit RGBA, no scanline filtering, one
IDAT chunk, which suits line art on a flat background — and it is verified to
produce output pixel-identical to z2d's. If z2d grows a stream-based export,
delete src/png.zig and have writePng call that; nothing else depends on it.
Coordinates
Everything internal is kept in plotter units of 1/40 mm, the native resolution of the hardware. There are three coordinate spaces:
| Space | What it is |
|---|---|
| User units | What the HP-GL stream talks in once SC is active. Without SC, these are plotter units. |
| Logical plotter units | User units resolved through the P1/P2 scaling points. RO rotates this space relative to the paper. |
| Device pixels | The z2d surface, in paper orientation with y increasing downward. |
The image is sized to cover the paper's hard-clip limits at the requested DPI: ANSI A at 200 dpi comes out 2031 × 1560, which is the 10.15 × 7.80 inches the manual gives as that sheet's plotting range. Nothing outside the hard-clip limits can be drawn, exactly as nothing outside the pen's mechanical reach can be drawn on real paper.
Instruction set
The 7475A has 56 instructions (programming manual, pages 1-8 to 1-10). This library implements these:
| Group | Instructions |
|---|---|
| Configuration | IN DF IP SC IW RO |
| Pen and vectors | SP PU PD PA PR |
| Arcs and figures | CI AA AR EA ER RA RR |
| Line and fill attributes | LT FT SM TL XT YT |
| Labels | LB DT SI SR SL DI DR CP |
Parsed and discarded: PT; the O* status queries (OA, OI, OP, …) and
the digitizing instructions (DC, DP), since there is no back-channel to
answer on; VS and PS, since there is no carriage to slow down or paper to
size; and ESC . device control sequences, which configure the serial
handshake. Alternate character set selection (CS, CA, SS, SA) resolves
to the one built-in font. Plotter.stats counts what was executed, ignored,
and unrecognized, so a plot that comes out wrong can be checked against what
the plotter actually understood.
Not implemented, though the 7475A has them: EW and WG, the edge and
shade wedge instructions, and UC, the user-defined character instruction.
IM (error masks) is meaningless without a back-channel.
Some things are accepted that the 7475A does not have, because plot files in the wild lean on them, all of which arrived with later plotters and HP-GL/2:
PW, which overrides a pen's tip width for the rest of the plot, andWU, which switchesPWbetween millimetres and a percentage of the P1-P2 diagonal.LO, which positions a label about a point.IR, which isIPin percentages of the hard-clip limits.RO180andRO270. The machine takes onlyRO0andRO90.SC's fifth parameter, selecting isotropic scaling or a direct point factor. The machine'sSCtakes four parameters and scales each axis independently.
Anything unrecognized is skipped rather than treated as an error, which is roughly what a plotter does when it meets an instruction from a model it is not.
Pens
SP selects a carousel position, not a colour: what comes out depends on
which pens someone loaded. Options.pens is that carousel.
const pens = [_]hp7475a.Pen{
.{ .color = .{ 0, 0, 0 }, .width_mm = 0.3 }, // 1 black, fine
.{ .color = try hp7475a.Pen.parseColor("red"), .width_mm = 0.7 },
try hp7475a.Pen.parse("#1a2b3c:0.5", .{}), // 3 hex and width
};
var plotter: hp7475a.Plotter = try .init(gpa, io, &buffer, .{ .pens = &pens });
Colours are sRGB in 0..1, or parsed from a hex triple (#rrggbb, #rgb) or
one of the names in named_colors — the colours HP plotter pens were sold in.
Pen.parse leaves the width alone when a spec omits it, so recolouring a slot
never quietly resets a width set elsewhere.
Tip widths are physical millimetres and that is what sets the line width:
a 0.7 mm pen draws a line 0.7 × dpi / 25.4 pixels wide, so the same plot
rendered at twice the resolution has lines twice as many pixels thick and the
same thickness on paper. Pen width also sets the size of the dot left by
putting the pen down without moving it, and the thickness of hatch rules.
Fine pens go sub-pixel at low resolutions — 0.3 mm at 50 dpi is about six
tenths of a pixel — so min_pen_width_px (default 1.0) floors how thin a line
may render. Set it to 0 for widths that are strictly to scale.
The same thing from the command line, where --pen edits slots of the default
carousel and --pen-width sets every loaded pen at once:
$ hp7475a --pen 1:black:0.25 --pen 2:red --pen 3:'#1a2b3c':0.7 plot.hgl
$ hp7475a --pen-width 0.5 --initial-pen 2 plot.hgl
Fidelity notes
- Labels are drawn with a single-stroke vector font in the spirit of the plotter's own character generator, covering printable ASCII. It is not a reproduction of HP's glyph shapes.
- The initial pen defaults to pen 1. A real plotter powers up with an empty
holder and draws nothing until the stream says
SP; setinitial_pento 0 for that behaviour. LTdash patterns. The six patterns are figures in the manual rather than tabulated numbers, so the proportions used here come from HP-GL/2's definitions of the same six. Everything around them is sourced:LT0is dots at the plotted points, a negative type is solid, types 7 to 127 are ignored, and the default pattern length is 4% of the P1-P2 diagonal.FTfills solid for the solid fill types and rules real hatch lines for types 3 and 4. The shading fill types are not distinguished, andPT, which would set the pen thickness the shading assumes, is ignored.- Media.
Options.mediais what is loaded in the plotter..plainis white paper,.transparencyrenders overhead film — the plot on a transparent background, ready to composite — and.{ .paper = .{ r, g, b } }is tinted stock. On the command line that is--media paper|transparency. - Pens are a convention, not hardware — see below.
- Paper covers all four sizes the machine takes —
.a,.b,.a4,.a3— with its own hard-clip limits and default scaling points.Paperis a plain struct, so you can pass your own numbers instead. ROdeviates, in the direction of being more forgiving. On the hardware anRO90instruction leaves P1 and P2 at their current coordinate values, so they travel with the rotation and can land outside the hard-clip limits, and the input window rotates with them and ends up clipped to a square. Here P1 and P2 are carried to where they physically were and the window reopens to the whole page, so a rotated plot lands differently than on the machine and is not confined to a square.IP;andIW;, which plot files generally issue afterRO, settle the difference.IP;also derives the rotated default scaling points rather than using the machine's tabulated ones.
Tests
$ zig build test
The suite covers the scanner (instructions split across chunk boundaries,
omitted terminators, DT, device control sequences) and the renderer end to
end, asserting on the pixels: what is drawn, what is clipped away, what colour
and how thick it comes out, and that feeding the same plot one byte at a time
produces an identical image.
The sample corpus
Beyond the hand-written tests there is a corpus of real HP-GL — plot files produced by other people's tools rather than by this project's own test fixtures — from the plotr project.
Those files are not vendored here. That project publishes no licence, so there is no permission to redistribute them, and several of the drawings are of third-party characters. They are fetched for local testing instead:
$ zig build samples # downloads into samples/, which git ignores
$ zig build test
Without them the corpus tests skip, so a clone with no network still runs green.
The commit is pinned in tools/fetch-samples.sh so the corpus does not move
under the tests, which assert exact instruction counts per file — the whole
corpus parses with nothing unrecognized — along with roughly how much ink each
one lays down.
One of them is worth knowing about. text.hpgl writes labels like this:
PA10,300;
LBABC
;
There is no ETX anywhere in the file, and ETX is what ends a label. A real
7475A would treat everything after LBABC as more label text, and so does this
library: the file comes out as three instructions and one very long label.
Tools that end a label at a newline or a semicolon will render it differently.
That is a difference in the file, not a bug in either renderer.
Fuzzing
A plot file is untrusted input — it comes off a serial capture or the far end
of a pipe — and it drives an interpreter that drives a rasterizer. So there is
a generator in src/fuzz.zig that builds hostile HP-GL: random mnemonics and
parameter counts, numeric literals long enough to parse to an infinity, line
noise and ESC . sequences between instructions, delivered in random chunk
sizes. Allocation runs through a budgeted allocator, so an instruction that
turns twelve bytes into an unbounded request fails the test rather than the
machine.
zig build test renders a few hundred of these. For a deeper run:
$ zig build fuzz -Dfuzz-seeds=100000
The generator is also wired up as std.testing.Smith targets for coverage
guided fuzzing, but zig build test --fuzz does not work on Zig 0.16.0:
its own shipped lib/compiler/test_runner.zig passes a
*std.builtin.StackTrace where std.debug.writeStackTrace expects a
*const std.debug.StackTrace, and any project that calls std.testing.fuzz
hits it. The targets are there and should work once that is fixed; until then
the seeded sweep is what actually runs.
It has already paid for itself. Both of these crashed the plotter, and both are
now pinned by regression tests in src/test.zig:
AR0,0,1e300— the arc's step count wassweep / chordcast to ausize, which trapped on the conversion, and before that asked to allocate that many points. Arcs and hatch rulings are now bounded, so an absurd parameter degrades to coarser geometry instead.- A label under a huge
SI— the glyph coordinates became infinities and reached the rasterizer, which trapped converting them to pixels. The cause was general: every comparison against a NaN is false, soclipLinepassed non-finite endpoints through the clip untouched. It rejects them now, along with the few paths that bypass it.
Performance
$ zig build bench -Doptimize=ReleaseFast
case time count rate
scan 7.213 ms 2097065 bytes 290721838 bytes/s
vectors 397.744 ms 200000 vectors 502836 vectors/s
longpath 81.243 ms 40000 vectors 492348 vectors/s
labels 546.034 ms 2000 labels 3663 labels/s
circles 229.587 ms 2000 circles 8711 circles/s
hatch 72.663 ms 300 boxes 4129 boxes/s
png 55.574 ms 3168360 pixels 57011302 pixels/s
corpus 80.130 ms 658959 bytes 8223616 bytes/s
corpus is the one to trust — the seventeen real plot files, rendered at
200 dpi. The rest are synthetic and shaped to match them.
Profiling says about 80% of the time is inside z2d rasterizing and
tessellating, and the HP-GL interpreter does not appear above 1.2%. Parsing
runs at roughly 300 MB/s and is not worth optimizing. So the only lever that
matters is how much work reaches the rasterizer, and there is exactly one
place where a plot file can make that enormous for no good reason: stroking
costs grow super-linearly with the size of a path, so one unbroken polyline of
forty thousand points cost seven times as much as the same points in short
runs. Options.max_subpath_nodes cuts long runs at 32 points, which recovers
that and renders the real corpus about a third faster.
The cut is nearly invisible — plotter pens are round, so the round cap at each
cut covers the round join it replaces — but not exactly. The two strokes
overlap there, so anti-aliased pixels along the overlap are painted twice and
come out slightly darker: four hundredths of one percent of the colour bytes
across the corpus. Set max_subpath_nodes to 0, or pass --exact, for output
identical to never cutting at all. Dashed lines are never cut either way,
since a dash pattern carries along a whole vector.
The obvious-looking optimisation in the other direction — batching many small
subpaths into one path to save on stroke calls — was measured and is much
worse, for the same super-linear reason. Labels took two and a half times as
long.
Bibliography
Three manuals, all from Hewlett-Packard for the 7475A. Facts taken from them are cited by page in the code.
Interfacing and Programming Manual (07475-90001)
The language: what each instruction means.
| What | Page |
|---|---|
The complete 56-instruction set, and that a terminator is ; or the next mnemonic |
1-8 to 1-10 |
Power-on defaults: solid line type, 4% pattern length, SR 0.75%/1.5%, tick lengths 0.5%, ETX label terminator, 5-degree chord angle, fill type 1 at 1% spacing and 0 degrees, scaling off |
1-12, 1-13 |
PA/PR set a plotting mode that PU and PD follow, cancelled by PA, IN or DF |
3-8 |
LT0 is dots at the plotted points; a negative type is solid; 7 to 127 is ignored; pattern length is a percentage of the P1-P2 diagonal |
4-6, 4-7 |
| Circles are drawn with the current line type; arcs use the pen state already commanded | 3-12, 3-15 |
FT spacing is in user units when scaling is on, and a spacing of zero is ignored |
4-19 |
| The character space is 1½ times the character width and twice its height; the carriage-return point is the last point moved to | 5-12, 5-14 |
SI is in centimetres, and negative parameters mirror the label |
5-16 |
Reading this one corrected four things: PU and PD were ignoring the
plotting mode, negative line types were being treated as HP-GL/2's adaptive
patterns rather than as solid, out-of-range line types were resetting to solid
instead of being ignored, and fill spacing was read as plotter units even under
scaling. It also settled that EA, ER, RA, RR and FT are native
instructions rather than the later additions this README used to call them.
The one thing it could not settle is the shape of the six LT dash patterns,
which appear as a figure rather than a table; those proportions still come from
HP-GL/2's definitions of the same six.
Operation and Interconnection Manual (07475-90002)
The machine: its geometry, its media, its hardware.
| What | Page |
|---|---|
| Six-pen carousel | 2-1 |
| A plotter unit is 0.02488 mm; coordinates run −32768 to +32767 | 2-4 |
| Coordinate origin and axis orientation; hard-clip limits; the power-on input window is coincident with them; X and Y ranges swap under rotation | 2-5 |
| Maximum plotting ranges for A, B, A4 and A3 | 2-6 |
| Default P1/P2 scaling points for each paper size | 2-7 |
What RO90 does to P1, P2 and the input window, and the rotated default scaling points |
2-13 |
The plotter unit is worth singling out. HP-GL is usually quoted as using 0.025 mm units, 1016 to the inch, and this library used that until the manual said otherwise: it gives 0.02488 mm, and its own tables agree — ANSI A's 10365 units of width are printed as 257.8 mm, which is 10365 × 0.02488, not 10365 × 0.025. The round number renders every plot half a percent larger than the machine draws it.
Service Manual (07475-90000)
The hardware, and a second opinion on the other two.
| What | Page |
|---|---|
| Specifications: addressable step size, plotting areas, pen velocity, repeatability | 1-1 |
The instruction set again, grouped by function: SR is a percentage of P2x−P1x and P2y−P1y, SL is a tangent, AR's centre is relative, CI draws around the current position, RO takes 0 or 90 |
2-3, 2-4 |
Its table 1-1 rounds the step size to 0.025 mm but gives 0.00098 in. beside it, which is 1020 units to the inch and not 1016 — so it agrees with the 0.02488 mm above rather than with the conventional figure. Its plotting areas agree with the other manual's to within rounding, which is a useful independent check on the paper table.
It also corrected this bibliography: 07475-90000 is the service manual, not the operation manual, which is -90002.
One specification is worth quoting for perspective. Repeatability is 0.1 mm with the same pen and 0.2 mm with a different one — about four and eight plotter units. This library is exact where the machine was not.
License
License
License
License
MIT.