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

jsafa-pdf

v0.1.1

Published

Client-side HTML/CSS to PDF with real, selectable text. No server, no rasterization.

Readme

jsafa-pdf

Client-side HTML/CSS → PDF with real, selectable, copyable text. No server, no rasterization.

Drop in one script tag and call one function. Latin and Arabic both come out as real text you can select, copy and search — not a picture of text.

<script src="https://cdn.jsdelivr.net/npm/jsafa-pdf/dist/jsafa-pdf.umd.js"></script>
<script>
  JsafaPDF.download('#invoice', 'invoice.pdf');
</script>

That's the whole integration. No build step, no bundler, no framework.

That tag always fetches the newest release, which is what you want while trying it out. For a page you actually ship, pin a version.

0.1.1 — the feature set below is implemented and tested on Chromium, Firefox and WebKit. The API may still shift before 1.0; Limitations lists what isn't covered.


Contents


Why

jsafa-pdf is built around one idea:

The browser has already laid out the document. Read it back — don't recompute it.

Range.getClientRects() reports one rect per rendered line box, so the engine tells us exactly where every line sits and where it broke. We never decide where a line breaks; we observe where it broke, then transpile that into PDF operators.

Three things follow from that:

  • Text stays text. Glyphs are drawn as glyphs with an embedded font, so the output is selectable, searchable and small — not a picture of a page.
  • Your CSS is the layout engine. Flexbox, grid, floats, bidi, @font-face — whatever the browser renders is what gets measured, so there's no CSS subset to learn and no document to rewrite.
  • It runs in the page. No print dialog, no server, no headless browser to operate.

The tradeoff is stated plainly in Limitations: this is a transpiler from computed layout to PDF operators, not a second rendering engine, so effects that never produce measurable geometry are the parts that don't come across.


Install

CDN — nothing to download

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jsafa-pdf.umd.js"></script>

unpkg works the same way: https://unpkg.com/[email protected]/dist/jsafa-pdf.umd.js

Either defines the JsafaPDF global.

Choosing a version. The CDNs accept a full version, a range, or nothing at all:

| URL | Gets | |---|---| | [email protected] | the newest 0.1.x — bug fixes, never a breaking change. Recommended. | | [email protected] | exactly that build, forever. Use when you need byte-identical output. | | jsafa-pdf | the newest release of anything, breaking changes included. This is the tag at the top of this page. |

The unversioned form is the right shortcut for a first try, but it updates a live page without you touching it. Before 1.0 the API can still shift between minor versions, so @0.1 gives you fixes automatically while keeping a breaking release from reaching users you did not ship it to.

npm

npm install jsafa-pdf
import { download, registerFont, mm } from 'jsafa-pdf';

Self-hosted

Serve jsafa-pdf.umd.js from your own origin — the file from the CDN, the npm package, or a GitHub release:

<script src="/js/jsafa-pdf.umd.js"></script>

From source

git clone https://github.com/Safwan-Murad/jsafa-pdf.git
cd jsafa-pdf && npm install && npm run build

Every route gives you both builds:

| File | Use | |---|---| | jsafa-pdf.umd.js | <script> tag. Defines the JsafaPDF global. | | jsafa-pdf.js | ESM, for bundlers. |

Type declarations (.d.ts) are emitted alongside them.

The UMD build is ~519 KB gzipped. It carries a full PDF writer and font engine — subsetting, Identity-H encoding and OpenType shaping — which is what lets text stay real text.

Serve your page over http(s), not file://. On file:// the browser blocks fetch and ES modules, so fonts and images cannot be embedded.


Quick start

Everything below is optional configuration. The minimum is one call:

<style>
  /* Declared once in CSS — jsafa-pdf finds and embeds it automatically. */
  @font-face {
    font-family: 'Noto Sans Arabic';
    src: url('/fonts/NotoSansArabic-Regular.ttf') format('truetype');
  }
  #invoice { width: 420px; padding: 20px; border: 2px solid #cbd5e1; }
  #invoice .rtl { direction: rtl; font-family: 'Noto Sans Arabic', serif; }
</style>

<div id="invoice">
  <h2>Invoice #A-1042</h2>
  <p>The quick brown fox jumps over the lazy dog.</p>
  <p class="rtl">مرحبا بالعالم</p>
</div>

<button onclick="JsafaPDF.download('#invoice', 'invoice.pdf')">Download PDF</button>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jsafa-pdf.umd.js"></script>

jsafa-pdf automatically waits for webfonts and images to load, discovers @font-face rules and embeds them. See examples/simple.html.


API reference

All functions are properties of the JsafaPDF global (or named ESM exports).

target is always an Element or a CSS selector string.

Producing a PDF

| Function | Returns | Description | |---|---|---| | toPdf(target, options?) | Promise<Uint8Array> | Raw PDF bytes. | | toBlob(target, options?) | Promise<Blob> | PDF as an application/pdf Blob. | | download(target, filename?, options?) | Promise<void> | Generates and saves the file. filename defaults to "document.pdf". | | open(target, options?) | Promise<Window \| null> | Opens the PDF in a new browser tab. |

