jsafa-pdf
v0.1.1
Published
Client-side HTML/CSS to PDF with real, selectable text. No server, no rasterization.
Maintainers
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-pdfimport { 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 buildEvery 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://. Onfile://the browser blocksfetchand 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:
@font-facein your CSS — found and embedded automatically. Recommended.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.
Limitations
Honest list. jsafa-pdf is not a browser.
Not implemented yet
box-shadow,text-shadowbackground-imageand 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: avoidand 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 buildnpm run exampleThen 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 webkitnpm testCI 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.1That 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 publicprepublishOnly 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
