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

ApproachBest forTradeoff
jsPDFBrowser apps, mature ecosystem~150KB bundle, slightly older API
pdf-libModern projects, isomorphic, lossless~250KB bundle
ChangeThisFile APIEdge runtimes, small bundles, multi-tenant SaaSNetwork 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.