JS PNG-to-PDF has two well-maintained library options: jsPDF (older, browser-focused) and pdf-lib (newer, isomorphic). Both are pure JS — no server required. The interesting choices are page sizing (image-size vs A4) and multi-page composition.
Method 1: jsPDF (browser, mature)
jsPDF is the most-used PDF library in JavaScript. Mature, well-documented, browser-first.
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
const { jsPDF } = window.jspdf;
async function pngToPdf(file) {
const dataUrl = await new Promise((res) => {
const fr = new FileReader();
fr.onload = () => res(fr.result);
fr.readAsDataURL(file);
});
// Get image dimensions for page sizing
const img = new Image();
await new Promise((res) => {
img.onload = res;
img.src = dataUrl;
});
// Create PDF sized to image (1 px = 1 pt for simplicity)
const pdf = new jsPDF({
orientation: img.width > img.height ? "landscape" : "portrait",
unit: "px",
format: [img.width, img.height],
});
pdf.addImage(dataUrl, "PNG", 0, 0, img.width, img.height);
pdf.save("output.pdf");
}
document.querySelector("input[type=file]").addEventListener("change", (e) => {
pngToPdf(e.target.files[0]);
});
For multi-page from many PNGs:
async function pngsToPdf(files) {
const pdf = new jsPDF({ unit: "px", format: "a4" });
for (let i = 0; i < files.length; i++) {
if (i > 0) pdf.addPage();
const dataUrl = await new Promise((res) => {
const fr = new FileReader();
fr.onload = () => res(fr.result);
fr.readAsDataURL(files[i]);
});
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
pdf.addImage(dataUrl, "PNG", 0, 0, pageWidth, pageHeight);
}
pdf.save("document.pdf");
}
Method 2: pdf-lib (browser + Node, lossless)
pdf-lib is the modern choice — isomorphic (browser and Node), lossless PNG embedding, cleaner API. Use this if you're starting fresh.
npm install pdf-lib
import { PDFDocument } from "pdf-lib";
import fs from "node:fs/promises";
async function pngToPdf(inPath, outPath) {
const pngBytes = await fs.readFile(inPath);
const pdfDoc = await PDFDocument.create();
const pngImage = await pdfDoc.embedPng(pngBytes);
const page = pdfDoc.addPage([pngImage.width, pngImage.height]);
page.drawImage(pngImage, {
x: 0,
y: 0,
width: pngImage.width,
height: pngImage.height,
});
const pdfBytes = await pdfDoc.save();
await fs.writeFile(outPath, pdfBytes);
}
await pngToPdf("diagram.png", "diagram.pdf");
For multi-page from many PNGs:
import { PDFDocument } from "pdf-lib";
import fs from "node:fs/promises";
import path from "node:path";
import { glob } from "glob";
async function pngsToPdf(inDir, outPath) {
const pdfDoc = await PDFDocument.create();
const pngs = (await glob(`${inDir}/*.png`)).sort();
for (const p of pngs) {
const bytes = await fs.readFile(p);
const img = await pdfDoc.embedPng(bytes);
const page = pdfDoc.addPage([img.width, img.height]);
page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
}
const pdfBytes = await pdfDoc.save();
await fs.writeFile(outPath, pdfBytes);
}
await pngsToPdf("./pages", "document.pdf");
pdf-lib embeds the PNG bytes losslessly — output PDF is approximately the sum of input PNG sizes plus minimal overhead.
Method 3: ChangeThisFile API (no library)
If you don't want a PDF library in your bundle, the API does it as one fetch. Free tier covers 1,000 conversions/month.
const API_KEY = "ctf_sk_your_key_here";
async function pngToPdf(pngBuffer, filename = "image.png") {
const form = new FormData();
form.append("file", new Blob([pngBuffer], { type: "image/png" }), filename);
form.append("source", "png");
form.append("target", "pdf");
const response = await fetch("https://changethisfile.com/v1/convert", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}` },
body: form,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.arrayBuffer();
}
// Cloudflare Worker example:
export default {
async fetch(request) {
const png = await request.arrayBuffer();
const pdf = await pngToPdf(new Uint8Array(png));
return new Response(pdf, {
headers: { "Content-Type": "application/pdf" },
});
},
};
The API embeds PNGs losslessly server-side. For multiple PNGs into one PDF, use the /v1/jobs endpoint with multiple file fields.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| jsPDF | Browser apps, mature ecosystem | ~150KB bundle, slightly older API |
| pdf-lib | Modern projects, isomorphic, lossless | ~250KB bundle |
| ChangeThisFile API | Edge runtimes, small bundles, multi-tenant SaaS | Network call, file size limit (25MB free) |
Production tips
- Prefer pdf-lib over jsPDF for new projects. Cleaner API, better TypeScript support, lossless image embedding. jsPDF is fine for existing codebases.
- Match PDF page size to image dimensions for screenshots. Setting
format: [img.width, img.height]avoids ugly white margins around the image. - Use jsPDF's compression: 'FAST' for big images. Default is 'NONE' for PNG. 'FAST' adds DEFLATE compression — for large PNGs, can cut PDF size by 20-40% with minimal speed cost.
- Lazy-load the PDF library. jsPDF and pdf-lib are 100-250KB minified. For pages where users only sometimes generate PDFs, dynamic import() to keep initial bundle small.
- Watch for transparency. PDF supports alpha but viewers handle it inconsistently. For maximum compatibility, composite the PNG onto white before adding to the PDF.
For new projects, pdf-lib is the right answer. For existing browser code, jsPDF is fine. For environments where you can't add a PDF library, the API. Free tier covers 1,000 conversions/month.