npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@imggion/html2realpdf

v0.3.0

Published

Browser-first HTML to real, selectable PDF rendering powered by Zig and WebAssembly

Readme

Contents

Why real PDFs

Screenshot-based PDF tools turn a page into an image. html2realpdf keeps text as text, links as PDF annotations, fonts as embedded subsets, and supported graphics as vectors.

The text includes Unicode mappings, so people can select, copy, and search the result. Tools and LLMs can read the document text without first running OCR. Text-heavy documents are also often smaller and stay sharp at every zoom level.

Zig performs the layout and PDF writing, while WebAssembly brings the renderer to the browser. The result is one portable pipeline for invoices, reports, tickets, letters, slides, and other web-generated documents.

See the CSS support matrix for the current layout and rendering coverage. The current release provides machine-readable text and accessible preview controls; it does not claim PDF/UA or fully tagged PDF compliance.

Install

Install the stable release with your package manager:

npm install @imggion/html2realpdf
pnpm add @imggion/html2realpdf
yarn add @imggion/html2realpdf
bun add @imggion/html2realpdf

Quick start

Render an HTML element, download the PDF, then release its resources:

import { renderPdf } from "@imggion/html2realpdf";

const invoice = document.querySelector<HTMLElement>("#invoice");
if (!invoice) throw new Error("Invoice not found");

const pdf = await renderPdf(invoice);
pdf.download("invoice.pdf");
pdf.dispose();

HTML strings are supported too:

const pdf = await renderPdf("<h1>Hello from a real PDF</h1>");

PDF link annotations keep absolute http, https, mailto, tel, and ftp URLs. Browser snapshots resolve relative links against baseUrl and canonicalize international URLs; direct native/WASM input must already use canonical ASCII. Unresolved, active, or local values such as javascript, data, and file are removed. Set enableLinks: false to remove every link annotation.

React

Pass a ref to a mounted element. The package understands React-shaped refs without depending on React itself.

const reportRef = useRef<HTMLDivElement>(null);

async function downloadReport() {
  if (!reportRef.current) return;

  const pdf = await renderPdf(reportRef);
  pdf.download("report.pdf");
  pdf.dispose();
}

return <Report ref={reportRef} />;

Report can be a component that forwards its ref to its root element. Pass the mounted ref, not an unmounted component definition.

Vue

Pass the mounted DOM element behind a template ref. With Vue 3.5 or newer, useTemplateRef() keeps the element typed without wrapping the renderer in a Vue-specific adapter.

<script setup lang="ts">
import { useTemplateRef } from "vue";
import { renderPdf } from "@imggion/html2realpdf";

const report = useTemplateRef<HTMLElement>("report");

async function downloadReport() {
  if (!report.value) return;

  const pdf = await renderPdf(report.value);
  try {
    pdf.download("report.pdf");
  } finally {
    pdf.dispose();
  }
}
</script>

<template>
  <article ref="report">
    <h1>Quarterly report</h1>
    <p>This content stays selectable in the PDF.</p>
  </article>

  <button type="button" @click="downloadReport">Download PDF</button>
</template>

On Vue 3.4 or earlier, use const report = ref<HTMLElement | null>(null) with the same template ref and pass report.value to renderPdf().

Preview

The preview renders the actual generated PDF inside your page. It uses isolated Shadow DOM and canvas pages instead of an iframe, browser PDF plugin, or fake HTML copy.

const pdf = await renderPdf(invoice);
const previewTarget = document.querySelector<HTMLElement>("#pdf-preview");
if (!previewTarget) throw new Error("Preview target not found");
const previousButton = document.querySelector<HTMLButtonElement>("#previous-page")!;
const nextButton = document.querySelector<HTMLButtonElement>("#next-page")!;
const pageCounter = document.querySelector<HTMLOutputElement>("#page-counter")!;

const preview = await pdf.preview(previewTarget, {
  initialScale: "fit-width",
  onPageChange(currentPage, totalPages) {
    pageCounter.textContent = `Page ${currentPage} of ${totalPages}`;
    previousButton.disabled = currentPage === 1;
    nextButton.disabled = currentPage === totalPages;
  },
});

