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

@pdfcraft-dev/pdf

v1.2.0

Published

HTML to PDF in one call. The official PDFCraft SDK.

Readme

@pdfcraft-dev/pdf

HTML to PDF, and PDF back to JSON. The official TypeScript client for PDFCraft — POST some HTML or a URL and get a PDF rendered by real Chromium, or POST a PDF and get its tables and labelled fields back as structured data.

npm zero dependencies license

npm install @pdfcraft-dev/pdf
import { Renderer } from '@pdfcraft-dev/pdf';

const pdfcraft = new Renderer(process.env.PDFCRAFT_API_KEY!);

const pdf = await pdfcraft.render({ html: '<h1>Invoice 1042</h1>' });
await writeFile('invoice.pdf', pdf);

That is the whole product. No browser to install, no Chromium in your Docker image, no --disable-dev-shm-usage to discover the hard way. Get a key at pdfcraft.dev — the free tier is 100 renders a month and needs no card.

What you are installing

Zero runtime dependencies. 24 kB packed — about half of which is source maps, so stack traces from inside the client point at real TypeScript — and nothing beneath it in your lockfile. The client is fetch plus a retry loop; the types are generated from the same source file the API validates requests against, so they cannot drift from the server.

Ships ESM and CommonJS. Works on Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge — anywhere there is a global fetch.

The methods

// HTML or a URL, out as a PDF
const pdf = await pdfcraft.render({ html });            // Uint8Array (a Buffer in Node)
const res = await pdfcraft.renderToUrl({ html });       // { url, expires_at, pages, bytes }
const job = await pdfcraft.renderAsync({ html, callback_url });
const sta = await pdfcraft.getRender(job.id);

// A PDF, out as JSON
const doc = await pdfcraft.extractPdf(bytes);           // { fields, tables, usage }
const via = await pdfcraft.extract({ file, schema });   // same, with base64 or a URL
const ref = await pdfcraft.extractToUrl({ file });      // { url, expires_at, pages }
const ejb = await pdfcraft.extractAsync({ file, callback_url });
const est = await pdfcraft.getExtraction(ejb.id);

const use = await pdfcraft.usage();                     // { used, limit, resets_at }

render streams the bytes back on the same connection — one round trip, typically under a second. renderToUrl uploads to storage and hands you a signed link instead, which is what you want when the PDF is large or the caller is a browser. Both take the same input; the method you call decides the output, so there is no output field to get wrong.

Page options

Every option the API accepts, fully typed, with autocomplete on the enums.

const pdf = await pdfcraft.render({
  html: invoiceHtml,
  options: {
    format: 'A4',                                   // A4 A3 A5 Letter Legal Tabloid
    landscape: false,
    margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
    scale: 1.0,                                     // 0.1 – 2.0
    printBackground: true,                          // on by default here, unlike a browser
    pageRanges: '1-5',
    headerHtml: '<div style="font-size:9px;width:100%;text-align:center">Acme Ltd</div>',
    footerHtml: '<div style="font-size:9px"><span class="pageNumber"></span></div>',
    waitFor: { selector: '#ready', networkIdle: true, delayMs: 0 },
    emulateMedia: 'print',                          // print | screen
    timeoutMs: 30_000,                              // ceiling 120_000
  },
  filename: 'invoice-1042.pdf',
});

headerHtml and footerHtml support Chromium's print classes — pageNumber, totalPages, date, title, url. A footer needs a bottom margin big enough to sit in, or it will not appear at all.

Rendering a page behind a login

const { url } = await pdfcraft.renderToUrl({
  url: 'https://app.example.com/reports/42',
  headers: { Authorization: `Bearer ${token}` },
  cookies: [{ name: 'session', value: sessionId }],
});

Waiting for the page to be ready

A chart that renders from JavaScript is not finished when the DOM loads. Use whichever signal your page actually gives you:

options: { waitFor: { selector: '#chart-rendered' } }   // best: explicit
options: { waitFor: { networkIdle: true } }             // good: no requests for 500ms
options: { waitFor: { delayMs: 1500 } }                 // last resort: a guess

PDF to JSON

The other direction. Hand it a PDF and get back the tables it reconstructed and the labelled fields it found — each with a bounding box saying where on the page it came from.

const doc = await pdfcraft.extractPdf(await readFile('invoice.pdf'));

doc.fields['Invoice Number'];
// { value: 'INV-2026-0417', raw: 'INV-2026-0417', page: 1, bbox: [115, 55, 176, 64] }

doc.tables[0];
// { page: 1,
//   header: ['DESCRIPTION', 'QUANTITY', 'UNIT PRICE', 'TOTAL'],
//   rows: [['Professional Services', '10', '1,000.00', '10,000.00'], …],
//   bbox: [30, 133, 565, 230] }

No OCR, and no model. Extraction is geometric: text runs are grouped into lines, lines are split into columns on horizontal whitespace, and a table is a run of consecutive lines sharing a column count. The same document always produces the same JSON. A scan has no text layer to read, so it comes back as extraction_failed — and is not billed, because charging for a no that took twenty milliseconds to determine would be rude.

A table broken across pages is stitched back together: consecutive pages whose header matches become one table and the repeated header rows are dropped, so a fourteen-page bank statement arrives as one table of 560 rows rather than fourteen tables and thirteen stray headers.

Asking for specific fields

