Implementation notes
HEIC Tools converts iPhone photos to JPG, PNG, and PDF without a server in the loop. This page documents how that decoding actually happens, for anyone curious what's running under the hood.
// 'decoded' is the array of frames returned by the libheif-js decoder
let primaryImage = decoded[0];
let maxArea = primaryImage.get_width() * primaryImage.get_height();
// HEIC files can hold multiple frames (thumbnails, bursts) —
// walk all of them and keep the highest-resolution one
for (let i = 1; i < decoded.length; i++) {
let area = decoded[i].get_width() * decoded[i].get_height();
if (area > maxArea) {
primaryImage = decoded[i];
maxArea = area;
}
}
// primaryImage is ready to be drawn to a canvas and exported
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.
This is the actual sequence a file goes through between being selected and coming out the other side as a JPG, PNG, or PDF.
The selected .heic file is read straight off disk into an ArrayBuffer. Nothing is sent anywhere yet, or ever.
Decoding is pushed onto a background thread so the page stays responsive, even converting a large batch at once.
The libheif-js WebAssembly module decodes the raw binary into image frames, using the device's own CPU.
A HEIC file can bundle several frames — the code above walks them and keeps the largest.
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:
// primaryImage is the frame picked in the previous step
function exportFrame(primaryImage, format = "image/jpeg", quality = 0.92) {
const canvas = document.createElement("canvas");
canvas.width = primaryImage.get_width();
canvas.height = primaryImage.get_height();
// libheif-js decodes straight into an ImageData-shaped buffer
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(canvas.width, canvas.height);
primaryImage.display(imageData, (displayData) => {
ctx.putImageData(displayData, 0, 0);
// toBlob runs entirely against in-memory canvas data —
// still no network request involved
canvas.toBlob((blob) => downloadBlob(blob, format), format, quality);
});
}
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:
// A separate worker, reusing the same off-main-thread approach as decoding
self.onmessage = async ({ data }) => {
const pdfDoc = await PDFDocument.create();
for (const photo of data.images) {
const embedded = await pdfDoc.embedJpg(photo.buffer);
// Scale to fit the page without stretching or cropping
const scale = Math.min(pageW / embedded.width, pageH / embedded.height, 1);
const w = embedded.width * scale, h = embedded.height * scale;
const page = pdfDoc.addPage([pageW, pageH]);
page.drawImage(embedded, { x: (pageW - w) / 2, y: (pageH - h) / 2, w, h });
}
self.postMessage({ pdfBytes: await pdfDoc.save() });
};
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.
Three export targets, each suited to a different use. All three run through the same local decode step above.