pretext-pdf 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,753 +1,795 @@
1
- # pretext-pdf
2
-
3
- > **The PDF library AI agents speak natively — and humans love writing.**
4
- >
5
- > A `PdfDocument` is plain JSON. LLMs emit it in one shot — no codegen, no headless browser, no `eval`.
6
- > Humans get a strict-typed declarative API for invoices, reports, resumes, and templates.
7
-
8
- [![npm version](https://img.shields.io/npm/v/pretext-pdf)](https://www.npmjs.com/package/pretext-pdf)
9
- [![npm downloads](https://img.shields.io/npm/dw/pretext-pdf)](https://www.npmjs.com/package/pretext-pdf)
10
- [![CI](https://github.com/Himaan1998Y/pretext-pdf/actions/workflows/ci.yml/badge.svg)](https://github.com/Himaan1998Y/pretext-pdf/actions)
11
- [![TypeScript](https://img.shields.io/badge/typescript-strict-blue)](https://www.typescriptlang.org/)
12
- [![Tests](https://img.shields.io/badge/tests-624-brightgreen)](#tests)
13
- [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
14
- [![Bundle](https://img.shields.io/badge/runtime%20deps-8-informational)](#runtime-footprint)
15
-
16
- **[Live demo](https://himaan1998y.github.io/pretext-pdf/)**  ·  **[`pretext-pdf-mcp`](https://www.npmjs.com/package/pretext-pdf-mcp)** (MCP server)  ·  **[Migrating from pdfmake?](#migrating-from-pdfmake)**
17
-
18
- *Layout powered by [`@chenglou/pretext`](https://github.com/chenglou/pretext) — the precision text-layout engine by [Cheng Lou](https://github.com/chenglou) (React core team, Midjourney).*
19
-
20
- ---
21
-
22
- ## Table of contents
23
-
24
- - [Why pretext-pdf](#why-pretext-pdf)
25
- - [Install](#install)
26
- - [Quick start](#quick-start)
27
- - [Library API](#library-api)
28
- - [CLI](#cli)
29
- - [Markdown](#markdown)
30
- - [Templates](#templates)
31
- - [pdfmake migration](#migrating-from-pdfmake)
32
- - [MCP server (Claude / Cursor / Windsurf)](#mcp-server-claude--cursor--windsurf)
33
- - [Built for AI agents](#built-for-ai-agents)
34
- - [Element catalog](#element-catalog)
35
- - [Document features](#document-level-features)
36
- - [API reference](#api-reference)
37
- - [India / GST invoicing](#india--gst-invoicing)
38
- - [Custom fonts](#custom-fonts)
39
- - [Rich text](#rich-text)
40
- - [Footnotes](#footnotes)
41
- - [Examples](#examples)
42
- - [Error handling](#error-handling)
43
- - [Troubleshooting](#troubleshooting)
44
- - [Non-goals](#non-goals)
45
- - [Runtime footprint](#runtime-footprint)
46
- - [Performance](#performance)
47
- - [Tests](#tests)
48
- - [Security](#security)
49
- - [Roadmap](#roadmap)
50
- - [Contributing](#contributing)
51
- - [Credits](#credits)
52
-
53
- ---
54
-
55
- ## Why pretext-pdf
56
-
57
- Three established camps in JS PDF generation, and one gap. pretext-pdf lives in the gap.
58
-
59
- | | pdfmake / jsPDF / pdfkit | Puppeteer / Playwright | LaTeX / WeasyPrint | **pretext-pdf** |
60
- |---|---|---|---|---|
61
- | Lightweight (no Chromium) | ✅ | ❌ ~300 MB | ❌ native binaries | ✅ |
62
- | Pure ESM, runs in serverless | ✅ | ⚠️ painful in Lambda | ❌ | ✅ |
63
- | Professional typography (kerning, hyphenation, RTL/CJK) | | | | ✅ |
64
- | Declarative describe the document, don't draw it | ⚠️ partial | | | ✅ |
65
- | **LLM emits a working document in one shot** | requires codegen loop | ❌ requires HTML+CSS knowledge | ❌ requires LaTeX knowledge | ✅ pure JSON |
66
- | MCP server out of the box | ❌ | ❌ | ❌ | ✅ |
67
- | Drop-in CLI for shell pipelines | ❌ | ⚠️ wrap with code | ⚠️ separate binary | ✅ `pretext-pdf in.json out.pdf` |
68
- | pdfmake migration shim | | ❌ | | ✅ `fromPdfmake()` |
69
-
70
- **The headline:** every other JS PDF library asks an LLM (or you) to *write code*. pretext-pdf asks for a JSON object. That difference is what makes agent-generated PDFs reliable — and the same shape happens to be a clean declarative API for humans too.
71
-
72
- ---
73
-
74
- ## Install
75
-
76
- ```bash
77
- npm install pretext-pdf
78
- ```
79
-
80
- > **ESM only** — use `import`, not `require`. Requires Node.js ≥ 18.
81
-
82
- Optional peer dependencies — install only what you use:
83
-
84
- | Peer | When you need it |
85
- |---|---|
86
- | `@napi-rs/canvas` | SVG / qr-code / barcode / chart elements (Node only — browser uses `OffscreenCanvas`) |
87
- | `qrcode` | `qr-code` element |
88
- | `bwip-js` | `barcode` element (100+ symbologies) |
89
- | `vega` + `vega-lite` | `chart` element |
90
- | `marked` | `pretext-pdf/markdown` entry point and `--markdown` CLI flag |
91
- | `@signpdf/signpdf` | PKCS#7 cryptographic signing |
92
-
93
- > **Encryption is built-in** since v0.4.0 — no extra install.
94
-
95
- ---
96
-
97
- ## Quick start
98
-
99
- ### Library API
100
-
101
- ```typescript
102
- import { render } from 'pretext-pdf'
103
- import { writeFileSync } from 'fs'
104
-
105
- const pdf = await render({
106
- pageSize: 'A4',
107
- margins: { top: 40, bottom: 40, left: 50, right: 50 },
108
- metadata: { title: 'Invoice #001', author: 'Acme Corp' },
109
- content: [
110
- { type: 'heading', level: 1, text: 'Invoice #12345' },
111
- { type: 'paragraph', text: 'Thank you for your business.', fontSize: 12 },
112
- {
113
- type: 'table',
114
- columns: [
115
- { width: 200 },
116
- { width: 50, align: 'right' },
117
- { width: 100, align: 'right' },
118
- ],
119
- rows: [
120
- { isHeader: true, cells: [{ text: 'Item', fontWeight: 700 }, { text: 'Qty', fontWeight: 700 }, { text: 'Price', fontWeight: 700 }] },
121
- { cells: [{ text: 'Professional Services' }, { text: '10' }, { text: '$1,000' }] },
122
- { cells: [{ text: 'Hosting (annual)' }, { text: '1' }, { text: '$500' }] },
123
- ],
124
- },
125
- { type: 'paragraph', text: 'Total: $1,500', align: 'right', fontWeight: 700 },
126
- ],
127
- })
128
-
129
- writeFileSync('invoice.pdf', pdf)
130
- ```
131
-
132
- ### CLI
133
-
134
- `pretext-pdf` ships with a binary that turns a JSON or Markdown file into a PDF — no Node code required.
135
-
136
- ```bash
137
- # JSON in, PDF out
138
- pretext-pdf doc.json invoice.pdf
139
-
140
- # Stdin → stdout (pipe-friendly)
141
- echo '{"content":[{"type":"heading","level":1,"text":"Hi"}]}' | pretext-pdf > out.pdf
142
-
143
- # Markdown straight to PDF
144
- pretext-pdf --markdown --code-font 'Courier New' README.md docs.pdf
145
-
146
- # Help / version
147
- pretext-pdf --help
148
- pretext-pdf --version
149
- ```
150
-
151
- | Flag | Meaning |
152
- |---|---|
153
- | `-i, --input <path>` | Read input from file (default: first positional, or stdin) |
154
- | `-o, --output <path>` | Write PDF to file (default: second positional, or stdout) |
155
- | `--markdown` | Treat input as Markdown converts via `pretext-pdf/markdown` |
156
- | `--code-font <name>` | With `--markdown`, font family for fenced code blocks |
157
- | `-v, --version` | Print version |
158
- | `-h, --help` | Print help |
159
-
160
- Exit codes: `0` success, `1` user error (bad args, invalid JSON), `2` render error.
161
-
162
- ### Markdown
163
-
164
- Convert any Markdown string to `ContentElement[]` in one call. Requires `marked` peer dep.
165
-
166
- ```typescript
167
- import { markdownToContent } from 'pretext-pdf/markdown'
168
- import { render } from 'pretext-pdf'
169
-
170
- const md = `
171
- # Q1 2026 Report
172
-
173
- Revenue grew **18%** year-over-year.
174
-
175
- | Metric | Q4 2025 | Q1 2026 | Change |
176
- |--------|--------:|--------:|:------:|
177
- | Revenue | $45M | $60M | +33% |
178
- | Margin | 62% | 68% | +6pp |
179
-
180
- - [x] Cloud expansion launched
181
- - [x] Enterprise pipeline doubled
182
- - [ ] APAC region opening Q2
183
-
184
- > All figures in USD millions.
185
- `
186
-
187
- const content = await markdownToContent(md, { codeFontFamily: 'Courier New' })
188
- const pdf = await render({ content })
189
- ```
190
-
191
- Supported: headings h1–h4, bold, italic, strikethrough, inline code, links, ordered/unordered lists (recursive nesting), **GFM tables (with column alignment)**, **GFM task lists** (☑/☐), fenced code blocks, blockquotes, horizontal rules.
192
-
193
- ### Templates
194
-
195
- Pre-built zero-dependency template functions:
196
-
197
- ```typescript
198
- import { createInvoice, createGstInvoice, createReport } from 'pretext-pdf/templates'
199
- import { render } from 'pretext-pdf'
200
-
201
- const content = createInvoice({
202
- from: { name: 'Acme Corp', address: '123 Main St', email: 'billing@acme.com' },
203
- to: { name: 'Client Ltd', address: '456 Oak Ave' },
204
- invoiceNumber: 'INV-2026-001',
205
- date: '2026-04-20',
206
- items: [{ description: 'Consulting', quantity: 10, unitPrice: 150 }],
207
- currency: '$', taxRate: 10, taxLabel: 'GST',
208
- qrData: 'upi://pay?pa=acme@bank&am=1650',
209
- })
210
- const pdf = await render({ content })
211
- ```
212
-
213
- Available: `createInvoice` (any currency), `createGstInvoice` (India GST/IGST/CGST+SGST + UPI QR + amount-in-words), `createReport` (with optional TOC).
214
-
215
- ### Migrating from pdfmake
216
-
217
- `pretext-pdf/compat` translates pdfmake document descriptors into a `PdfDocument` — most common patterns work without code changes.
218
-
219
- ```typescript
220
- import { fromPdfmake } from 'pretext-pdf/compat'
221
- import { render } from 'pretext-pdf'
222
-
223
- // Existing pdfmake document, unchanged
224
- const pdfmakeDoc = {
225
- pageSize: 'LETTER',
226
- pageMargins: [40, 60, 40, 60],
227
- defaultStyle: { fontSize: 11 },
228
- styles: {
229
- header: { fontSize: 22, bold: true },
230
- subheader: { fontSize: 16 },
231
- },
232
- content: [
233
- { text: 'Invoice #001', style: 'header' },
234
- { text: 'Acme Corp', style: 'subheader' },
235
- 'Thanks for your business.',
236
- {
237
- table: {
238
- widths: ['*', 'auto', 80],
239
- headerRows: 1,
240
- body: [
241
- ['Item', 'Qty', 'Price'],
242
- ['Widget', '3', '$30'],
243
- ['Sprocket', '5', '$50'],
244
- ],
245
- },
246
- },
247
- { ul: ['Net 30 terms', 'Late fee: 1.5%/mo'] },
248
- ],
249
- }
250
-
251
- const pdf = await render(fromPdfmake(pdfmakeDoc))
252
- ```
253
-
254
- | pdfmake feature | Compat support |
255
- |---|---|
256
- | `string` content | ✅ → paragraph |
257
- | `{ text, bold, italics, color, fontSize, alignment, font }` | ✅ → paragraph or rich-paragraph |
258
- | `{ text, style: 'header' }` (style lookup) | ✅ — `header`/`h1`/`title` map to heading 1, `subheader`/`h2` to 2, etc. |
259
- | `{ ul }` / `{ ol }` (recursive) | ✅ list |
260
- | `{ table: { body, widths, headerRows } }` | ✅ → table |
261
- | `{ image, width, height }` | ✅ → image |
262
- | `{ qr, fit }` | ✅ → qr-code |
263
- | `{ pageBreak: 'before' \| 'after' }` | ✅ → page-break |
264
- | `{ stack }` | ✅ → flattened inline |
265
- | `{ link }` on inline text | ✅ → span.href |
266
- | `pageSize`, `pageOrientation`, `pageMargins` | ✅ |
267
- | `info` (title/author/subject/keywords) | ✅ → metadata |
268
- | `header`, `footer` (string form) | ✅ |
269
- | `{ columns }` | ⚠️ flattened with a warning |
270
- | `{ canvas }` | unsupported (drawing primitives) |
271
- | Function-style `header`/`footer` | ❌ pass a string |
272
-
273
- Override the heading-name mapping via `fromPdfmake(doc, { headingMap: { ... } })`.
274
-
275
- ### MCP server (Claude / Cursor / Windsurf)
276
-
277
- Drop into any MCP-aware AI agent in 60 seconds:
278
-
279
- ```json
280
- {
281
- "mcpServers": {
282
- "pretext-pdf": {
283
- "command": "npx",
284
- "args": ["-y", "pretext-pdf-mcp"]
285
- }
286
- }
287
- }
288
- ```
289
-
290
- Exposes: `generate_pdf`, `generate_invoice`, `generate_report`, `generate_from_markdown`, `list_element_types`. Versioned alongside this library — see [`pretext-pdf-mcp`](https://www.npmjs.com/package/pretext-pdf-mcp).
291
-
292
- ---
293
-
294
- ## Built for AI agents
295
-
296
- A `PdfDocument` is a plain JSON object. No functions are required. Every field is optional except `type` and a few element-specific essentials. That shape is exactly what an LLM can produce reliably with no tool-use loop.
297
-
298
- ```typescript
299
- import { render } from 'pretext-pdf'
300
-
301
- // Whatever produced this JSON — Claude, GPT, a workflow node, a form submission — works the same
302
- const pdf = await render({
303
- metadata: { title: 'AI-generated quarterly report' },
304
- content: [
305
- { type: 'heading', level: 1, text: 'Q1 2026 Summary' },
306
- { type: 'paragraph', text: 'Revenue grew 18% YoY.' },
307
- { type: 'table', columns: [/* ... */], rows: [/* ... */] },
308
- ],
309
- })
310
- ```
311
-
312
- ### Why JSON-first matters for agents
313
-
314
- - **No code execution loop.** Model returns JSON; you call `render()`. No sandbox, no `vm`, no Vercel Sandbox roundtrip.
315
- - **Schema-validatable.** Strict TypeScript types double as the contract. Pair with [Anthropic tool use](https://docs.anthropic.com/en/docs/build-with-claude/tool-use) or [Vercel AI SDK structured output](https://sdk.vercel.ai/docs/ai-sdk-core/generating-structured-data).
316
- - **Self-correcting errors.** Every failure throws `PretextPdfError` with a typed `code`. Feed it back to the model and it fixes itself.
317
- - **Progressive disclosure.** Optional peer deps mean agents only ask for QR codes, charts, or markdown when needed token-efficient prompts.
318
-
319
- ---
320
-
321
- ## Element catalog
322
-
323
- ```
324
- paragraph heading(1-4) spacer hr page-break
325
- table image svg list code
326
- blockquote rich-paragraph callout comment form-field
327
- toc qr-code barcode chart footnote-def
328
- ```
329
-
330
- | Element | What it does |
331
- | --- | --- |
332
- | `paragraph` | Text block — font, size, color, align, background, letterSpacing, smallCaps, tabularNumbers, multi-column (`columns` + `columnGap`), RTL (`dir`) |
333
- | `heading` | H1–H4 with bookmarks, URL links, internal anchors, tabularNumbers, RTL |
334
- | `table` | Fixed/proportional/auto columns, colspan, rowspan, repeating headers across page breaks |
335
- | `image` | PNG/JPG/WebP with sizing, alignment, float left/right with `floatText` or rich `floatSpans` |
336
- | `list` | Ordered/unordered, recursive nesting, `nestedNumberingStyle: 'restart' \| 'continue'` |
337
- | `code` | Monospace block with background and padding |
338
- | `blockquote` | Left border + background |
339
- | `rich-paragraph` | Mixed bold/italic/color/size/super/subscript spans with inline hyperlinks |
340
- | `svg` | Embedded SVG graphics with auto-sizing from viewBox |
341
- | `toc` | Auto-generated table of contents with accurate page numbers (two-pass) |
342
- | `qr-code` | Scannable QR code UPI, URLs, vCards. Requires `qrcode` peer dep. |
343
- | `barcode` | 100+ symbologiesEAN-13, Code128, PDF417, DataMatrix, etc. Requires `bwip-js`. |
344
- | `chart` | Vega-Lite data visualisation as vector SVG. Requires `vega` + `vega-lite`. |
345
- | `comment` | PDF sticky-note annotation (visible in Acrobat/Preview sidebar) |
346
- | `form-field` | Interactive text/checkbox/radio/dropdown/button (with `flattenForms` to bake) |
347
- | `callout` | Info / warning / tip / note callout boxes |
348
- | `footnote-def` | Paired with `span.footnoteRef` for proper footnote numbering + zone reservation |
349
- | `hr` / `spacer` / `page-break` | Layout primitives |
350
-
351
- ### Document-level features
352
-
353
- | Feature | Config key | Notes |
354
- | --- | --- | --- |
355
- | Watermarks | `doc.watermark` | Text or image, opacity, rotation |
356
- | Encryption | `doc.encryption` | Password + granular permissions, built-in |
357
- | Cryptographic signing | `doc.signature: { p12, passphrase, ... }` | PKCS#7, optional `@signpdf/signpdf` |
358
- | PDF Bookmarks | `doc.bookmarks` | Auto-generated from headings |
359
- | Hyphenation | `doc.hyphenation` | Liang's algorithm, e.g. `language: 'en-us'` |
360
- | Headers/Footers | `doc.header` / `doc.footer` | `{{pageNumber}}`, `{{totalPages}}`, `{{date}}` tokens |
361
- | Per-section overrides | `doc.sections` | Different header/footer per page range |
362
- | Metadata | `doc.metadata` | Title, author, subject, keywords, language, producer |
363
- | Hyperlinks | `paragraph.url`, `heading.url`, `heading.anchor`, `span.href` | External, mailto, internal anchors |
364
- | Document assembly | `merge(pdfs)`, `assemble(parts)` | Combine pre-rendered + freshly rendered |
365
- | Path-traversal lockdown | `doc.allowedFileDirs` | Restrict file-source reads to listed dirs |
366
-
367
- ---
368
-
369
- ## API reference
370
-
371
- ### `render(doc): Promise<Uint8Array>`
372
-
373
- ```typescript
374
- import { render } from 'pretext-pdf'
375
-
376
- const pdf = await render({
377
- pageSize: 'A4', // 'A4' | 'A3' | 'A5' | 'Letter' | 'Legal' | 'Tabloid' | [w, h]
378
- margins: { top: 72, bottom: 72, left: 72, right: 72 },
379
- defaultFont: 'Inter', // Inter 400/700 bundled
380
- defaultFontSize: 12,
381
- metadata: { title: '...', author: '...', keywords: ['pdf'] },
382
- watermark: { text: 'DRAFT', opacity: 0.15, rotation: -45 },
383
- encryption: { userPassword: 'open', ownerPassword: 'admin', permissions: { printing: true, copying: false } },
384
- bookmarks: { minLevel: 1, maxLevel: 3 },
385
- hyphenation: { language: 'en-us', minWordLength: 6 },
386
- header: { text: '{{pageNumber}} of {{totalPages}}', align: 'right' },
387
- footer: { text: 'Confidential', align: 'center', color: '#999' },
388
- content: [ /* ContentElement[] */ ],
389
- })
390
- ```
391
-
392
- ### `merge(pdfs): Promise<Uint8Array>`
393
-
394
- Combine pre-rendered PDFs:
395
-
396
- ```typescript
397
- import { merge } from 'pretext-pdf'
398
- const combined = await merge([coverPdf, bodyPdf, appendixPdf])
399
- ```
400
-
401
- ### `assemble(parts): Promise<Uint8Array>`
402
-
403
- Mix new docs with existing PDFs:
404
-
405
- ```typescript
406
- import { assemble } from 'pretext-pdf'
407
-
408
- const report = await assemble([
409
- { pdf: existingCoverPdf },
410
- { doc: { content: [/* fresh */] } },
411
- { pdf: standardTermsPdf },
412
- ])
413
- ```
414
-
415
- ### `createPdf(opts): PdfBuilder` (fluent builder)
416
-
417
- ```typescript
418
- import { createPdf } from 'pretext-pdf'
419
-
420
- const pdf = await createPdf({ pageSize: 'A4' })
421
- .addHeading('My Report', 1)
422
- .addText('Fluent chainable API.')
423
- .addTable({ columns: [{ name: 'Col A' }, { name: 'Col B' }], rows: [{ 'Col A': 'x', 'Col B': 'y' }] })
424
- .build()
425
- ```
426
-
427
- ### `markdownToContent(md, opts?)` *(from `pretext-pdf/markdown`)*
428
-
429
- ### `createInvoice / createGstInvoice / createReport` *(from `pretext-pdf/templates`)*
430
-
431
- ### `fromPdfmake(doc, opts?)` *(from `pretext-pdf/compat`)*
432
-
433
- ---
434
-
435
- ## India / GST invoicing
436
-
437
- Built-in support for Indian invoice requirements:
438
-
439
- - **₹ symbol** renders correctly (bundled Inter includes the Rupee glyph)
440
- - **Indian number formatting** (`1,00,000` not `100,000`)
441
- - **GST structure** CGST/SGST (intra-state) and IGST (inter-state) layouts (auto-detected from state fields)
442
- - **Amount in words** — Indian numbering system (Lakh/Crore), with correct sub-rupee handling
443
- - **SAC/HSN codes** column support in line-item tables
444
-
445
- ```typescript
446
- import { createGstInvoice } from 'pretext-pdf/templates'
447
- import { render } from 'pretext-pdf'
448
-
449
- const content = createGstInvoice({
450
- supplier: { name: 'Antigravity Systems', address: 'Gurugram, HR', gstin: '06AAACA1234A1ZV', state: 'Haryana' },
451
- buyer: { name: 'TechStartup Ltd', address: 'Mumbai, MH', gstin: '27AABCB5678B1ZP', state: 'Maharashtra' },
452
- invoiceNumber: 'INV/2026-27/001',
453
- invoiceDate: '20 Apr 2026',
454
- placeOfSupply: 'Maharashtra (27)',
455
- items: [
456
- { description: 'Software Development', hsnSac: '998314', quantity: 80, unit: 'Hrs', rate: 3000, taxRate: 18 },
457
- ],
458
- qrUpiData: 'upi://pay?pa=merchant@hdfc&pn=Antigravity&am=283200',
459
- bankName: 'HDFC Bank', accountNumber: '501001234567', ifscCode: 'HDFC0001234',
460
- })
461
- const pdf = await render({ content })
462
- ```
463
-
464
- See [`examples/gst-invoice-india.ts`](examples/gst-invoice-india.ts) for a fully wired example.
465
-
466
- ---
467
-
468
- ## Custom fonts
469
-
470
- ```typescript
471
- const pdf = await render({
472
- fonts: [
473
- { family: 'Roboto', weight: 400, src: '/path/to/Roboto-Regular.ttf' },
474
- { family: 'Roboto', weight: 700, src: '/path/to/Roboto-Bold.ttf' },
475
- { family: 'Roboto', style: 'italic', src: '/path/to/Roboto-Italic.ttf' },
476
- ],
477
- defaultFont: 'Roboto',
478
- content: [
479
- { type: 'paragraph', text: 'Uses Roboto' },
480
- { type: 'paragraph', text: 'Bold', fontWeight: 700 },
481
- ],
482
- })
483
- ```
484
-
485
- > **Avoid `system-ui`**known Pretext layout-measurement inaccuracy on macOS. Always name fonts explicitly.
486
-
487
- ---
488
-
489
- ## Rich text
490
-
491
- ```typescript
492
- {
493
- type: 'rich-paragraph',
494
- fontSize: 13,
495
- spans: [
496
- { text: 'Normal ' },
497
- { text: 'bold', fontWeight: 700 },
498
- { text: ' and ', fontStyle: 'italic' },
499
- { text: 'colored', color: '#e63946' },
500
- { text: ' and ' },
501
- { text: 'linked', href: 'https://example.com', underline: true, color: '#0070f3' },
502
- { text: '. Also: E=mc' },
503
- { text: '2', verticalAlign: 'superscript' },
504
- { text: ' and H' },
505
- { text: '2', verticalAlign: 'subscript' },
506
- { text: 'O.' },
507
- ],
508
- }
509
- ```
510
-
511
- ---
512
-
513
- ## Footnotes
514
-
515
- `createFootnoteSet()` produces matched reference/definition pairs with guaranteed unique IDs:
516
-
517
- ```typescript
518
- import { render, createFootnoteSet } from 'pretext-pdf'
519
-
520
- const notes = createFootnoteSet([
521
- { text: 'Smith, J. (2022). Typography in PDFs.' },
522
- { text: 'Ibid., p. 42.' },
523
- ])
524
-
525
- await render({
526
- content: [
527
- {
528
- type: 'rich-paragraph',
529
- spans: [
530
- { text: 'See the original research' },
531
- { text: '¹', verticalAlign: 'superscript', footnoteRef: notes[0]!.id },
532
- { text: ' for details.' },
533
- ],
534
- },
535
- ...notes.map(n => n.def), // footnote-def elements go at end of document
536
- ],
537
- })
538
- ```
539
-
540
- ---
541
-
542
- ## Examples
543
-
544
- ```bash
545
- npm run example # Basic invoice
546
- npm run example:gst # India GST invoice
547
- npm run example:watermark # Text/image watermarks
548
- npm run example:bookmarks # PDF outline/bookmarks
549
- npm run example:toc # Auto table of contents
550
- npm run example:rtl # Arabic/Hebrew RTL text
551
- npm run example:encryption # Password-protected PDF
552
- npm run example:hyperlinks # External + email + internal anchors
553
- npm run example:annotations # Sticky notes
554
- npm run example:assembly # Merge + assemble multiple PDFs
555
- npm run example:inline # Super/subscript, letterSpacing, smallCaps
556
- npm run example:forms # Interactive form fields
557
- npm run example:callout # Callout boxes
558
- ```
559
-
560
- All write to `output/*.pdf`.
561
-
562
- ---
563
-
564
- ## Error handling
565
-
566
- Every error throws `PretextPdfError` with a typed `code`:
567
-
568
- ```typescript
569
- import { render, PretextPdfError } from 'pretext-pdf'
570
-
571
- try {
572
- const pdf = await render(config)
573
- } catch (err) {
574
- if (err instanceof PretextPdfError) {
575
- switch (err.code) {
576
- case 'VALIDATION_ERROR': // Invalid config
577
- case 'FONT_LOAD_FAILED': // Font file not found
578
- case 'IMAGE_TOO_TALL': // Image doesn't fit on page
579
- case 'IMAGE_LOAD_FAILED': // URL fetch / safety check failed
580
- case 'ASSEMBLY_EMPTY': // merge / assemble called with empty array
581
- // ... see CHANGELOG.md for the full list
582
- }
583
- }
584
- }
585
- ```
586
-
587
- This shape is also designed for AI self-correction loops — the typed `code` is enough context for an LLM to fix its own output.
588
-
589
- ---
590
-
591
- ## Troubleshooting
592
-
593
- ### Hyphenation language not found
594
-
595
- Use **lowercase** language codes that match the npm package name:
596
-
597
- ```typescript
598
- hyphenation: { language: 'en-us' } //
599
- hyphenation: { language: 'en-US' } // ❌ fails on Linux (case-sensitive FS)
600
- ```
601
-
602
- ### SVG / chart / qr-code / barcode rendering
603
-
604
- Install `@napi-rs/canvas` (Node only — browsers use native `OffscreenCanvas`):
605
-
606
- ```bash
607
- npm install @napi-rs/canvas
608
- ```
609
-
610
- ### PDF is blank or too small
611
-
612
- Check margins. If `left + right` exceeds page width, content width becomes negative:
613
-
614
- ```typescript
615
- margins: { top: 36, bottom: 36, left: 36, right: 36 }
616
- ```
617
-
618
- ### Form fields not interactive
619
-
620
- `flattenForms: true` bakes fields into static content — by design. Remove the flag to keep them interactive.
621
-
622
- ### Browser usage
623
-
624
- Supply font bytes via `doc.fonts: [{ family: 'Inter', weight: 400, src: <Uint8Array> }]` — the bundled Inter loader is Node-only. Also register the same font with `document.fonts.add(new FontFace(...))` so pretext's measurement matches pdf-lib's drawing.
625
-
626
- ---
627
-
628
- ## Non-goals
629
-
630
- What pretext-pdf is **not** trying to be — pick a different tool for these:
631
-
632
- - **Editing or parsing existing PDFs** → [`pdf-lib`](https://github.com/Hopding/pdf-lib), [`pdf-parse`](https://www.npmjs.com/package/pdf-parse)
633
- - **Filling existing PDF form templates** → [`pdf-lib`](https://github.com/Hopding/pdf-lib), [`pdftk`](https://www.pdflabs.com/tools/pdftk-server/)
634
- - **Heavily art-directed pages** with CSS grids, SVG illustrations, floats, background images → headless Chrome (Puppeteer)
635
- - **PDF/A archival, PDF/UA accessibility tagging** → not yet
636
- - **Print-shop kerning pairs, OpenType ligatures, variable-font axes beyond weight** → upstream Pretext doesn't model these
637
-
638
- ---
639
-
640
- ## Runtime footprint
641
-
642
- Mandatory runtime dependencies:
643
-
644
- - `@cantoo/pdf-lib` PDF assembly
645
- - `@chenglou/pretext` — text-layout engine
646
- - `@fontsource/inter` + `@pdf-lib/fontkit`bundled Inter + font subsetting
647
- - `bidi-js` — bidirectional text resolution
648
- - `hypher` + `hyphenation.en-us` — hyphenation
649
-
650
- All other capabilities (SVG, charts, QR, barcodes, markdown, signing) are optional peer deps — install only what you use.
651
-
652
- **Browser:** the library imports cleanly from any non-`file://` URL (esm.sh, Vite dev server, browser bundles) since v0.8.1. Bring your own Inter font via `doc.fonts` and register it with `document.fonts.add(...)` for accurate measurement.
653
-
654
- ---
655
-
656
- ## Performance
657
-
658
- Benchmarked on Windows 11 / Node 22 / Intel i7-12th Gen. Averages over 10 runs, excluding the first cold JIT.
659
-
660
- | Document | Render time | PDF size |
661
- | --- | --- | --- |
662
- | 1 page (heading + paragraph + list) | ~220 ms | ~45 KB |
663
- | Mixed (heading + paragraph + 20-row table + list + hr) | ~290 ms | ~60 KB |
664
- | 10 pages (40 sections, mixed elements) | ~1,100 ms | ~180 KB |
665
-
666
- **Font subsetting** is automatic for TTF/OTF fonts. Only used glyphs are embeddedtypically 40–60% smaller than full-font embedding. Single-font invoices render under 65 KB.
667
-
668
- For documents with 10,000+ elements, set `NODE_OPTIONS=--max-old-space-size=4096`.
669
-
670
- ---
671
-
672
- ## Tests
673
-
674
- 600+ tests with 100% pass rate:
675
-
676
- ```bash
677
- npm test # Full suite (contract + unit + e2e + phases + 2f stress)
678
- npm run test:unit # Validation, builder, rich-text
679
- npm run test:e2e # End-to-end render
680
- npm run test:phases # All phase tests including v0.8/v0.9 features
681
- npm run test:rich # Rich-paragraph compositor (incl. v0.8.2 whitespace regressions)
682
- npm run test:contract # Public API surface contracts
683
- npm run test:visual # Pixel-diff visual regressions
684
- ```
685
-
686
- **Coverage**: type safety, path validation, SSRF, error handling, boundary cases, crypto signing, document assembly, every content element, optional-dep error codes, MCP tool validation, browser import simulation.
687
-
688
- ---
689
-
690
- ## Security
691
-
692
- A comprehensive April 2026 audit fixed 41 issues across path-traversal protection, async I/O, error sanitization, type safety, and explicit failure modes. Subsequent fixes:
693
-
694
- - **v0.8.3** IPv4-mapped IPv6 SSRF bypass closed; `fetch` redirects now revalidated per hop.
695
- - **v0.8.1** — Browser module-init crashes fixed (Node-only APIs gated behind `IS_NODE` checks).
696
-
697
- Highlights of the current security posture:
698
-
699
- - Opt-in `allowedFileDirs` lockdown for user-controlled file inputs
700
- - All error messages sanitized (no filesystem paths or secrets leak)
701
- - Async file I/O throughout (non-blocking)
702
- - Strict TypeScript with documented `any`-casts only at pdf-lib internal boundaries
703
- - HTTPS-only fetch with private-IP / SSRF guard, including IPv6
704
- - HTTP redirect chain re-validated against the same SSRF guard
705
-
706
- See [SECURITY.md](SECURITY.md) for disclosure policy.
707
-
708
- ---
709
-
710
- ## Roadmap
711
-
712
- | Phase | Feature | Status |
713
- |-------|---------|--------|
714
- | 1–6 | Core engine, pagination, typography, rich text, builder, columns | ✅ |
715
- | 7A–G | Bookmarks, watermarks, hyphenation, TOC, SVG, RTL, encryption | ✅ |
716
- | 8A–H | Annotations, forms, assembly, callouts, signatures, metadata, hyperlinks, inline formatting | ✅ |
717
- | 9A–C | Cryptographic signatures (PKCS#7), image floats, font subsetting | ✅ |
718
- | 10A–D | QR codes, barcodes, Vega-Lite charts, Markdown, templates | ✅ |
719
- | 11+ | Performance enhancements, hardening | |
720
- | **0.9.0** | **CLI, pdfmake compat shim, GFM tables + task lists** | ✅ |
721
- | Future | Variable fonts, OpenType features, PDF/A, PDF/UA accessibility | 🔜 |
722
-
723
- See [docs/ROADMAP.md](docs/ROADMAP.md).
724
-
725
- ---
726
-
727
- ## Contributing
728
-
729
- See [CONTRIBUTING.md](CONTRIBUTING.md). TDD approach — write tests first.
730
-
731
- Useful commands:
732
-
733
- ```bash
734
- npm install # one-time setup
735
- npm run build # tsc → dist/
736
- npm run typecheck # tsc --noEmit
737
- npm test # full suite
738
- npm run example # run a sample render
739
- ```
740
-
741
- ---
742
-
743
- ## License
744
-
745
- [MIT](LICENSE)
746
-
747
- ---
748
-
749
- ## Credits
750
-
751
- Built by [Himanshu Jain](https://github.com/Himaan1998Y) on the shoulders of [pretext](https://github.com/chenglou/pretext), [pdf-lib](https://github.com/Hopding/pdf-lib), and [@napi-rs/canvas](https://github.com/napi-rs/canvas).
752
-
753
- Questions? [Open an issue](https://github.com/Himaan1998Y/pretext-pdf/issues) — or try it live at the [demo](https://himaan1998y.github.io/pretext-pdf/).
1
+ # pretext-pdf
2
+
3
+ > **The PDF library AI agents speak natively — and humans love writing.**
4
+ >
5
+ > A `PdfDocument` is plain JSON. LLMs emit it in one shot — no codegen, no headless browser, no `eval`.
6
+ > Humans get a strict-typed declarative API for invoices, reports, resumes, and templates.
7
+
8
+ [![npm version](https://img.shields.io/npm/v/pretext-pdf)](https://www.npmjs.com/package/pretext-pdf)
9
+ [![npm downloads](https://img.shields.io/npm/dw/pretext-pdf)](https://www.npmjs.com/package/pretext-pdf)
10
+ [![CI](https://github.com/Himaan1998Y/pretext-pdf/actions/workflows/ci.yml/badge.svg)](https://github.com/Himaan1998Y/pretext-pdf/actions)
11
+ [![TypeScript](https://img.shields.io/badge/typescript-strict-blue)](https://www.typescriptlang.org/)
12
+ [![Tests](https://img.shields.io/badge/tests-624-brightgreen)](#tests)
13
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
14
+ [![Bundle](https://img.shields.io/badge/runtime%20deps-8-informational)](#runtime-footprint)
15
+
16
+ **[Live demo](https://himaan1998y.github.io/pretext-pdf/)** &nbsp;·&nbsp; **[`pretext-pdf-mcp`](https://www.npmjs.com/package/pretext-pdf-mcp)** (MCP server) &nbsp;·&nbsp; **[Migrating from pdfmake?](#migrating-from-pdfmake)**
17
+
18
+ *Layout powered by [`@chenglou/pretext`](https://github.com/chenglou/pretext) — the precision text-layout engine by [Cheng Lou](https://github.com/chenglou) (React core team, Midjourney).*
19
+
20
+ ---
21
+
22
+ ## Table of contents
23
+
24
+ - [Why pretext-pdf](#why-pretext-pdf)
25
+ - [Install](#install)
26
+ - [Quick start](#quick-start)
27
+ - [Library API](#library-api)
28
+ - [CLI](#cli)
29
+ - [Markdown](#markdown)
30
+ - [Templates](#templates)
31
+ - [pdfmake migration](#migrating-from-pdfmake)
32
+ - [MCP server (Claude / Cursor / Windsurf)](#mcp-server-claude--cursor--windsurf)
33
+ - [Built for AI agents](#built-for-ai-agents)
34
+ - [Element catalog](#element-catalog)
35
+ - [Document features](#document-level-features)
36
+ - [API reference](#api-reference)
37
+ - [Strict validation](#strict-validation)
38
+ - [India / GST invoicing](#india--gst-invoicing)
39
+ - [Custom fonts](#custom-fonts)
40
+ - [Rich text](#rich-text)
41
+ - [Footnotes](#footnotes)
42
+ - [Examples](#examples)
43
+ - [Error handling](#error-handling)
44
+ - [Troubleshooting](#troubleshooting)
45
+ - [Non-goals](#non-goals)
46
+ - [Runtime footprint](#runtime-footprint)
47
+ - [Performance](#performance)
48
+ - [Tests](#tests)
49
+ - [Security](#security)
50
+ - [Roadmap](#roadmap)
51
+ - [Contributing](#contributing)
52
+ - [Credits](#credits)
53
+
54
+ ---
55
+
56
+ ## Why pretext-pdf
57
+
58
+ Three established camps in JS PDF generation, and one gap. pretext-pdf lives in the gap.
59
+
60
+ | | pdfmake / jsPDF / pdfkit | Puppeteer / Playwright | LaTeX / WeasyPrint | **pretext-pdf** |
61
+ |---|---|---|---|---|
62
+ | Lightweight (no Chromium) | ✅ | ~300 MB | ❌ native binaries | ✅ |
63
+ | Pure ESM, runs in serverless | | ⚠️ painful in Lambda | | ✅ |
64
+ | Professional typography (kerning, hyphenation, RTL/CJK) | | | | ✅ |
65
+ | Declarative describe the document, don't draw it | ⚠️ partial | ❌ | ❌ | ✅ |
66
+ | **LLM emits a working document in one shot** | ❌ requires codegen loop | ❌ requires HTML+CSS knowledge | ❌ requires LaTeX knowledge | ✅ pure JSON |
67
+ | MCP server out of the box | ❌ | | | ✅ |
68
+ | Drop-in CLI for shell pipelines | ❌ | ⚠️ wrap with code | ⚠️ separate binary | ✅ `pretext-pdf in.json out.pdf` |
69
+ | pdfmake migration shim | — | ❌ | ❌ | ✅ `fromPdfmake()` |
70
+
71
+ **The headline:** every other JS PDF library asks an LLM (or you) to *write code*. pretext-pdf asks for a JSON object. That difference is what makes agent-generated PDFs reliable — and the same shape happens to be a clean declarative API for humans too.
72
+
73
+ ---
74
+
75
+ ## Install
76
+
77
+ ```bash
78
+ npm install pretext-pdf
79
+ ```
80
+
81
+ > **ESM only** — use `import`, not `require`. Requires Node.js ≥ 18.
82
+
83
+ Optional peer dependencies — install only what you use:
84
+
85
+ | Peer | When you need it |
86
+ |---|---|
87
+ | `@napi-rs/canvas` | SVG / qr-code / barcode / chart elements (Node only — browser uses `OffscreenCanvas`) |
88
+ | `qrcode` | `qr-code` element |
89
+ | `bwip-js` | `barcode` element (100+ symbologies) |
90
+ | `vega` + `vega-lite` | `chart` element |
91
+ | `marked` | `pretext-pdf/markdown` entry point and `--markdown` CLI flag |
92
+ | `@signpdf/signpdf` | PKCS#7 cryptographic signing |
93
+
94
+ > **Encryption is built-in** since v0.4.0 — no extra install.
95
+
96
+ ---
97
+
98
+ ## Quick start
99
+
100
+ ### Library API
101
+
102
+ ```typescript
103
+ import { render } from 'pretext-pdf'
104
+ import { writeFileSync } from 'fs'
105
+
106
+ const pdf = await render({
107
+ pageSize: 'A4',
108
+ margins: { top: 40, bottom: 40, left: 50, right: 50 },
109
+ metadata: { title: 'Invoice #001', author: 'Acme Corp' },
110
+ content: [
111
+ { type: 'heading', level: 1, text: 'Invoice #12345' },
112
+ { type: 'paragraph', text: 'Thank you for your business.', fontSize: 12 },
113
+ {
114
+ type: 'table',
115
+ columns: [
116
+ { width: 200 },
117
+ { width: 50, align: 'right' },
118
+ { width: 100, align: 'right' },
119
+ ],
120
+ rows: [
121
+ { isHeader: true, cells: [{ text: 'Item', fontWeight: 700 }, { text: 'Qty', fontWeight: 700 }, { text: 'Price', fontWeight: 700 }] },
122
+ { cells: [{ text: 'Professional Services' }, { text: '10' }, { text: '$1,000' }] },
123
+ { cells: [{ text: 'Hosting (annual)' }, { text: '1' }, { text: '$500' }] },
124
+ ],
125
+ },
126
+ { type: 'paragraph', text: 'Total: $1,500', align: 'right', fontWeight: 700 },
127
+ ],
128
+ })
129
+
130
+ writeFileSync('invoice.pdf', pdf)
131
+ ```
132
+
133
+ ### CLI
134
+
135
+ `pretext-pdf` ships with a binary that turns a JSON or Markdown file into a PDF — no Node code required.
136
+
137
+ ```bash
138
+ # JSON in, PDF out
139
+ pretext-pdf doc.json invoice.pdf
140
+
141
+ # Stdin stdout (pipe-friendly)
142
+ echo '{"content":[{"type":"heading","level":1,"text":"Hi"}]}' | pretext-pdf > out.pdf
143
+
144
+ # Markdown straight to PDF
145
+ pretext-pdf --markdown --code-font 'Courier New' README.md docs.pdf
146
+
147
+ # Help / version
148
+ pretext-pdf --help
149
+ pretext-pdf --version
150
+ ```
151
+
152
+ | Flag | Meaning |
153
+ |---|---|
154
+ | `-i, --input <path>` | Read input from file (default: first positional, or stdin) |
155
+ | `-o, --output <path>` | Write PDF to file (default: second positional, or stdout) |
156
+ | `--markdown` | Treat input as Markdown converts via `pretext-pdf/markdown` |
157
+ | `--code-font <name>` | With `--markdown`, font family for fenced code blocks |
158
+ | `-v, --version` | Print version |
159
+ | `-h, --help` | Print help |
160
+
161
+ Exit codes: `0` success, `1` user error (bad args, invalid JSON), `2` render error.
162
+
163
+ ### Markdown
164
+
165
+ Convert any Markdown string to `ContentElement[]` in one call. Requires `marked` peer dep.
166
+
167
+ ```typescript
168
+ import { markdownToContent } from 'pretext-pdf/markdown'
169
+ import { render } from 'pretext-pdf'
170
+
171
+ const md = `
172
+ # Q1 2026 Report
173
+
174
+ Revenue grew **18%** year-over-year.
175
+
176
+ | Metric | Q4 2025 | Q1 2026 | Change |
177
+ |--------|--------:|--------:|:------:|
178
+ | Revenue | $45M | $60M | +33% |
179
+ | Margin | 62% | 68% | +6pp |
180
+
181
+ - [x] Cloud expansion launched
182
+ - [x] Enterprise pipeline doubled
183
+ - [ ] APAC region opening Q2
184
+
185
+ > All figures in USD millions.
186
+ `
187
+
188
+ const content = await markdownToContent(md, { codeFontFamily: 'Courier New' })
189
+ const pdf = await render({ content })
190
+ ```
191
+
192
+ Supported: headings h1–h4, bold, italic, strikethrough, inline code, links, ordered/unordered lists (recursive nesting), **GFM tables (with column alignment)**, **GFM task lists** (☑/☐), fenced code blocks, blockquotes, horizontal rules.
193
+
194
+ ### Templates
195
+
196
+ Pre-built zero-dependency template functions:
197
+
198
+ ```typescript
199
+ import { createInvoice, createGstInvoice, createReport } from 'pretext-pdf/templates'
200
+ import { render } from 'pretext-pdf'
201
+
202
+ const content = createInvoice({
203
+ from: { name: 'Acme Corp', address: '123 Main St', email: 'billing@acme.com' },
204
+ to: { name: 'Client Ltd', address: '456 Oak Ave' },
205
+ invoiceNumber: 'INV-2026-001',
206
+ date: '2026-04-20',
207
+ items: [{ description: 'Consulting', quantity: 10, unitPrice: 150 }],
208
+ currency: '$', taxRate: 10, taxLabel: 'GST',
209
+ qrData: 'upi://pay?pa=acme@bank&am=1650',
210
+ })
211
+ const pdf = await render({ content })
212
+ ```
213
+
214
+ Available: `createInvoice` (any currency), `createGstInvoice` (India GST/IGST/CGST+SGST + UPI QR + amount-in-words), `createReport` (with optional TOC).
215
+
216
+ ### Migrating from pdfmake
217
+
218
+ `pretext-pdf/compat` translates pdfmake document descriptors into a `PdfDocument` — most common patterns work without code changes.
219
+
220
+ ```typescript
221
+ import { fromPdfmake } from 'pretext-pdf/compat'
222
+ import { render } from 'pretext-pdf'
223
+
224
+ // Existing pdfmake document, unchanged
225
+ const pdfmakeDoc = {
226
+ pageSize: 'LETTER',
227
+ pageMargins: [40, 60, 40, 60],
228
+ defaultStyle: { fontSize: 11 },
229
+ styles: {
230
+ header: { fontSize: 22, bold: true },
231
+ subheader: { fontSize: 16 },
232
+ },
233
+ content: [
234
+ { text: 'Invoice #001', style: 'header' },
235
+ { text: 'Acme Corp', style: 'subheader' },
236
+ 'Thanks for your business.',
237
+ {
238
+ table: {
239
+ widths: ['*', 'auto', 80],
240
+ headerRows: 1,
241
+ body: [
242
+ ['Item', 'Qty', 'Price'],
243
+ ['Widget', '3', '$30'],
244
+ ['Sprocket', '5', '$50'],
245
+ ],
246
+ },
247
+ },
248
+ { ul: ['Net 30 terms', 'Late fee: 1.5%/mo'] },
249
+ ],
250
+ }
251
+
252
+ const pdf = await render(fromPdfmake(pdfmakeDoc))
253
+ ```
254
+
255
+ | pdfmake feature | Compat support |
256
+ |---|---|
257
+ | `string` content | ✅ → paragraph |
258
+ | `{ text, bold, italics, color, fontSize, alignment, font }` | paragraph or rich-paragraph |
259
+ | `{ text, style: 'header' }` (style lookup) | ✅ `header`/`h1`/`title` map to heading 1, `subheader`/`h2` to 2, etc. |
260
+ | `{ ul }` / `{ ol }` (recursive) | ✅ → list |
261
+ | `{ table: { body, widths, headerRows } }` | ✅ → table |
262
+ | `{ image, width, height }` | ✅ → image |
263
+ | `{ qr, fit }` | ✅ → qr-code |
264
+ | `{ pageBreak: 'before' \| 'after' }` | ✅ → page-break |
265
+ | `{ stack }` | ✅ → flattened inline |
266
+ | `{ link }` on inline text | ✅ → span.href |
267
+ | `pageSize`, `pageOrientation`, `pageMargins` | ✅ |
268
+ | `info` (title/author/subject/keywords) | ✅ → metadata |
269
+ | `header`, `footer` (string form) | |
270
+ | `{ columns }` | ⚠️ flattened with a warning |
271
+ | `{ canvas }` | ❌ unsupported (drawing primitives) |
272
+ | Function-style `header`/`footer` | ❌ pass a string |
273
+
274
+ Override the heading-name mapping via `fromPdfmake(doc, { headingMap: { ... } })`.
275
+
276
+ ### MCP server (Claude / Cursor / Windsurf)
277
+
278
+ Drop into any MCP-aware AI agent in 60 seconds:
279
+
280
+ ```json
281
+ {
282
+ "mcpServers": {
283
+ "pretext-pdf": {
284
+ "command": "npx",
285
+ "args": ["-y", "pretext-pdf-mcp"]
286
+ }
287
+ }
288
+ }
289
+ ```
290
+
291
+ Exposes: `generate_pdf`, `generate_invoice`, `generate_report`, `generate_from_markdown`, `list_element_types`. Versioned alongside this library — see [`pretext-pdf-mcp`](https://www.npmjs.com/package/pretext-pdf-mcp).
292
+
293
+ ---
294
+
295
+ ## Built for AI agents
296
+
297
+ A `PdfDocument` is a plain JSON object. No functions are required. Every field is optional except `type` and a few element-specific essentials. That shape is exactly what an LLM can produce reliably with no tool-use loop.
298
+
299
+ ```typescript
300
+ import { render } from 'pretext-pdf'
301
+
302
+ // Whatever produced this JSON — Claude, GPT, a workflow node, a form submission — works the same
303
+ const pdf = await render({
304
+ metadata: { title: 'AI-generated quarterly report' },
305
+ content: [
306
+ { type: 'heading', level: 1, text: 'Q1 2026 Summary' },
307
+ { type: 'paragraph', text: 'Revenue grew 18% YoY.' },
308
+ { type: 'table', columns: [/* ... */], rows: [/* ... */] },
309
+ ],
310
+ })
311
+ ```
312
+
313
+ ### Why JSON-first matters for agents
314
+
315
+ - **No code execution loop.** Model returns JSON; you call `render()`. No sandbox, no `vm`, no Vercel Sandbox roundtrip.
316
+ - **Schema-validatable.** Strict TypeScript types double as the contract. Pair with [Anthropic tool use](https://docs.anthropic.com/en/docs/build-with-claude/tool-use) or [Vercel AI SDK structured output](https://sdk.vercel.ai/docs/ai-sdk-core/generating-structured-data).
317
+ - **Self-correcting errors.** Every failure throws `PretextPdfError` with a typed `code`. Feed it back to the model and it fixes itself.
318
+ - **Progressive disclosure.** Optional peer deps mean agents only ask for QR codes, charts, or markdown when needed — token-efficient prompts.
319
+
320
+ ---
321
+
322
+ ## Element catalog
323
+
324
+ ```
325
+ paragraph heading(1-4) spacer hr page-break
326
+ table image svg list code
327
+ blockquote rich-paragraph callout comment form-field
328
+ toc qr-code barcode chart footnote-def
329
+ ```
330
+
331
+ | Element | What it does |
332
+ | --- | --- |
333
+ | `paragraph` | Text block — font, size, color, align, background, letterSpacing, smallCaps, tabularNumbers, multi-column (`columns` + `columnGap`), RTL (`dir`) |
334
+ | `heading` | H1–H4 with bookmarks, URL links, internal anchors, tabularNumbers, RTL |
335
+ | `table` | Fixed/proportional/auto columns, colspan, rowspan, repeating headers across page breaks |
336
+ | `image` | PNG/JPG/WebP with sizing, alignment, float left/right with `floatText` or rich `floatSpans` |
337
+ | `list` | Ordered/unordered, recursive nesting, `nestedNumberingStyle: 'restart' \| 'continue'` |
338
+ | `code` | Monospace block with background and padding |
339
+ | `blockquote` | Left border + background |
340
+ | `rich-paragraph` | Mixed bold/italic/color/size/super/subscript spans with inline hyperlinks |
341
+ | `svg` | Embedded SVG graphics with auto-sizing from viewBox |
342
+ | `toc` | Auto-generated table of contents with accurate page numbers (two-pass) |
343
+ | `qr-code` | Scannable QR code UPI, URLs, vCards. Requires `qrcode` peer dep. |
344
+ | `barcode` | 100+ symbologies — EAN-13, Code128, PDF417, DataMatrix, etc. Requires `bwip-js`. |
345
+ | `chart` | Vega-Lite data visualisation as vector SVG. Requires `vega` + `vega-lite`. |
346
+ | `comment` | PDF sticky-note annotation (visible in Acrobat/Preview sidebar) |
347
+ | `form-field` | Interactive text/checkbox/radio/dropdown/button (with `flattenForms` to bake) |
348
+ | `callout` | Info / warning / tip / note callout boxes |
349
+ | `footnote-def` | Paired with `span.footnoteRef` for proper footnote numbering + zone reservation |
350
+ | `hr` / `spacer` / `page-break` | Layout primitives |
351
+
352
+ ### Document-level features
353
+
354
+ | Feature | Config key | Notes |
355
+ | --- | --- | --- |
356
+ | Watermarks | `doc.watermark` | Text or image, opacity, rotation |
357
+ | Encryption | `doc.encryption` | Password + granular permissions, built-in |
358
+ | Cryptographic signing | `doc.signature: { p12, passphrase, ... }` | PKCS#7, optional `@signpdf/signpdf` |
359
+ | PDF Bookmarks | `doc.bookmarks` | Auto-generated from headings |
360
+ | Hyphenation | `doc.hyphenation` | Liang's algorithm, e.g. `language: 'en-us'` |
361
+ | Headers/Footers | `doc.header` / `doc.footer` | `{{pageNumber}}`, `{{totalPages}}`, `{{date}}` tokens |
362
+ | Per-section overrides | `doc.sections` | Different header/footer per page range |
363
+ | Metadata | `doc.metadata` | Title, author, subject, keywords, language, producer |
364
+ | Hyperlinks | `paragraph.url`, `heading.url`, `heading.anchor`, `span.href` | External, mailto, internal anchors |
365
+ | Document assembly | `merge(pdfs)`, `assemble(parts)` | Combine pre-rendered + freshly rendered |
366
+ | Path-traversal lockdown | `doc.allowedFileDirs` | Restrict file-source reads to listed dirs |
367
+
368
+ ---
369
+
370
+ ## API reference
371
+
372
+ ### `render(doc): Promise<Uint8Array>`
373
+
374
+ ```typescript
375
+ import { render } from 'pretext-pdf'
376
+
377
+ const pdf = await render({
378
+ pageSize: 'A4', // 'A4' | 'A3' | 'A5' | 'Letter' | 'Legal' | 'Tabloid' | [w, h]
379
+ margins: { top: 72, bottom: 72, left: 72, right: 72 },
380
+ defaultFont: 'Inter', // Inter 400/700 bundled
381
+ defaultFontSize: 12,
382
+ metadata: { title: '...', author: '...', keywords: ['pdf'] },
383
+ watermark: { text: 'DRAFT', opacity: 0.15, rotation: -45 },
384
+ encryption: { userPassword: 'open', ownerPassword: 'admin', permissions: { printing: true, copying: false } },
385
+ bookmarks: { minLevel: 1, maxLevel: 3 },
386
+ hyphenation: { language: 'en-us', minWordLength: 6 },
387
+ header: { text: '{{pageNumber}} of {{totalPages}}', align: 'right' },
388
+ footer: { text: 'Confidential', align: 'center', color: '#999' },
389
+ content: [ /* ContentElement[] */ ],
390
+ })
391
+ ```
392
+
393
+ ### `merge(pdfs): Promise<Uint8Array>`
394
+
395
+ Combine pre-rendered PDFs:
396
+
397
+ ```typescript
398
+ import { merge } from 'pretext-pdf'
399
+ const combined = await merge([coverPdf, bodyPdf, appendixPdf])
400
+ ```
401
+
402
+ ### `assemble(parts): Promise<Uint8Array>`
403
+
404
+ Mix new docs with existing PDFs:
405
+
406
+ ```typescript
407
+ import { assemble } from 'pretext-pdf'
408
+
409
+ const report = await assemble([
410
+ { pdf: existingCoverPdf },
411
+ { doc: { content: [/* fresh */] } },
412
+ { pdf: standardTermsPdf },
413
+ ])
414
+ ```
415
+
416
+ ### `createPdf(opts): PdfBuilder` (fluent builder)
417
+
418
+ ```typescript
419
+ import { createPdf } from 'pretext-pdf'
420
+
421
+ const pdf = await createPdf({ pageSize: 'A4' })
422
+ .addHeading('My Report', 1)
423
+ .addText('Fluent chainable API.')
424
+ .addTable({ columns: [{ name: 'Col A' }, { name: 'Col B' }], rows: [{ 'Col A': 'x', 'Col B': 'y' }] })
425
+ .build()
426
+ ```
427
+
428
+ ### `markdownToContent(md, opts?)` *(from `pretext-pdf/markdown`)*
429
+
430
+ ### `createInvoice / createGstInvoice / createReport` *(from `pretext-pdf/templates`)*
431
+
432
+ ### `fromPdfmake(doc, opts?)` *(from `pretext-pdf/compat`)*
433
+
434
+ ---
435
+
436
+ ## Strict validation
437
+
438
+ By default, `render()` uses permissive validation — unknown properties are silently ignored. Enable strict mode to catch typos and ensure property names match the schema exactly:
439
+
440
+ ```typescript
441
+ import { render } from 'pretext-pdf'
442
+
443
+ const pdf = await render(doc, { strict: true })
444
+ ```
445
+
446
+ In strict mode:
447
+
448
+ - **Unknown properties are rejected** with a `VALIDATION_ERROR` that includes:
449
+ - Property name and location (JSONPath-like: `content[3].table.rows[0].cells[1].align`)
450
+ - Typo suggestions via Levenshtein distance (edit distance ≤2)
451
+ - All violations collected before throwing, with a 20-error cap + overflow indicator
452
+
453
+ Example error:
454
+
455
+ ```
456
+ VALIDATION_ERROR:
457
+ unknown property 'fontSizee' at content[0].fontSizee (did you mean fontsize, fontSize?)
458
+ unknown property 'colorr' at content[1].inline.colorr (did you mean color?)
459
+ ```
460
+
461
+ Strict validation is useful for:
462
+ - **AI agent self-correction**: LLMs can parse error messages and fix typos
463
+ - **Template development**: catch copy-paste errors in large documents
464
+ - **Type safety**: ensure your generator is emitting well-formed documents
465
+
466
+ You can also call `validate()` standalone for testing:
467
+
468
+ ```typescript
469
+ import { validate } from 'pretext-pdf'
470
+
471
+ // Throws PretextPdfError('VALIDATION_ERROR', ...) if strict check fails
472
+ validate(doc, { strict: true })
473
+ ```
474
+
475
+ ---
476
+
477
+ ## India / GST invoicing
478
+
479
+ Built-in support for Indian invoice requirements:
480
+
481
+ - **₹ symbol** renders correctly (bundled Inter includes the Rupee glyph)
482
+ - **Indian number formatting** (`1,00,000` not `100,000`)
483
+ - **GST structure** — CGST/SGST (intra-state) and IGST (inter-state) layouts (auto-detected from state fields)
484
+ - **Amount in words** — Indian numbering system (Lakh/Crore), with correct sub-rupee handling
485
+ - **SAC/HSN codes**column support in line-item tables
486
+
487
+ ```typescript
488
+ import { createGstInvoice } from 'pretext-pdf/templates'
489
+ import { render } from 'pretext-pdf'
490
+
491
+ const content = createGstInvoice({
492
+ supplier: { name: 'Antigravity Systems', address: 'Gurugram, HR', gstin: '06AAACA1234A1ZV', state: 'Haryana' },
493
+ buyer: { name: 'TechStartup Ltd', address: 'Mumbai, MH', gstin: '27AABCB5678B1ZP', state: 'Maharashtra' },
494
+ invoiceNumber: 'INV/2026-27/001',
495
+ invoiceDate: '20 Apr 2026',
496
+ placeOfSupply: 'Maharashtra (27)',
497
+ items: [
498
+ { description: 'Software Development', hsnSac: '998314', quantity: 80, unit: 'Hrs', rate: 3000, taxRate: 18 },
499
+ ],
500
+ qrUpiData: 'upi://pay?pa=merchant@hdfc&pn=Antigravity&am=283200',
501
+ bankName: 'HDFC Bank', accountNumber: '501001234567', ifscCode: 'HDFC0001234',
502
+ })
503
+ const pdf = await render({ content })
504
+ ```
505
+
506
+ See [`examples/gst-invoice-india.ts`](examples/gst-invoice-india.ts) for a fully wired example.
507
+
508
+ ---
509
+
510
+ ## Custom fonts
511
+
512
+ ```typescript
513
+ const pdf = await render({
514
+ fonts: [
515
+ { family: 'Roboto', weight: 400, src: '/path/to/Roboto-Regular.ttf' },
516
+ { family: 'Roboto', weight: 700, src: '/path/to/Roboto-Bold.ttf' },
517
+ { family: 'Roboto', style: 'italic', src: '/path/to/Roboto-Italic.ttf' },
518
+ ],
519
+ defaultFont: 'Roboto',
520
+ content: [
521
+ { type: 'paragraph', text: 'Uses Roboto' },
522
+ { type: 'paragraph', text: 'Bold', fontWeight: 700 },
523
+ ],
524
+ })
525
+ ```
526
+
527
+ > **Avoid `system-ui`** — known Pretext layout-measurement inaccuracy on macOS. Always name fonts explicitly.
528
+
529
+ ---
530
+
531
+ ## Rich text
532
+
533
+ ```typescript
534
+ {
535
+ type: 'rich-paragraph',
536
+ fontSize: 13,
537
+ spans: [
538
+ { text: 'Normal ' },
539
+ { text: 'bold', fontWeight: 700 },
540
+ { text: ' and ', fontStyle: 'italic' },
541
+ { text: 'colored', color: '#e63946' },
542
+ { text: ' and ' },
543
+ { text: 'linked', href: 'https://example.com', underline: true, color: '#0070f3' },
544
+ { text: '. Also: E=mc' },
545
+ { text: '2', verticalAlign: 'superscript' },
546
+ { text: ' and H' },
547
+ { text: '2', verticalAlign: 'subscript' },
548
+ { text: 'O.' },
549
+ ],
550
+ }
551
+ ```
552
+
553
+ ---
554
+
555
+ ## Footnotes
556
+
557
+ `createFootnoteSet()` produces matched reference/definition pairs with guaranteed unique IDs:
558
+
559
+ ```typescript
560
+ import { render, createFootnoteSet } from 'pretext-pdf'
561
+
562
+ const notes = createFootnoteSet([
563
+ { text: 'Smith, J. (2022). Typography in PDFs.' },
564
+ { text: 'Ibid., p. 42.' },
565
+ ])
566
+
567
+ await render({
568
+ content: [
569
+ {
570
+ type: 'rich-paragraph',
571
+ spans: [
572
+ { text: 'See the original research' },
573
+ { text: '¹', verticalAlign: 'superscript', footnoteRef: notes[0]!.id },
574
+ { text: ' for details.' },
575
+ ],
576
+ },
577
+ ...notes.map(n => n.def), // footnote-def elements go at end of document
578
+ ],
579
+ })
580
+ ```
581
+
582
+ ---
583
+
584
+ ## Examples
585
+
586
+ ```bash
587
+ npm run example # Basic invoice
588
+ npm run example:gst # India GST invoice
589
+ npm run example:watermark # Text/image watermarks
590
+ npm run example:bookmarks # PDF outline/bookmarks
591
+ npm run example:toc # Auto table of contents
592
+ npm run example:rtl # Arabic/Hebrew RTL text
593
+ npm run example:encryption # Password-protected PDF
594
+ npm run example:hyperlinks # External + email + internal anchors
595
+ npm run example:annotations # Sticky notes
596
+ npm run example:assembly # Merge + assemble multiple PDFs
597
+ npm run example:inline # Super/subscript, letterSpacing, smallCaps
598
+ npm run example:forms # Interactive form fields
599
+ npm run example:callout # Callout boxes
600
+ ```
601
+
602
+ All write to `output/*.pdf`.
603
+
604
+ ---
605
+
606
+ ## Error handling
607
+
608
+ Every error throws `PretextPdfError` with a typed `code`:
609
+
610
+ ```typescript
611
+ import { render, PretextPdfError } from 'pretext-pdf'
612
+
613
+ try {
614
+ const pdf = await render(config)
615
+ } catch (err) {
616
+ if (err instanceof PretextPdfError) {
617
+ switch (err.code) {
618
+ case 'VALIDATION_ERROR': // Invalid config
619
+ case 'FONT_LOAD_FAILED': // Font file not found
620
+ case 'IMAGE_TOO_TALL': // Image doesn't fit on page
621
+ case 'IMAGE_LOAD_FAILED': // URL fetch / safety check failed
622
+ case 'ASSEMBLY_EMPTY': // merge / assemble called with empty array
623
+ // ... see CHANGELOG.md for the full list
624
+ }
625
+ }
626
+ }
627
+ ```
628
+
629
+ This shape is also designed for AI self-correction loops — the typed `code` is enough context for an LLM to fix its own output.
630
+
631
+ ---
632
+
633
+ ## Troubleshooting
634
+
635
+ ### Hyphenation language not found
636
+
637
+ Use **lowercase** language codes that match the npm package name:
638
+
639
+ ```typescript
640
+ hyphenation: { language: 'en-us' } // ✅
641
+ hyphenation: { language: 'en-US' } // ❌ fails on Linux (case-sensitive FS)
642
+ ```
643
+
644
+ ### SVG / chart / qr-code / barcode rendering
645
+
646
+ Install `@napi-rs/canvas` (Node onlybrowsers use native `OffscreenCanvas`):
647
+
648
+ ```bash
649
+ npm install @napi-rs/canvas
650
+ ```
651
+
652
+ ### PDF is blank or too small
653
+
654
+ Check margins. If `left + right` exceeds page width, content width becomes negative:
655
+
656
+ ```typescript
657
+ margins: { top: 36, bottom: 36, left: 36, right: 36 }
658
+ ```
659
+
660
+ ### Form fields not interactive
661
+
662
+ `flattenForms: true` bakes fields into static content by design. Remove the flag to keep them interactive.
663
+
664
+ ### Browser usage
665
+
666
+ Supply font bytes via `doc.fonts: [{ family: 'Inter', weight: 400, src: <Uint8Array> }]` the bundled Inter loader is Node-only. Also register the same font with `document.fonts.add(new FontFace(...))` so pretext's measurement matches pdf-lib's drawing.
667
+
668
+ ---
669
+
670
+ ## Non-goals
671
+
672
+ What pretext-pdf is **not** trying to be — pick a different tool for these:
673
+
674
+ - **Editing or parsing existing PDFs** → [`pdf-lib`](https://github.com/Hopding/pdf-lib), [`pdf-parse`](https://www.npmjs.com/package/pdf-parse)
675
+ - **Filling existing PDF form templates** → [`pdf-lib`](https://github.com/Hopding/pdf-lib), [`pdftk`](https://www.pdflabs.com/tools/pdftk-server/)
676
+ - **Heavily art-directed pages** with CSS grids, SVG illustrations, floats, background images → headless Chrome (Puppeteer)
677
+ - **PDF/A archival, PDF/UA accessibility tagging** not yet
678
+ - **Print-shop kerning pairs, OpenType ligatures, variable-font axes beyond weight** → upstream Pretext doesn't model these
679
+
680
+ ---
681
+
682
+ ## Runtime footprint
683
+
684
+ Mandatory runtime dependencies:
685
+
686
+ - `@cantoo/pdf-lib` PDF assembly
687
+ - `@chenglou/pretext` — text-layout engine
688
+ - `@fontsource/inter` + `@pdf-lib/fontkit` — bundled Inter + font subsetting
689
+ - `bidi-js` — bidirectional text resolution
690
+ - `hypher` + `hyphenation.en-us` — hyphenation
691
+
692
+ All other capabilities (SVG, charts, QR, barcodes, markdown, signing) are optional peer deps install only what you use.
693
+
694
+ **Browser:** the library imports cleanly from any non-`file://` URL (esm.sh, Vite dev server, browser bundles) since v0.8.1. Bring your own Inter font via `doc.fonts` and register it with `document.fonts.add(...)` for accurate measurement.
695
+
696
+ ---
697
+
698
+ ## Performance
699
+
700
+ Benchmarked on Windows 11 / Node 22 / Intel i7-12th Gen. Averages over 10 runs, excluding the first cold JIT.
701
+
702
+ | Document | Render time | PDF size |
703
+ | --- | --- | --- |
704
+ | 1 page (heading + paragraph + list) | ~220 ms | ~45 KB |
705
+ | Mixed (heading + paragraph + 20-row table + list + hr) | ~290 ms | ~60 KB |
706
+ | 10 pages (40 sections, mixed elements) | ~1,100 ms | ~180 KB |
707
+
708
+ **Font subsetting** is automatic for TTF/OTF fonts. Only used glyphs are embedded — typically 40–60% smaller than full-font embedding. Single-font invoices render under 65 KB.
709
+
710
+ For documents with 10,000+ elements, set `NODE_OPTIONS=--max-old-space-size=4096`.
711
+
712
+ ---
713
+
714
+ ## Tests
715
+
716
+ 600+ tests with 100% pass rate:
717
+
718
+ ```bash
719
+ npm test # Full suite (contract + unit + e2e + phases + 2f stress)
720
+ npm run test:unit # Validation, builder, rich-text
721
+ npm run test:e2e # End-to-end render
722
+ npm run test:phases # All phase tests including v0.8/v0.9 features
723
+ npm run test:rich # Rich-paragraph compositor (incl. v0.8.2 whitespace regressions)
724
+ npm run test:contract # Public API surface contracts
725
+ npm run test:visual # Pixel-diff visual regressions
726
+ ```
727
+
728
+ **Coverage**: type safety, path validation, SSRF, error handling, boundary cases, crypto signing, document assembly, every content element, optional-dep error codes, MCP tool validation, browser import simulation.
729
+
730
+ ---
731
+
732
+ ## Security
733
+
734
+ A comprehensive April 2026 audit fixed 41 issues across path-traversal protection, async I/O, error sanitization, type safety, and explicit failure modes. Subsequent fixes:
735
+
736
+ - **v0.8.3** IPv4-mapped IPv6 SSRF bypass closed; `fetch` redirects now revalidated per hop.
737
+ - **v0.8.1** Browser module-init crashes fixed (Node-only APIs gated behind `IS_NODE` checks).
738
+
739
+ Highlights of the current security posture:
740
+
741
+ - Opt-in `allowedFileDirs` lockdown for user-controlled file inputs
742
+ - All error messages sanitized (no filesystem paths or secrets leak)
743
+ - Async file I/O throughout (non-blocking)
744
+ - Strict TypeScript with documented `any`-casts only at pdf-lib internal boundaries
745
+ - HTTPS-only fetch with private-IP / SSRF guard, including IPv6
746
+ - HTTP redirect chain re-validated against the same SSRF guard
747
+
748
+ See [SECURITY.md](SECURITY.md) for disclosure policy.
749
+
750
+ ---
751
+
752
+ ## Roadmap
753
+
754
+ | Phase | Feature | Status |
755
+ |-------|---------|--------|
756
+ | 1–6 | Core engine, pagination, typography, rich text, builder, columns | ✅ |
757
+ | 7A–G | Bookmarks, watermarks, hyphenation, TOC, SVG, RTL, encryption | ✅ |
758
+ | 8A–H | Annotations, forms, assembly, callouts, signatures, metadata, hyperlinks, inline formatting | ✅ |
759
+ | 9A–C | Cryptographic signatures (PKCS#7), image floats, font subsetting | ✅ |
760
+ | 10A–D | QR codes, barcodes, Vega-Lite charts, Markdown, templates | ✅ |
761
+ | 11+ | Performance enhancements, hardening | ✅ |
762
+ | **0.9.0** | **CLI, pdfmake compat shim, GFM tables + task lists** | ✅ |
763
+ | Future | Variable fonts, OpenType features, PDF/A, PDF/UA accessibility | 🔜 |
764
+
765
+ See [docs/ROADMAP.md](docs/ROADMAP.md).
766
+
767
+ ---
768
+
769
+ ## Contributing
770
+
771
+ See [CONTRIBUTING.md](CONTRIBUTING.md). TDD approach — write tests first.
772
+
773
+ Useful commands:
774
+
775
+ ```bash
776
+ npm install # one-time setup
777
+ npm run build # tsc → dist/
778
+ npm run typecheck # tsc --noEmit
779
+ npm test # full suite
780
+ npm run example # run a sample render
781
+ ```
782
+
783
+ ---
784
+
785
+ ## License
786
+
787
+ [MIT](LICENSE)
788
+
789
+ ---
790
+
791
+ ## Credits
792
+
793
+ Built by [Himanshu Jain](https://github.com/Himaan1998Y) on the shoulders of [pretext](https://github.com/chenglou/pretext), [pdf-lib](https://github.com/Hopding/pdf-lib), and [@napi-rs/canvas](https://github.com/napi-rs/canvas).
794
+
795
+ Questions? [Open an issue](https://github.com/Himaan1998Y/pretext-pdf/issues) — or try it live at the [demo](https://himaan1998y.github.io/pretext-pdf/).