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.
File → <img> → <canvas> → JPEG bytes → PDF object graph → Blob → downloadNormalising 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
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-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
%%EOFEmbedding 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
4 0 obj
<< /Type /XObject /Subtype /Image /Width 800 /Height 600
/ColorSpace /DeviceRGB /BitsPerComponent 8
/Filter /DCTDecode /Length 21987 >>
stream
… raw JPEG bytes …
endstream
endobjPlacing 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.
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 endobjTo 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.
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 nNote 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.