// Bytes, for uploading or storing yourself
const bytes = await JsafaPDF.toPdf('#invoice');
await fetch('/api/save', { method: 'POST', body: bytes });

// Blob, e.g. to preview in an <iframe>
const blob = await JsafaPDF.toBlob(document.getElementById('invoice'));
iframe.src = URL.createObjectURL(blob);

// Save to the user's downloads
await JsafaPDF.download('#invoice', 'invoice-1042.pdf');

// Open in a new tab
await JsafaPDF.open('#invoice');

Fonts

| Function | Returns | Description | |---|---|---| | registerFont({ family, src, weight?, style? }) | Promise<void> | Make a font embeddable. src is a URL, ArrayBuffer or Uint8Array. | | registerFontsFromCss(document?) | Promise<DiscoveredFont[]> | Register every @font-face on the page. Runs automatically. | | registeredFamilies() | string[] | Families currently available for embedding. | | clearFonts() | void | Forget all registered fonts. | | clearDiscoveredFonts() | void | Forget which @font-face rules were processed, so they are re-read. |

// Only needed when a font is NOT declared via @font-face
await JsafaPDF.registerFont({
  family: 'Cairo',
  src: '/fonts/Cairo-Bold.ttf',
  weight: 700,
  style: 'normal',
});

// Or from a file the user picked
await JsafaPDF.registerFont({ family: 'Cairo', src: await file.arrayBuffer() });

Units and page sizes

| Function | Returns | Description | |---|---|---| | mm(n) | number | Millimetres → PDF points. | | cm(n) | number | Centimetres → points. | | inch(n) | number | Inches → points. | | PAGE_SIZES | object | { A3, A4, A5, Letter, Legal }, each [width, height] in points. |

PDF points are 1/72 inch. A4 is [595.28, 841.89].

Advanced

Not needed for ordinary use — these expose the pipeline for custom output or debugging.

| Function | Returns | Description | |---|---|---| | waitForAssets(element) | Promise<void> | Wait for webfonts and images. Runs automatically. | | captureElement(element) | CapturedDocument | Read layout into a paint list without producing a PDF. | | extractTextRuns(textNode) | TextRun[] | Line- and direction-split runs for one text node. | | planPages(captured, pageOptions) | PlannedPage[] | Slice a captured document into pages. | | emitPdf(captured, options?) | Promise<Uint8Array> | Render an already-captured document. | | clearBaselineCache() | void | Drop memoised font metrics after swapping webfonts. | | pxToPt(px) / ptToPx(pt) | number | CSS px ↔ PDF points (× 0.75). |

// Inspect what was captured, without generating anything
const captured = JsafaPDF.captureElement('#invoice');
console.log(captured.runs.map(r => `${r.direction} "${r.text}"`));

Options

Every producing function accepts the same options object.

| Option | Type | Default | Description | |---|---|---|---| | page | PageOptions | (none) | Paginate onto fixed pages. Omit for a single page sized to the element. | | autoFonts | boolean | true | Discover and register @font-face fonts automatically. | | waitForAssets | boolean | true | Wait for fonts and images before reading layout. | | subset | boolean | true | Embed only the glyphs used. Smaller files; unsupported by a few font files. | | title | string | (none) | PDF document title metadata. |

PageOptions

| Option | Type | Default | Description | |---|---|---|---| | size | 'A3' \| 'A4' \| 'A5' \| 'Letter' \| 'Legal' \| [w, h] | 'A4' | Named size, or explicit [width, height] in points. | | orientation | 'portrait' \| 'landscape' | 'portrait' | | | margin | number \| { top, right, bottom, left } | mm(12) | In points. Use mm() / cm() / inch(). | | fit | 'width' \| 'none' | 'width' | width scales the element to the content width. none keeps 1px = 0.75pt. | | avoidBreakInside | boolean | true | Move a page boundary above a break-inside: avoid element rather than cutting it. | | repeatTableHeaders | boolean | true | Repeat <thead> at the top of continuation pages. | | footer | FooterOptions | (none) | Text drawn in the bottom margin. See below. |

Page numbers and footers

page: {
  size: 'A4',
  footer: {
    text: ({ page, pages }) => `Page ${page} of ${pages}`,
    align: 'center',       // 'left' | 'center' | 'right'
    fontSize: 9,           // points
    color: '#607d8b',      // any CSS colour
    fontFamily: 'Inter',   // only needed for a non-Latin footer
  }
}

Table pagination

Two hints your print stylesheet probably already sets are read automatically:

| CSS | effect | |---|---| | tr { break-inside: avoid } (or page-break-inside) | the boundary moves above the row instead of cutting through it | | thead { display: table-header-group } | the header repeats on every continuation page, with space reserved for it |

A region taller than a whole page is still cut — honouring it would leave no legal break anywhere.

await JsafaPDF.download('#report', 'report.pdf', {
  page: {
    size: 'A4',
    orientation: 'landscape',
    margin: { top: JsafaPDF.mm(20), right: JsafaPDF.mm(15),
              bottom: JsafaPDF.mm(20), left: JsafaPDF.mm(15) },
  },
  title: 'Quarterly Report',
});

