A virtual HP 7475A pen plotter for Zig 0.16.
  • Zig 98.7%
  • Shell 0.9%
  • Nix 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie c7adf1748f
Benchmark the renderer, and cut over-long strokes
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
2026-09-03 01:25:02 -05:00
examples Declare out-of-band licensing in REUSE.toml 2026-09-02 22:09:01 -05:00
LICENSES Declare out-of-band licensing in REUSE.toml 2026-09-02 22:09:01 -05:00
src Benchmark the renderer, and cut over-long strokes 2026-09-03 01:25:02 -05:00
tools Test against a corpus of real HP-GL, fetched rather than vendored 2026-09-02 23:21:56 -05:00
.gitignore Test against a corpus of real HP-GL, fetched rather than vendored 2026-09-02 23:21:56 -05:00
build.zig Benchmark the renderer, and cut over-long strokes 2026-09-03 01:25:02 -05:00
build.zig.zon Add the MIT license text and finish REUSE compliance 2026-09-02 22:07:01 -05:00
flake.lock init repository 2026-09-02 20:56:43 -05:00
flake.nix Implement the HP 7475A plotter as a std.Io.Writer 2026-09-02 22:05:29 -05:00
README.md Benchmark the renderer, and cut over-long strokes 2026-09-03 01:25:02 -05:00
REUSE.toml Declare out-of-band licensing in REUSE.toml 2026-09-02 22:09:01 -05:00

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, and WU, which switches PW between millimetres and a percentage of the P1-P2 diagonal.
  • LO, which positions a label about a point.
  • IR, which is IP in percentages of the hard-clip limits.
  • RO180 and RO270. The machine takes only RO0 and RO90.
  • SC's fifth parameter, selecting isotropic scaling or a direct point factor. The machine's SC takes 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; set initial_pen to 0 for that behaviour.
  • LT dash 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: LT0 is 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.
  • FT fills solid for the solid fill types and rules real hatch lines for types 3 and 4. The shading fill types are not distinguished, and PT, which would set the pen thickness the shading assumes, is ignored.
  • Media. Options.media is what is loaded in the plotter. .plain is white paper, .transparency renders 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. Paper is a plain struct, so you can pass your own numbers instead.
  • RO deviates, in the direction of being more forgiving. On the hardware an RO90 instruction 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; and IW;, which plot files generally issue after RO, 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 was sweep / chord cast to a usize, 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, so clipLine passed 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 P2xP1x and P2yP1y, 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.