PDF documents
pdfRender PDFs from layouts and templates in the reader's language with the built-in renderer, Gotenberg or DocRaptor; the rendering half of documents.
documentsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
pdf renders invoices, receipts, certificates and contracts from templates, with text, money and dates in the reader's language through i18n.
The real call is documents.render; pdf.render(template, data) is an alias of it, kept for the short name.
At a glance
pdf:
driver: builtinimport { pdf } from "mainsdk";
const invoice = await pdf.render("invoice", { order, locale: user.locale });lib/invoice.test.ts
import { it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { documents } from "mainsdk";
it("documents AC11 renders an invoice and reads it back", async () => {
createTestApp({ main: "./main.yaml" });
const ref = await documents.render(
{ body: [{ text: "Invoice INV-7", style: "h1" }] },
{},
{ owner: "u1" },
);
const doc = await documents.parse(ref, { as: "u1" });
assert.match(doc.pages[0]?.text ?? "", /Invoice INV-7/);
});Drivers
4 drivers. Select one to see its line in main.yaml.
pdf:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-DOC-0011 | Checkmainsdk check, before deploy | A locale with a complex script renders with the builtin renderer, which doesn't shape it |
MAIN-DOC-0007 | RuntimeError responses and logs | A render layout or template is invalid (400) |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Render from a template | Phase 1 | 0.4 | documents#render |
| HTML to PDF through Gotenberg or DocRaptor | Phase 1 | 0.4 | documents#render-drivers-checks |
| Sign a document | Phase 3 | 1.2 | signed-approvals |
Quick start
documents:
render: { driver: builtin }
permissions:
disk:
read: [templates]import { documents } from "mainsdk";
export async function invoiceFor(order: { number: string }, userId: string, locale: string) {
return documents.render("invoice", order, { owner: userId, locale });
}templates/invoice.pdf.yaml holds the layout. The call returns a FileRef: the PDF is already stored through files, owned by owner, in the tenant's region.
Templates and layouts
documents.render takes either a template name or a layout object.
page: A4 # A4 | Letter
margin: 20mm
body:
- text: "Invoice {{number}}"
style: h1 # h1 | h2 | body | small
- table:
head: ["Item", "Qty", "Price"]
rows:
each: items # repeats the row for every item; the header repeats on each page
cells: ["{{name}}", "{{qty}}", "{{price | money:currency}}"]
- text: "Total {{total}}"
style: h2- Elements are
text,table,image(aFileRef),spacerandpageBreak. {{path}}reads from your data. A missing value fails withMAIN-DOC-0007; nothing is evaluated.- A value written as
{ money: 125000, currency: "INR" },{ number: 4 }or{ date: "2026-10-27" }is formatted for the locale. The filter{{price | money:currency}}does the same for a plain number. - The template is found along the locale chain:
templates/<locale>/<name>.pdf.yaml, then the fallbacks, thentemplates/<name>.pdf.yaml. Declare the folder underpermissions.disk.read. - Page numbers ("Page 1 of 2") are added for you.
Configuration
documents:
render:
driver: builtin # builtin | gotenberg | docraptor
fonts: [assets/fonts/Inter.ttf] # embedded and subset per document
url: https://gotenberg.example.com # gotenberg only
key: secret:DOCRAPTOR_API_KEY # docraptor only
permissions:
disk:
read: [templates, assets/fonts]| Key | Default | What it does |
|---|---|---|
documents.render.driver | builtin | The renderer |
documents.render.fonts | none | TrueType fonts to embed. The first is the default; set font: in a layout to pick another |
documents.render.url | none | Where your Gotenberg runs |
documents.render.key | none | The DocRaptor key, as a secret: reference |
Without a font the built-in renderer uses a standard PDF font, which has no rupee sign, for example. Embed a TrueType font that has the characters you print.
What you get
- PDFs from a layout or a template, rendered by the built-in renderer, Gotenberg or DocRaptor.
- Text, money and dates in the reader's language, with a missing translation caught by the
i18ncheck. - Output stored through
fileswith an owner, ready to share with an expiring link. - Template values sanitized before an HTML driver sees them, and no fetches outside
network.allow.
Examples
Render an invoice for a signed-in customer
import { auth, documents, models, s } from "mainsdk";
const { orders } = models({
orders: s.object({ item: s.string(), total: s.int(), locale: s.string() }),
}); // declared once in lib/orders.ts, shown inline so the example stands alone
export async function customerInvoice(req: Request, orderId: string) {
const session = await auth.require(req);
const order = await orders.for(session.user).get(orderId);
return documents.render("invoice", { ...order }, { owner: session.user, locale: order.locale });
}orders.for(session.user) means a customer can only render invoices for their own orders.
Render from a layout in code
import { documents } from "mainsdk";
export function receipt(userId: string, lines: string[][]) {
return documents.render(
{ page: "A4", body: [{ text: "Receipt", style: "h1" }, { table: { head: ["Item", "Qty"], rows: lines } }] },
{},
{ owner: userId, title: "Receipt" },
);
}Store and share a certificate
import { documents, share } from "mainsdk";
export async function issueCertificate(user: { id: string; locale: string }, course: { name: string }) {
const ref = await documents.render("certificate", { course }, { owner: user.id, locale: user.locale });
return share(ref, { expiresIn: "30d" });
}Files and links go through getqr.id; see File uploads and Share links and QR codes.
Sign a contract (planned)
Signing a rendered PDF is planned for phase 3 and has no code. A signing driver (Documenso) is named in the catalog.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-DOC-0007 | The layout, template, font or a value is missing or invalid | Add templates/<name>.pdf.yaml, declare the folder under permissions.disk.read, or give the value the template names |
MAIN-FILES-0002 | No owner was passed and no person is signed in | Pass { owner: user } to documents.render |
MAIN-PERM-0002 | The templates or fonts folder is not under permissions.disk.read | Add it in main.yaml |
MAIN-DOC-0011 | In check: a complex-script locale (Devanagari, Tamil, Arabic) on the built-in renderer | Use gotenberg or docraptor |
A missing translation in a template fails the i18n check, not the reader.
Swapping drivers
documents:
render: { driver: docraptor, key: secret:DOCRAPTOR_API_KEY } # was builtinYour documents.render calls stay the same. The HTML drivers render an HTML template (templates/<name>.pdf.html) after sanitize, and fetch nothing outside network.allow. See Swap a driver.
Testing
The built-in renderer runs offline. Render, then read the result back with documents.parse:
import { it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { documents } from "mainsdk";
it("documents AC11 renders an invoice and reads it back", async () => {
createTestApp({ main: "./main.yaml" });
const ref = await documents.render(
{ body: [{ text: "Invoice INV-7", style: "h1" }] },
{},
{ owner: "u1" },
);
const doc = await documents.parse(ref, { as: "u1" });
assert.match(doc.pages[0]?.text ?? "", /Invoice INV-7/);
});Security and privacy
- Generated documents often hold personal data. They are stored through
fileswith an owner, and you share them with expiring links. - The HTML drivers receive the document data. Check their regions before you choose one; see Data and regions.
- Template values are sanitized before an HTML driver sees them.
FAQ
Do I need a hosted service? No. The built-in renderer is the default.
Which languages render? The built-in renderer handles scripts that need no shaping, such as Latin, Greek, Cyrillic and CJK, with an embedded font that has the glyphs. For Devanagari, Tamil, Arabic and other scripts that need shaping, use gotenberg or docraptor.
Next
Was this page helpful?