const doc = await pdfcraft.extract({
  file: base64Pdf,                                 // or an https URL to a PDF
  schema: {
    invoice_number: { type: 'string', match: 'Invoice Number' },
    issued:         { type: 'date',   match: 'Invoice Date' },
    total:          { type: 'number', match: 'Total' },
  },
  options: { pages: '1-5', tables: true, text: false },
});

doc.fields.issued;   // { value: '2026-08-21', raw: '21/08/2026', page: 1, bbox: […] }
doc.fields.missing;  // null — the document does not contain it. Still a 200.

Matching ignores case and punctuation, so invoice_number finds Invoice Number: without being told. Numbers accept thousands separators, Indian digit grouping, a currency symbol and accounting negatives in parentheses.

Ambiguous dates are deliberately not guessed. 03/04/2026 is two different days depending on who printed it, so it stays a string; raw always holds the text exactly as printed, which is how you decide.

Straight from a URL or from HTML

const doc = await pdfcraft.extract({
  url: 'https://app.example.com/invoices/1042',
  schema: { total: { type: 'number', match: 'Total' } },
});

The page is rendered with the same Chromium as render(), then extracted. One call, one charge.

What it will not do

  • No OCR. No scans, no photographs, no handwriting.
  • No encrypted PDFs. Decrypt first — the API deliberately accepts no password, so it never holds one.
  • PDF in only. Not DOCX, not XLSX. If your source is a web page, send url or html.
  • 500 pages per request. Use options.pages beyond that.

Extraction is billed per page read, not per call, so a page range narrows the bill as well as the work. usage() counts pages and renders in the same allowance.

Errors

Every failure is a PDFCraftError with a stable .code you can branch on. The codes are part of the contract and will not be renamed inside a major version.

import { PDFCraftError } from '@pdfcraft-dev/pdf';

try {
  await pdfcraft.render({ html });
} catch (error) {
  if (error instanceof PDFCraftError) {
    if (error.code === 'quota_exceeded') return showUpgradePrompt();
    if (error.retryable) return queueForLater();
    throw error;
  }
}

| .code | HTTP | Means | Billed? | | ------------------ | ---- | ---------------------------------------- | --------------------------------------- | | invalid_request | 400 | Bad option, or both/neither html and url | no | | invalid_api_key | 401 | Missing, malformed or revoked key | no | | payment_required | 402 | Subscription past due | no | | not_found | 404 | Unknown render id | no | | render_timeout | 408 | The page exceeded timeoutMs | no | | render_failed | 422 | Navigation failed, selector never showed | yes — Chromium ran, your HTML broke | | rate_limited | 429 | Too many requests per second | no | | quota_exceeded | 429 | Monthly plan limit reached | no | | unsupported_file | 415 | Not a PDF, encrypted, or corrupt | no | | extraction_failed| 422 | Parsed, but no text layer — likely a scan| no | | internal_error | 500 | Our fault | no |

error.retryable is true for network_error, 429 and 5xx. Retries on those happen automatically — up to three retries with exponential backoff, honouring Retry-After. Other 4xx are never retried, because they fail identically however often you ask.

Idempotency

Pass a key as the second argument. The same key inside 24 hours returns the original render instead of billing you twice — which matters when the thing calling you is a webhook handler or a job queue that retries.

await pdfcraft.render({ html }, `invoice-${invoiceId}`);
await pdfcraft.extract({ file }, `parse-${invoiceId}`);

Keys are per account and shared across both endpoints, so use distinct ones for a render and an extraction of the same document.

Async renders and extractions

For documents that take a while, or when you do not want to hold a connection open. extractAsync is the same shape as renderAsync and signs its callback the same way.

const { id } = await pdfcraft.renderAsync({
  html,
  callback_url: 'https://example.com/hooks/pdf',
});

// later, if you would rather poll than receive
const { status, url } = await pdfcraft.getRender(id);

// extraction is identical, with its own id space
const job = await pdfcraft.extractAsync({ file, callback_url });
const { status: s, url: u } = await pdfcraft.getExtraction(job.id);

An extraction id is not a render id: getRender returns 404 on one and getExtraction returns 404 on a render id, deliberately, so neither can be probed with the other's ids.

The callback is a POST with an x-signature header: HMAC-SHA256 of the raw request body, hex-encoded, keyed with your webhook secret from the dashboard. Verify it before trusting the payload.

import { createHmac, timingSafeEqual } from 'node:crypto';

const expected = createHmac('sha256', process.env.PDFCRAFT_WEBHOOK_SECRET!)
  .update(rawBody) // the raw bytes, not the parsed object
  .digest('hex');

const given = Buffer.from(req.headers['x-signature'] as string);
const ok = given.length === expected.length && timingSafeEqual(Buffer.from(expected), given);

Client options

new Renderer(apiKey, {
  baseUrl: 'https://api.pdfcraft.dev', // point at a test double in your test suite
  maxRetries: 3,
  timeoutMs: 130_000,                  // just past the API's own 120s ceiling
  fetch: myInstrumentedFetch,          // inject your own for logging or mocking
});

Testing against a fake is the reason fetch is injectable — you should not need network access to run your unit tests.

Links

Contributing

Issues and pull requests are welcome.

One exception: src/contract/ is generated from the API's own option, plan and error definitions, so the client and the server cannot disagree about them. A change there has to start on the server side — open an issue and it will come back through as a release. Everything else is ordinary source.

License

MIT © Gaurav Singh. See LICENSE.