OcxlyDev · Tutorial

Build an image-to-PDF converter — no libraries, no server

A PDF is not magic. It is a plain-text object graph with a few binary streams bolted in. Once you see its skeleton, you can write one from scratch and embed a JPEG in a couple of dozen lines — which is exactly how our Image to PDF tool works, with no dependencies at all.

The plan

Three steps: normalise each image to a JPEG on a <canvas>, embed that JPEG straight into a PDF, and hand the bytes back as a download. The trick that makes this small is that JPEG data can live inside a PDF verbatim — the format understands it natively.

the pipeline
File → <img> → <canvas> → JPEG bytes → PDF object graph → Blob → download

Normalising an image on a canvas

Draw each image onto a canvas sized to its natural dimensions, then read it back as a JPEG with canvas.toBlob().1 Painting a white rectangle first flattens any transparency (PNG, WebP) so it does not turn black in a format that has no alpha channel.2

javascript
function toJpeg(img, quality) {
  const c = document.createElement("canvas");
  c.width = img.naturalWidth;
  c.height = img.naturalHeight;
  const ctx = c.getContext("2d");
  ctx.fillStyle = "#fff";               // flatten transparency
  ctx.fillRect(0, 0, c.width, c.height);
  ctx.drawImage(img, 0, 0);
  return new Promise(res =>
    c.toBlob(b => b.arrayBuffer().then(buf =>
      res(new Uint8Array(buf))), "image/jpeg", quality));
}

The anatomy of a minimal PDF

A PDF is a header, a set of numbered objects, a cross-reference table that lists each object's byte offset, and a trailer pointing at the root.3 The smallest useful document is a Catalog → a Pages tree → one or more Page objects.

pdf
%PDF-1.3
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 >> endobj
…
xref
0 6
0000000000 65535 f 
0000000015 00000 n 
…
trailer << /Size 6 /Root 1 0 R >>
startxref
67968
%%EOF

Embedding the JPEG with DCTDecode

An image is an XObject stream. Because our bytes are already JPEG, we tag the stream with the /DCTDecode filter — the PDF viewer's built-in JPEG decoder — and drop the raw bytes in unmodified.4

pdf
4 0 obj
<< /Type /XObject /Subtype /Image /Width 800 /Height 600
   /ColorSpace /DeviceRGB /BitsPerComponent 8
   /Filter /DCTDecode /Length 21987 >>
stream
… raw JPEG bytes …
endstream
endobj

Placing the image on the page

PDF measures everything in points (72 per inch) with the origin at the bottom-left.3 A page declares its size with /MediaBox, and a tiny content stream positions the image: the cm operator sets a transform matrix (scale x, 0, 0, scale y, translate x, translate y), then Do paints the XObject into that unit square.

pdf
5 0 obj << /Type /Page /Parent 2 0 R
   /MediaBox [0 0 595.28 841.89]        % A4 in points
   /Resources << /XObject << /Im0 4 0 R >> >>
   /Contents 6 0 R >> endobj

6 0 obj << /Length 40 >>
stream
q 540 0 0 405 28 218 cm /Im0 Do Q      % scale, translate, draw
endstream endobj

To fit an image inside a page with margins, scale it by min((pageW−2m)/imgW, (pageH−2m)/imgH) and centre the result — ordinary "contain" arithmetic.

The cross-reference table must be exact

This is the part that bites everyone. The xref table records the exact byte offset of every object, and the trailer's startxref records the offset of the table itself.5 If a single offset is off by one byte, readers reject the file. So assemble the document as an array of byte chunks, keep a running total, and record the offset the instant before you write each object.

javascript
const chunks = []; let offset = 0; const off = {};
const enc = new TextEncoder();
function put(x) {
  const u = typeof x === "string" ? enc.encode(x) : x;
  chunks.push(u); offset += u.length;   // track bytes, not characters
}
function mark(n) { off[n] = offset; }   // call right before writing object n

Note offset counts bytes, not string length — the JPEG streams and the binary header comment are why you must encode through TextEncoder and measure the resulting Uint8Array, never string.length.6

Handing back the file

Concatenate the chunks into one Uint8Array, wrap it in a Blob with type application/pdf, and trigger a download with an object URL on a temporary <a download>.78 No upload, no library, no server — a complete PDF writer that fits on one screen. Every page you add is just three more objects and three more offsets.

References

  1. MDN Web Docs — HTMLCanvasElement.toBlob(): canvas to a compressed image file.
  2. MDN Web Docs — CanvasRenderingContext2D.drawImage().
  3. ISO 32000-1 (PDF 1.7) — document structure, objects, MediaBox and the coordinate system.
  4. ISO 32000-1 — the DCTDecode filter (embedding JPEG image data).
  5. ISO 32000-1 — the cross-reference table and trailer.
  6. MDN Web Docs — TextEncoder: encode strings to UTF-8 bytes (and measure byte length).
  7. MDN Web Docs — the Blob interface.
  8. MDN Web Docs — the <a download> attribute for triggering downloads.