<!-- Markdown version of https://ocxly.com/build-image-to-pdf.html · auto-generated, may lag the live page -->

# 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](image-to-pdf.html) 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.

```
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](#ref-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](#ref-2)

```
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](#ref-3) The smallest useful document is a `Catalog` → a `Pages` tree → one or more `Page` objects.

```
%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](#ref-4)

```
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](#ref-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.

```
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](#ref-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.

```
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](#ref-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>`.[7](#ref-7)[8](#ref-8) 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.](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob)
2. [MDN Web Docs — CanvasRenderingContext2D.drawImage().](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage)
3. [ISO 32000-1 (PDF 1.7) — document structure, objects, MediaBox and the coordinate system.](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)
4. [ISO 32000-1 — the DCTDecode filter (embedding JPEG image data).](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=32)
5. [ISO 32000-1 — the cross-reference table and trailer.](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=59)
6. [MDN Web Docs — TextEncoder: encode strings to UTF-8 bytes (and measure byte length).](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder)
7. [MDN Web Docs — the Blob interface.](https://developer.mozilla.org/en-US/docs/Web/API/Blob)
8. [MDN Web Docs — the <a download> attribute for triggering downloads.](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#download)

---
*Source: [ocxly.com/build-image-to-pdf.html](https://ocxly.com/build-image-to-pdf.html) — OCXLY, free 100% client-side privacy-first tools. Learn to build an image-to-PDF converter with no libraries: normalise images on a canvas, embed JPEGs via DCTDecode, hand-write the PDF object graph and xref table, and download a valid PDF. Cited to the ISO 32000 spec and MDN.*