Fonts

This is the one constraint worth understanding up front.

JavaScript cannot read arbitrary system font files. A font can only be embedded if its bytes are reachable, which means one of:

  1. @font-face in your CSS — found and embedded automatically. Recommended.
  2. registerFont() — for fonts loaded some other way.

Fonts you don't provide fall back to the closest PDF standard font (serif → Times, sans-serif → Helvetica, monospace → Courier). A document set in Georgia will render as Times.

Non-Latin scripts must be provided. The 14 standard PDF fonts contain no Arabic, CJK, Cyrillic or Greek glyphs at all. If your document has Arabic and you haven't supplied a font that covers it, jsafa-pdf throws an error naming the family rather than silently producing a broken PDF.

So if your document uses Arial or Segoe UI, self-host them:

@font-face { font-family: 'Inter'; src: url('/fonts/Inter.ttf') format('truetype'); }
body { font-family: 'Inter', sans-serif; }

Prefer TTF or OTF. WOFF works; WOFF2 is brotli-compressed and may fail to parse — if your CSS offers several formats, jsafa-pdf picks the most compatible one automatically.

Font fallback follows the browser's per-glyph behaviour: font-family: 'Noto Sans Arabic', serif paints Arabic with Noto and Latin with serif, exactly as on screen.


Links and transforms

<a href> becomes a clickable link annotation. Nothing to configure. Relative URLs are resolved to absolute, so they still work from a PDF opened anywhere. A link that wraps across lines gets one annotation per line. javascript: and bare #fragment targets are skipped — they would do nothing in a reader.

CSS transform is supported, including rotate, scale, translate and nested combinations. Transformed text stays a single selectable run rather than fragmenting into one run per character. This works because transforms are paint-time only: capture suspends them, reads clean geometry, and re-applies each as a PDF matrix.


Multiple pages

Pass page to paginate. Omit it and you get a single page sized exactly to the element — ideal for a ticket, badge or label.

await JsafaPDF.download('#statement', 'statement.pdf', {
  page: { size: 'A4', margin: JsafaPDF.mm(12) }
});

Pages are produced by slicing the captured layout, not re-flowing it, so line breaks stay exactly where the browser put them. Text is assigned whole-line to a single page (a line may overhang into the margin rather than be cut in half), and backgrounds, borders and images continue across breaks, clipped to each page.

See examples/paginate.html.


Limitations

Honest list. jsafa-pdf is not a browser.

Not implemented yet

  • box-shadow, text-shadow
  • background-image and CSS gradients
  • Running headers (footers and page numbers are supported)
  • break-before / break-after, widow and orphan control — these need layout re-flowed, which slicing deliberately does not do. break-inside: avoid and repeating <thead> are honoured

Inherent

  • Fonts must be reachable as bytes (see Fonts)
  • Cross-origin images need CORS, or their pixels cannot be read
  • Exotic CSS (filters, blend modes) may not reproduce exactly
  • Serve over http(s); file:// blocks the fetches this relies on

Development

Requires Node 20+.

npm install
npm run build
npm run example

Then open http://localhost:5174/examples/simple.html.

| Example | | |---|---| | simple.html | Minimal zero-config usage | | verify.html | Correctness suite — re-opens generated PDFs with pdf.js and checks the text layer, path geometry and pixel colours | | paginate.html | Multi-page output, checked for content loss and duplication |

Tests

The suite runs in real browsers — jsdom has no layout engine, so it would pass while proving nothing — and on all three engines, because capture reads layout back out of the engine and a single-engine run proves very little.

30 tests × Chromium, Firefox, WebKit = 90, covering line-box and bidi capture, backgrounds, borders, border-radius, images, object-fit, links, transforms, slicing, break-inside, repeated headers and footers. Generated PDFs are re-opened with pdf.js and asserted against the DOM they came from. Fixtures use standard PDF fonts and images encoded at runtime, so nothing needs a font file or the network.

First fetch the browser binaries (once):

npx playwright install chromium firefox webkit
npm test

CI runs the same thing on every push — see .github/workflows/ci.yml.

npm run dev serves the internal capture harness at http://localhost:5173. It must not be used to serve the examples: Vite transforms what it serves and would rewrite dist/ into a broken stub.

Releasing

Bump version in package.json, then tag it:

git tag v0.1.1 && git push origin v0.1.1

That runs .github/workflows/release.yml: it refuses to continue if the tag disagrees with package.json, runs typecheck, build and the full three-engine suite, then creates the GitHub release with jsafa-pdf.umd.js and jsafa-pdf.js attached.

Publishing to npm stays manual, since it is the one step that cannot be undone:

npm publish --access public

prepublishOnly re-runs typecheck and build first, so a stale dist/ cannot ship.

dist/ is intentionally not committed — minified bundles diff badly and would grow the repository by roughly its own size on every build. The CDN and release assets serve that need instead.

See DESIGN.md for architecture and rationale.


License

MIT