A PDF Writer in 40 Lines: Turning Photos Into PDFs in the Browser, No Library
We are a small Saudi team in Riyadh, and one of our 200 free browser tools turns photos into a PDF. People use it for receipts, ID scans, homework and contracts, so two things mattered: the photos must never be uploaded,
We are a small Saudi team in Riyadh, and one of our 200 free browser tools turns photos into a PDF. People use it for receipts, ID scans, homework and contracts, so two things mattered: the photos must never be uploaded, and the page must stay light on a phone.
The usual answer is to load a PDF library. We didn't. The whole writer is about 40 lines of plain JavaScript, and in our test its output passed a strict parser and opened in MuPDF without any repair. This post walks through how it works and the five small traps we hit.
The trick: PDF can carry a JPEG as-is
A PDF page can hold an image object with /Filter /DCTDecode. That filter is JPEG. So if you already have JPEG bytes, you can drop them into the file unchanged: no decoding, no compression code, no library.
And the browser gives you JPEG bytes for free:
const blob = await new Promise(r => canvas.toBlob(r, "image/jpeg", 0.8));
const jpeg = new Uint8Array(await blob.arrayBuffer());
Everything else is just writing a few text objects around those bytes.
The whole file, in order
A PDF with one image per page needs only these objects:
- Catalog: the root, pointing to the page tree.
- Pages: the list of pages.
- For each page, three objects: the Page (its size and resources), a tiny content stream that says "draw image Im0 here, this big", and the Image itself (our JPEG bytes).
- An Info dictionary with the title.
- The xref table: the byte offset of every object, so a reader can jump straight to any of them.
- The trailer, then
startxrefand%%EOF.
Here is the core of our writer, lightly tidied for reading:
const enc = new TextEncoder();
function buildPdf(pages, title) {
const parts = [], offs = [];
let len = 0;
const nObj = 2 + pages.length * 3 + 1;
const push = x => { const b = typeof x === "string" ? enc.encode(x) : x; parts.push(b); len += b.length; };
const obj = (id, body) => { offs[id] = len; push(id + " 0 obj\n"); body(); push("\nendobj\n"); };
push("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
const kids = pages.map((_, i) => (3 + i * 3) + " 0 R").join(" ");
obj(1, () => push("<< /Type /Catalog /Pages 2 0 R >>"));
obj(2, () => push(`<< /Type /Pages /Count ${pages.length} /Kids [${kids}] >>`));
pages.forEach((p, i) => {
const pg = 3 + i * 3, ct = pg + 1, im = pg + 2;
const cmd = `q ${p.dw} 0 0 ${p.dh} ${p.x} ${p.y} cm /Im0 Do Q`;
obj(pg, () => push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.pw} ${p.ph}] /Contents ${ct} 0 R ` +
`/Resources << /XObject << /Im0 ${im} 0 R >> >> >>`));
obj(ct, () => push(`<< /Length ${cmd.length} >>\nstream\n${cmd}\nendstream`));
obj(im, () => {
push(`<< /Type /XObject /Subtype /Image /Width ${p.w} /Height ${p.h} /ColorSpace /DeviceRGB ` +
`/BitsPerComponent 8 /Filter /DCTDecode /Length ${p.jpeg.length} >>\nstream\n`);
push(p.jpeg); push("\nendstream");
});
});
// ... Info object and xref (below)
return new Blob(parts, { type: "application/pdf" });
}
The content stream is one line of PDF drawing language: q saves state, cm scales the unit square to the image size and moves it, Do draws the image, and Q restores.
Trap 1: offsets are bytes, not characters
The xref table must hold the exact byte position of each object. If you build the file as one big JavaScript string and use .length, every non-ASCII character throws your offsets off, and strict readers complain.
So we never measure strings. Every piece goes through TextEncoder first, and we add up the encoded lengths. That includes the binary marker on line two (%Γ’Γ£ΓΓ), which exists to tell old tools "this file is binary", and which takes more bytes than characters.
const xref = len;
let rows = `xref\n0 ${nObj + 1}\n0000000000 65535 f \n`;
for (let j = 1; j <= nObj; j++) rows += String(offs[j]).padStart(10, "0") + " 00000 n \n";
push(rows + `trailer\n<< /Size ${nObj + 1} /Root 1 0 R /Info ${nObj} 0 R >>\nstartxref\n${xref}\n%%EOF\n`);
Each xref row is exactly 20 bytes, including the space before the newline. That trailing space is easy to drop and matters.
Trap 2: Arabic file names in the title
Our users name files in Arabic. PDF text strings are not UTF-8. The safe way is UTF-16BE with a byte-order mark, written as a hex string:
let hex = "FEFF";
for (let k = 0; k < title.length; k++) // code units, so emoji surrogate pairs stay intact
hex += title.charCodeAt(k).toString(16).padStart(4, "0").toUpperCase();
push(`<< /Title <${hex}> >>`);
Now a file called Β«Ψ΅ΩΨ± Ψ§ΩΨ±ΨΩΨ©Β» shows its real title in the readers we tested.
Trap 3: transparent PNGs turn black
JPEG has no transparency. Draw a transparent PNG onto a canvas and export it as JPEG, and the transparent areas come out black. One line fixes it: paint the canvas white before drawing.
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, w, h);
ctx.drawImage(bitmap, 0, 0, w, h);
Trap 4: units
PDF measures in points (1/72 inch). A4 is 595.28 Γ 841.89 pt. Margins come from the user in millimetres, so we multiply by 72 / 25.4. Then we fit the image into the page box, keep its aspect ratio, and centre it. That gives the x, y, dw and dh the content stream needs. With "fit page to image", each pixel becomes 0.75 pt, the same ratio as CSS pixels to points.
Rotation happens on the canvas, before encoding: swap width and height for 90Β° and 270Β°, rotate the context around the centre, and draw. The PDF never needs to know.
Trap 5: phones run out of memory
A 48-megapixel phone photo decoded to a canvas is about 190 MB of RGBA. Ten of them will crash a mid-range phone. So we:
- cap the long side at 2,000 px (normal) or 3,500 px (high quality) before encoding,
- process one image at a time, releasing each bitmap before opening the next,
- limit a batch to 100 images.
iPhone HEIC photos go through our shared image reader first, so they work even in browsers that can't show HEIC themselves.
Did it work?
We ran the writer in Node on two test JPEGs (31,258 bytes together) and checked the result:
- the PDF was 32,641 bytes, so the whole structure added about 1.4 KB;
-
pypdfopened it in strict mode with both pages and the Arabic title; - MuPDF opened it and reported no repair.
The writer is about 40 lines. The rest of the tool is the user interface: drag to reorder, rotate, page size and margins.
When you shouldn't do this
This approach is perfect for "images in, PDF out". If you need text layout, fonts, forms or editing of existing PDFs, use a real library. If you only need to wrap images, 40 lines and the browser's own JPEG encoder are enough.
You can try the result here: Image to PDF. Open the network tab while it works: nothing is uploaded.
We are Webnaa, a Saudi web design team based in Riyadh. We build websites and online stores for businesses in Saudi Arabia and the Gulf. This post was written by Claude, an AI assistant, from our production code and a validation run on its output.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.