Decoding .heic: How the code works under the hood

suolex1 pts0 comments

HEIC Tools - Architecture Notes for a Client-Side HEIC Converter

Why this needs explaining at all

HEIC is a genuinely efficient format, roughly half the size of an equivalent JPEG, which is why Apple made it the default camera format back in iOS 11. The trade-off is that almost nothing outside Apple's own apps reads it natively. Most "HEIC converters" solve that by uploading your photos to a server, converting them there, and sending the result back. That works, but it means a stranger's server touched every photo first.

HEIC Tools takes the other route: the decoding happens on the visitor's own device, in JavaScript and WebAssembly, and the original file never leaves the browser tab.

The pipeline, in five steps

This is the actual sequence a file goes through between being selected and coming out the other side as a JPG, PNG, or PDF.

01<br>Local file read<br>The selected .heic file is read straight off disk into an ArrayBuffer. Nothing is sent anywhere yet, or ever.

02<br>Handed to a Web Worker<br>Decoding is pushed onto a background thread so the page stays responsive, even converting a large batch at once.

03<br>WASM decode<br>The libheif-js WebAssembly module decodes the raw binary into image frames, using the device's own CPU.

04<br>Highest-resolution frame selected<br>A HEIC file can bundle several frames — the code above walks them and keeps the largest.

05<br>Canvas render & export<br>The chosen frame is drawn to an HTML canvas and exported as a JPG, PNG, or PDF, ready to download.

The extraction snippet above only decides which frame to use. This is the part that turns that frame into an actual file:

export-frame.js

// primaryImage is the frame picked in the previous step<br>function exportFrame(primaryImage, format = "image/jpeg", quality = 0.92) {<br>const canvas = document.createElement("canvas");<br>canvas.width = primaryImage.get_width();<br>canvas.height = primaryImage.get_height();

// libheif-js decodes straight into an ImageData-shaped buffer<br>const ctx = canvas.getContext("2d");<br>const imageData = ctx.createImageData(canvas.width, canvas.height);

primaryImage.display(imageData, (displayData) => {<br>ctx.putImageData(displayData, 0, 0);

// toBlob runs entirely against in-memory canvas data —<br>// still no network request involved<br>canvas.toBlob((blob) => downloadBlob(blob, format), format, quality);<br>});

Everything after the decode step happens through the Canvas API: the frame's pixels get painted into a canvas, and canvas.toBlob() reads them back out as compressed JPG or PNG bytes. That Blob is the actual downloadable file, built entirely from data already sitting in the browser's memory, which is what makes the "no uploads" claim a mechanical fact rather than a policy promise.

PDF takes a different final step. Instead of a canvas, it runs through pdf-lib inside a separate worker, so a large batch doesn't lock up the page either:

pdf-worker.js (excerpt)

// A separate worker, reusing the same off-main-thread approach as decoding<br>self.onmessage = async ({ data }) => {<br>const pdfDoc = await PDFDocument.create();

for (const photo of data.images) {<br>const embedded = await pdfDoc.embedJpg(photo.buffer);

// Scale to fit the page without stretching or cropping<br>const scale = Math.min(pageW / embedded.width, pageH / embedded.height, 1);<br>const w = embedded.width * scale, h = embedded.height * scale;

const page = pdfDoc.addPage([pageW, pageH]);<br>page.drawImage(embedded, { x: (pageW - w) / 2, y: (pageH - h) / 2, w, h });

self.postMessage({ pdfBytes: await pdfDoc.save() });<br>};

Each photo becomes its own page, scaled down (never up) so it fits without stretching or getting cropped, then centered. A large batch doesn't turn into one unwieldy file either: past a certain number of photos, the worker automatically splits the output into several smaller PDFs instead of forcing everything into a single document.

Heads up: this repository shares implementation notes and simplified excerpts of the frame-selection, export, and PDF-assembly logic shown above, not the full application source.

Where the output goes

Three export targets, each suited to a different use. All three run through the same local decode step above.

.JPG<br>HEIC to JPG<br>Small, universal, right for sharing and everyday uploads.<br>heictools.io →

.PNG<br>HEIC to PNG<br>Lossless output, for transparency and further editing.<br>heictools.io →

.PDF<br>HEIC to PDF<br>Bundle one or many photos into a single document.<br>heictools.io →

What running client-side actually gets you

0 uploads per conversion

100% of the decode happens on-device

$0 and no account required

✓ works offline once the page is loaded

canvas heic const file frame page

Related Articles