I built Graph Paper Online, a free tool that generates printable graph, dot, isometric, lined and other paper. The part that turned out to be interesting wasn't drawing a grid. It was making the grid come out of a printer at the size it claims to be.
If you have ever printed a "5 mm" grid and measured it with a ruler, you know why this matters.
The rule: one geometry, three outputs
The site needs three things to agree with each other:
- a preview in the page,
- what the browser prints,
- the downloadable PDF (plus PNG and SVG).
If each had its own drawing code, they would drift apart. So there is exactly one place that decides where every line goes, and the three outputs only translate its result.
The engine is plain TypeScript with no DOM dependency, and everything is in millimetres. A paper spec goes in, a flat description of the sheet comes out:
// Simplified from the project's types
interface Sheet {
width: number; // mm
height: number; // mm
lines: { x1: number; y1: number; x2: number; y2: number; w: number; color: string }[];
circles: { cx: number; cy: number; r: number; w: number; color: string }[];
dots: { x: number; y: number; r: number; color: string }[];
labels: { x: number; y: number; size: number; text: string; anchor: 'start' | 'middle' | 'end'; color: string }[];
}
const sheet = buildSheet(spec); // spec: paper kind, page size, spacing in mm, margins, ...
Grid, dot, isometric, coordinate, polar, lined and staff paper are all just different ways of producing lines, dots, circles and labels. Because the engine only returns numbers, it is easy to unit test. The tests check the geometry against expected positions with a tolerance of 0.1 mm.
SVG: let the viewBox be millimetres
For the preview and for SVG download, the sheet is rendered as an SVG whose viewBox is in millimetres:
<svg viewBox="0 0 210 297" width="210mm" height="297mm" ...>
There are two modes. For the on-screen preview the width and height are 100%, so the container decides the size and the drawing scales. For printing and export the width and height are written as physical mm values, so the same drawing has a defined physical size.
A small detail that keeps the DOM light: lines with the same stroke width and colour are merged into a single <path>, instead of emitting one <line> element per grid line.
PDF: page size equals paper size
For the PDF, I use pdf-lib. The two things that matter:
- The page size is the paper size converted to points (1 pt = 1/72 inch, so
pt = mm * 72 / 25.4). A4 becomes a page that is exactly A4, not "a bit larger with scaling". - PDF's origin is the bottom-left, while the engine's origin is top-left, so every y coordinate is flipped.
const PT_PER_MM = 72 / 25.4;
const pt = (mm: number) => mm * PT_PER_MM;
const page = doc.addPage([pt(sheet.width), pt(sheet.height)]);
for (const l of sheet.lines) {
page.drawLine({
start: { x: pt(l.x1), y: H - pt(l.y1) },
end: { x: pt(l.x2), y: H - pt(l.y2) },
thickness: l.w,
color: hexToRgb(l.color),
});
}
Everything is vector, so the file stays small and sharp, and multi-page PDFs just repeat the same page drawing. The tool caps a PDF at 50 pages. pdf-lib is loaded on demand, so visitors who only preview or print never download it.
The part code can't fix: the print dialog
Even with exact geometry, the final size depends on the print dialog. If it is set to "Fit to page", the printer may scale the sheet down to fit its printable area, and your 5 mm grid is now 4.8 mm.
I can't force that from a web page, so the tool does two things instead:
- it tells you to print at 100% / Actual size, and
- it offers an optional size-check ruler at the bottom of the sheet (a 1 inch and a 1 cm mark), so you can measure the printed page with a real ruler.
That ruler ended up being one of the most useful features, because it turns "my printout looks off" into something you can actually verify. I wrote the longer version of this advice as a guide: how to print graph paper at actual size.
The rest of the stack
- Astro with static output. Pages are pre-rendered; the interactive tool is a Preact island, and it is the only part that loads JavaScript of its own.
- Plain CSS, no framework. There wasn't enough UI to justify one.
-
State in the URL hash (
#s=6.35&p=a4...). Settings are shareable and work with the back button, without creating new indexable URLs. - Cloudflare Workers static assets for hosting, deployed by GitHub Actions on push.
- Share images and the paper thumbnails are generated at build time (satori and resvg for the Open Graph images), so there is nothing to maintain by hand.
Takeaways
- Keep one source of truth for geometry and make every output a thin translation of it.
- Decide on units on day one. Everything in millimetres, convert only at the edges, never in the middle.
- Test the geometry as numbers. Pixels are hard to assert on; coordinates are not.
Try it
It's free and doesn't need an account: graphpaperonline.com. I'm the maker, and I'd like to hear from anyone who prints a lot of paper: which layout or control is missing? And if you have seen better ways to handle print scaling from a web page, I'm interested.



Top comments (0)