previousButton.addEventListener("click", () => preview.previousPage());
nextButton.addEventListener("click", () => preview.nextPage());
preview.goToPage(7); // Clamped to the first or last page when outside the document range.
console.log(preview.currentPage); // 1-based and synchronized with manual scrolling

// Later, when closing the preview:
preview.dispose();
pdf.dispose();

Page layouts

Use the named a4 and letter formats in portrait or landscape mode. A4 landscape works well for presentation decks:

const pdf = await renderPdf(slides, {
  page: {
    format: "a4",
    orientation: "landscape",
    unit: "mm",
    margin: [12, 12],
  },
});

For postcards or any other size, pass custom [width, height] dimensions:

const pdf = await renderPdf(postcard, {
  page: { format: [148, 105], unit: "mm", margin: 8 },
});

PDF/A and attachments

Opt into PDF/A-3u independently of the CSS profile:

const pdf = await renderPdf(invoice, {
  conformance: "pdfa-3u",
  metadata: { title: "Invoice" },
  attachments: [{
    name: "invoice.xml",
    data: new TextEncoder().encode(xml),
    mimeType: "application/xml",
    relationship: "Data",
  }],
});

The writer embeds sRGB, synchronized XMP metadata and Unicode font mappings. Incompatible resources produce an error; there is no fallback to ordinary PDF. CMYK JPEGs, missing glyphs and fonts that forbid outline embedding are rejected. Transparency and supported SVG remain native PDF graphics. This is PDF/A-3u, not a claim of Tagged PDF, PDF/UA, digital signatures or Factur-X compliance.

Attachments also work without conformance. Their bytes are preserved, and caller buffers stay usable in both Worker and main-thread execution. MIME type defaults to application/octet-stream and relationship to Unspecified. Optional description and modifiedAt: Date are supported; dates are never invented. Empty/duplicate names, malformed MIME types and invalid dates fail. The html2pdf.js adapter accepts the same options through .set(...).

See PDF/A implementation and validation for limits and the pinned veraPDF gate. Existing calls without these options keep their ordinary PDF output.

Compliance

Try it yourself: render a document with conformance: "pdfa-3u", download it, and open it in veraPDF, or check both profiles with its CLI:

verapdf --flavour 3u --format text invoice.pdf
verapdf --flavour 3b --format text invoice.pdf

To run the repository's PDF/A-3u validation suite, use make test-pdfa after completing the contributor setup.

Benchmark

One recorded run of the deterministic 30-page stress report produced:

| Engine | First PDF | Warm render | File size | Pages | Content model | | --- | ---: | ---: | ---: | ---: | --- | | html2realpdf | 1595.9 ms | 1451.2 ms | 441.1 kB | 30 | Native/selectable PDF | | html2pdf.js | 2124.6 ms | 1952.4 ms | 3.11 MB | 30 | Raster image PDF |

Main differences versus html2pdf.js

  • 33.1% faster first PDF
  • 34.5% faster warm render
  • 85.8% smaller file
  • and, of course, a REAL PDF 😏

Contributing

You need Zig 0.16.0, Node.js 20.16+, npm, and Make. The PDF/A gate also needs Java 17+, Poppler, curl and unzip. On a fresh checkout, install the JavaScript dependencies once:

npm ci --prefix bindings/js
npm ci --prefix tests/react
npm ci --prefix tests/web

| Command | Purpose | | --- | --- | | make test | Run the Zig and renderer tests | | make release | Build the native release binary | | make wasm | Build the default ReleaseFast WebAssembly and browser package | | make wasm-small | Build the optional size-oriented ReleaseSmall package asset | | make react | Start the React integration app | | make test-pdfa | Validate PDF/A-3u with veraPDF 1.30.2 and benchmark 30 pages | | make test-release | Run the complete release gate |

To open the small browser test harness:

make wasm
python3 -m http.server 8765

Visit http://localhost:8765/tests/web/index.html.

License

The project code is released under the MIT License. See LICENSE.md for the complete project and third-party license inventory.