DEV Community

anders lee
anders lee

Posted on

Writing a PDF by hand: graph paper that prints at exactly the right size

I needed graph paper with 1/4 inch squares. Not "about a quarter inch": a floor plan at one square
per foot is only useful if four squares measure exactly one inch. Many of the free printables I found
were PNGs or PDFs wrapping a raster image, and the grid came out at 0.24 inch once printed.

Two things cause that. The image has a resolution that doesn't map cleanly to inches, and the PDF
page is the full sheet with the grid running to the edge, so the print dialog shrinks it into the
printable area. Both go away if the grid is generated as vector lines on a page that already
leaves a margin. A PDF that contains only lines turns out to be small enough to write by hand.

Points, not pixels

PDF coordinates are in points: 1/72 inch. That makes the units trivial:

const PT_PER_IN = 72;
const PT_PER_MM = 72 / 25.4;
// US Letter is 612 × 792 pt, A4 is 595.28 × 841.89 pt
Enter fullscreen mode Exit fullscreen mode

A 1/4 inch square is 18 pt, a 5 mm square is 14.17 pt. There is no DPI anywhere, so nothing gets
resampled.

Fit whole squares, then center them

The second rule: never cut a square at the margin. Take the area inside the margins, see how many
whole squares fit, and center that block:

function fitCells(area, s) {
  const nx = Math.max(1, Math.floor((area.x1 - area.x0) / s + 1e-6));
  const ny = Math.max(1, Math.floor((area.y1 - area.y0) / s + 1e-6));
  const x0 = (area.x0 + area.x1 - nx * s) / 2;
  const y0 = (area.y0 + area.y1 - ny * s) / 2;
  return { nx, ny, x0, y0 };
}
Enter fullscreen mode Exit fullscreen mode

The 1e-6 matters: 8 inches of space divided by 18 pt is exactly 32 squares, and without the
epsilon floating point can give 31.999999 and lose a column. With 1/4 inch margins a Letter page
holds 32 × 42 squares; A4 holds 31 × 44.

Drawing the grid is then two loops of lines, with every Nth line heavier (a bold line per inch on
quarter-inch paper).

The content stream

A PDF page's drawing is a text stream of postfix operators. For lines you need four of them:
w (line width), RG (stroke color), m / l (move / line to) and S (stroke). One wrinkle:
PDF's y axis points up, so if you lay the page out top-down like a screen, flip it when writing:

0.35 w 0.557 0.694 0.839 RG
36 774 m 36 18 l
54 774 m 54 18 l
...
S
Enter fullscreen mode Exit fullscreen mode

Grouping all lines of the same width and color under one w … RG … S keeps the stream short.
A full Letter page of 1/4 inch grid is about 76 lines.

Two tricks for the other paper types:

  • Dots are zero-length lines with round caps: set 1 J, then x y m x y l with the line width equal to the dot diameter. Far smaller than drawing circles.
  • Circles (for polar paper) are four cubic Béziers per circle, with control points at 0.5523 × r from each quadrant point—the usual approximation, invisible on paper.

The file around it

A minimal PDF is a handful of numbered objects, a cross-reference table giving the byte offset of
each object, and a trailer. This is the whole writer:

export function toPDF(content, w, h, pages = 1) {
  const objs = [];
  const add = (s) => objs.push(s);
  const pageIds = Array.from({ length: pages }, (_, i) => 6 + i);
  add(`<< /Type /Catalog /Pages 2 0 R >>`);                                                    // 1
  add(`<< /Type /Pages /Kids [${pageIds.map((i) => `${i} 0 R`).join(" ")}] /Count ${pages} >>`); // 2
  add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`);     // 3
  add(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`);                     // 4
  add(`<< /Title (Graph paper) /Producer (graphpaperpdf.net) >>`);                             // 5
  for (let i = 0; i < pages; i++)
    add(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] /Resources << /Font << /F1 3 0 R >> >> /Contents 4 0 R >>`);

  let out = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
  const offsets = [];
  objs.forEach((o, i) => { offsets.push(out.length); out += `${i + 1} 0 obj\n${o}\nendobj\n`; });
  const xref = out.length;
  out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
  for (const off of offsets) out += `${String(off).padStart(10, "0")} 00000 n \n`;
  out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R /Info 5 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
  return Uint8Array.from(out, (ch) => ch.charCodeAt(0) & 255);
}
Enter fullscreen mode Exit fullscreen mode

Things that bit me:

  • Offsets are bytes, not characters. Keep everything single-byte (the content is ASCII, the binary comment on line 2 is Latin-1) and string.length is the byte offset. One stray UTF-8 character and every offset after it is wrong.
  • Each xref line is exactly 20 bytes, including the space before \n. Viewers are forgiving; some printers' PDF interpreters are not.
  • /Length must match the stream. Same single-byte rule.

The nice part is /Contents 4 0 R on every page: 100 pages of graph paper share one content
stream. A one-page 1/4 inch grid is 2.3 KB; a hundred pages is 18.8 KB.

Text, and the π problem

Labels (axis numbers on a coordinate plane, degrees on polar paper) use the built-in Helvetica, so
no font embedding. That covers digits, minus and the degree sign (\260 in WinAnsi). Then I added
radian labels, and π isn't in that encoding.

The fix is old-school: the standard Symbol font has π at the position of the letter p. So a
label like 5π/6 becomes one text object that switches fonts mid-string:

BT 0.333 0.333 0.333 rg 300 410 Td
/F1 8 Tf (5) Tj /F2 8 Tf (p) Tj /F1 8 Tf (/6) Tj ET
Enter fullscreen mode Exit fullscreen mode

Text position advances after each Tj, so the pieces line up. Centering the label needs the
advance widths, which for a handful of glyphs is a small lookup table (digits are 556/1000 em in
Helvetica, π is 549 in Symbol).

Testing a file format

The tests that caught real bugs check structure rather than pixels: for every paper type, parse
startxref, confirm it points at xref, and confirm each offset in the table lands on
N 0 obj; check that /Length bytes later the stream says endstream. Geometry tests pin the
square counts (32 × 42 on Letter at 1/4 inch), so a refactor can't silently shave off a column.

Printing is the last mile

Even a perfect PDF prints small if the dialog is set to "Fit to page". Since the page already has a
1/4 inch margin—wider than the unprintable border of most home printers—"Actual size" / 100% is
safe. Measuring ten squares (2.5
inches on quarter-inch paper) is the quickest check.


I built this into a free generator at graphpaperpdf.net: square grids
in inches or millimetres, dot grid, isometric, polar, coordinate planes, lined and hexagon paper,
on Letter or A4. The PDF is generated in the browser, so nothing is uploaded. If you have a grid
type it doesn't do yet, I'd like to hear about it.

Top comments (0)