I build practice exams for Dutch certification exams. One of them is the Dutch boating licence, and many of its questions are pictures: a buoy, a traffic sign on a bridge, the lights of a boat at night seen from one side. The questions are written by a language model.
That's a risk you don't have with text. If a buoy is drawn with the wrong topmark, the question doesn't just look odd. It teaches the wrong rule, and the candidate learns it from us.
So no question in our content contains an image. Each one contains a small typed spec, and our own SVG components draw the picture from it. Here's how that works, and the one rule I care most about: a spec that doesn't parse renders as plain text, never as a picture we guessed.
Why not just use images
There were two obvious options, and our decision log lists both as rejected: free-form specs, and the official PNGs from the regulation.
Images from an image model were never an option. Our imagery rules say why: generated pictures invent details, like a wrong colour on a buoy or a wrong number of lights, and you can't cite them as a source.
The official PNGs would have been accurate. But a PNG is a fact you can't test. The rule ("a north cardinal mark has two black cones, both pointing up") lives in the pixels of each image, and in every question that uses one. With a spec, the rule lives in one place in code, and a test can check it. Copyright was never the issue: Dutch copyright law leaves laws and their annexes free (article 11 of the Auteurswet), so the drawings could have been used. It was a testing decision, not a legal one.
What a question stores
The specs are zod schemas. This is the whole spec for a buoy:
export const BuoySpec = z.object({ mark: BuoyMark, form: z.enum(["default", "spar"]).default("default"), topmark: z.boolean().default(true), /** Kenteken painted on the buoy, e.g. "HD 4". */ label: z.string().max(10).optional(), }); BuoyMark is an enum of 12 marks (lateral-right, cardinal-n, safe-water and so on). Notice what's missing: colour, shape and what the topmark looks like. The question says which mark it is, not what it looks like. All visual kinds sit in one discriminated union, and every kind requires alt text:
export const VisualSpecStrict = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("vessel-lights"), spec: VesselLightsSpec, alt: z.string().min(1) }), z.object({ kind: z.literal("buoy"), spec: BuoySpec, alt: z.string().min(1) }), // ...signs, day signals, sound signals, safety signs, chart tasks, chart symbols ]); In a question file, a buoy is just this:
"visual": { "kind": "buoy", "spec": { "mark": "cardinal-e", "form": "default" }, "alt": "Een zwart-geel-zwarte boei met twee zwarte kegels, de bases naar elkaar toe." } The rules live in the renderer
The buoy component has one table, with one row per mark, and each row comes from the annex of the inland shipping regulations:
const RULES: Record<BuoyMark, Rule> = { "lateral-right": { body: "can", bands: [{ color: PAINT.red, from: 0, to: 1 }], topmark: { type: "cylinder", color: PAINT.red } }, "cardinal-e": { body: "pillar", bands: [/* black, yellow, black */], topmark: { type: "two-cones", dir: "bases-together" } }, "safe-water": { body: "sphere", bands: "vertical-red-white", topmark: null }, // ... }; Record<BuoyMark, Rule> means TypeScript won't compile if a mark is added to the enum without a rule. And some rules can't be overridden by the spec at all. In Dutch inland waters, a safe-water mark has no topmark, whatever the question asks for:
// Safe water never shows a topmark in BPR waters (bijlage 8, 6.3), whatever the spec says. const topmark = spec.topmark ? rule.topmark : null; Navigation lights go one step further. A light only shines over a fixed arc: a masthead light forward, a red sidelight on the port side, a stern light aft. Which lights you see depends on where you're standing. So the spec lists all the lights a vessel carries, plus the view, and the code decides what's visible:
export function visibleFrom(role: LightRole, view: View) { switch (role) { case "masthead": case "masthead-aft": return view !== "stern"; case "sidelight-port": return view === "bow" || view === "port"; case "sidelight-starboard": return view === "bow" || view === "starboard"; case "stern": return view === "stern"; // ... } } One of our questions describes a small motor boat with a masthead light, two sidelights and a stern light, seen from straight behind, and asks which lights you see. The spec lists all four lights with "view": "stern". The model that wrote the question never decides which lights are visible. The renderer draws one white light, because that's what the arcs allow. The drawing and the answer key come from different places, so a wrong key can't come with a matching wrong picture.
Seen head-on, the red light is on your right, because the boat's port side faces you. That's the kind of detail that's easy to get wrong once and copy forever. Here it's one line in the renderer and one test.
A spec that doesn't parse is text
This is the component that every question visual goes through:
export function Visual({ visual }: { visual: unknown }) { const parsed = VisualSpecStrict.safeParse(visual); if (!parsed.success) { const alt = typeof visual === "object" && visual && "alt" in visual ? String((visual as { alt: unknown }).alt) : "Afbeelding"; return <div role="img" aria-label={alt} data-visual="invalid">{alt}</div>; } // switch on parsed.data.kind and render the right SVG } (Trimmed: the real one also has classes for a dashed box.)
It takes unknown on purpose: it trusts nothing it's given. If the spec is wrong, there's no fallback picture, no closest match, no default buoy. You get the alt text in a box. As the comment in the code puts it: a missing image is recoverable, a misleading one is not.
In practice that box should never appear, because there's a second layer. The draft files are loaded with the same schema (visual: VisualSpecStrict.nullable()), and a test parses every exam's drafts. A model that writes { "teken": "A.11" } instead of { "code": "A.11" } breaks CI before anything is imported. The fallback is for what slips past that.
Testing drawings without screenshots
These tests don't use screenshots. The renderers put small data attributes on the parts that carry meaning, and the tests read the markup:
it("cardinal topmarks point the right way", () => { const cones = (mark: string) => [...draw(mark).matchAll(/data-cone="(up|down)"/g)].map((m) => m[1]); expect(cones("cardinal-n")).toEqual(["up", "up"]); expect(cones("cardinal-e")).toEqual(["up", "down"]); // bases together expect(cones("cardinal-w")).toEqual(["down", "up"]); // points together }); tests/visuals.test.tsx has 149 of these: light arcs per view, every buoy mark as buoy and spar with and without topmark, every traffic sign code, and one that hands the dispatcher a broken sign spec and expects the alt text back. A separate file with 176 tests checks the opposite: that the markup does not contain the answer. A question that asks "what does this sign mean?" shouldn't have the sign code in an attribute where anyone can read it in the page source.
For looking rather than testing, there's a dev-only gallery at /dev/visuals that draws every kind and variant, each set of lights from four sides, at night and as a sketch. That's where a new drawing is compared with the official image before it goes live.
The numbers
The renderers cover 12 buoy marks, 8 light roles, 107 traffic sign codes and 49 workplace safety signs and hazard pictograms. In our Dutch exams' draft files, 170 of 3,532 question drafts carry a visual (including redrafts, so it's not the live count). All 170 parse.
What I'd tell someone else
- Store what something is, not what it looks like. "cardinal-e" can be tested. A colour list in each question can't.
- Put the rules that can't change in code the content can't override. The safe-water topmark is a good example.
- When the data is bad, show less, not something. A box with text is a visible bug. A plausible wrong picture is an invisible one.
- Test the meaning in the markup. A few data attributes make SVG easy to assert on.
The same components draw the reference pages on the site, like the safe water mark without its topmark, the east cardinal mark, and the lights of a small motor boat from every side (all in Dutch).
I wrote this post with help from an AI assistant. The code, tests and numbers come from our own repository.