Skip to content

Print templates and PDF ​

A document prints as a self-contained HTML page, either in the standard layout built from its DocType or through a template an app declares. The same HTML is what the PDF renderer converts, so a preview and its PDF come from one render.

The standard layout ​

Every DocType has a standard format, with no code.

  • The header is the DocType's label, followed by : <value> when the DocType has a titleField with a value, and the document id as the subtitle. A submittable DocType gets a badge: Draft, Submitted or Cancelled. Other DocTypes get none.
  • Fields go into a fixed two-column grid, in declaration order. A Section Break with a label starts a heading. Tab Breaks are skipped, so the form's tabs do not carry over. Empty values, Hidden fields, and Password and Vault fields are left out.
  • A Table field prints as a heading and a table. Its columns are the child DocType's visible fields: the first six, plus any later field marked inListView. Hidden, Password and Vault fields are left out here too.
  • A Table MultiSelect prints as one line of the grid: its values, joined with , .
  • Currency prints in the site currency, with the grouping and decimal mark of the print language: with a USD site, $ 50,000.50 in en and US$ 50.000,50 in pt-BR. Date prints dd/mm/yyyy for a pt* language and ISO otherwise; Datetime is shown in the site timezone. Percent follows the language's separators, a Check prints ✓ when set, and a Select value is translated.

A template ​

A template lives in print/<name>.print.ts. Nothing registers it; the file is enough.

ts
import { definePrintTemplate, _ } from "@ddcore/sdk";

export default definePrintTemplate({
  name: "sales.invoice_official",
  doctype: "Invoice",
  label: "Official Invoice",
  body: (doc, b, ctx) => [
    b.header(doc.id, { subtitle: _("Invoice"), badge: _("Paid"), badgeColor: "green" }),
    b.columns([
      [b.h(3, _("Billed To")), b.p(doc.customer_name)],
      [b.keyValues([[_("Posting Date"), ctx.formatDate(doc.posting_date)]])],
    ]),
    b.rule(),
    b.table(
      [_("Item"), _("Qty"), _("Amount")],
      (doc.items || []).map((i: any) => [i.description, i.qty, ctx.formatCurrency(i.amount)]),
      { aligns: ["left", "right", "right"] },
    ),
    b.totals([[_("Grand Total"), ctx.formatCurrency(doc.grand_total)]]),
  ],
});

name is unique across every installed app, so prefix it with the app's own; declaring the same name twice is a load error. doctype is the one DocType the template prints: asking for it on another DocType is a ValidationError. label is what the format list shows, translated; without one the list shows name. The label is a catalogue key, collected by ddcore i18n extract.

body runs on the server, synchronously, in the print language, so a literal _("…") in it comes out translated. doc is a Document built from the document's values after redaction (below), and ddcore.* is available and runs as the user who asked for the print.

Blocks ​

body returns blocks. Text is escaped when rendered; richText and markdown render markup, cleaned by the allowlist, and only raw/html is neither escaped nor cleaned.

  • b.header(title, { subtitle, badge, badgeColor }) — a title line with an optional subtitle and badge. badgeColor is green, red, blue, orange or gray (the default). There is no logo option; a logo belongs to the Letter Head.
  • b.section(title, blocks) — a titled group of blocks.
  • b.keyValues(pairs, { columns: 2 | 3 | 4 }) — label and value pairs in a grid, two columns unless told otherwise.
  • b.table(headers, rows, { aligns }) — a table; aligns holds left, right or center per column. The stylesheet asks for the header row to repeat on each printed page and for a row not to split across pages.
  • b.totals(rows) — right-aligned label and value pairs, the last row emphasized.
  • b.columns(cells) — side-by-side columns of equal width; each cell is a list of blocks.
  • b.p(text) — a paragraph.
  • b.h(level, text) — a heading; b.h1, b.h2 and b.h3 are shorthands.
  • b.rule() — a horizontal line; b.divider() is the same block.
  • b.pageBreak() — starts a new page.
  • b.richText(html, title?) — a Text Editor value, rendered as the markup it is and cleaned by the server's allowlist first, so a template cannot print what a document may not hold. title labels it like a field.
  • b.markdown(source, title?) — a Markdown Editor source, rendered and cleaned the same way.
  • b.pre(text, title?) — preformatted text, escaped, keeping its whitespace: a Code field.
  • b.barcode(value, symbology?, title?) — a barcode drawn as vectors: symbology is "Code128" (the default), "EAN-13" or "QR", and a Barcode field's is its options. 1D codes print 18 mm tall with the value underneath; a QR code prints 30 mm square. A value the symbology refuses prints as text. The standard layout draws every Barcode field this way.
  • b.signature(dataUrl, title?) — a Signature field's image, at most 25 mm tall. The value is checked again as the field checks it on save, so anything but a small PNG data URL prints as nothing — a template may pass it anything, and no other URL reaches the <img>. The standard layout prints every Signature field this way, and a child-table cell as "Signed".
  • b.image(file, { title?, maxHeight?, maxWidth? }) — the image a File holds, file being an Attach Image value (its file_url) or a File id. The server reads the File as the user printing — with ignorePermissions when ddcore.print.pdf was called with it — and inlines it as a data URL, so a private file prints in the PDF too, where the renderer has no session to fetch it with. It prints at most 80 mm tall and the page wide; maxHeight and maxWidth are CSS lengths ("40mm", "3cm", "50%", in mm, cm, in, pt, px or %), and anything else is ignored. A file the user may not read, one that does not exist, one over 10 MiB, and one whose bytes are not a PNG, JPEG, GIF or WebP image (SVG is not taken) print as nothing. The standard layout prints every Attach Image field this way.

A Geolocation field has no block: the standard layout prints it as a key/value, a single point as lat, lon and anything else as what it holds ("2 points, 1 polygon"), and a child-table cell the same way. No map is drawn — its tiles would have to be fetched by the PDF renderer, from a third-party server, while it runs. A template that wants the coordinates reads doc.<field>.features itself.

  • b.html(markup) / b.raw(markup) — markup inserted as written, unescaped.

A block type the renderer does not know renders as nothing.

ctx formats values in the print language:

  • ctx.formatCurrency(value) — two decimals with the language's separators (1.500,00 in pt-BR), with no currency symbol. ddcore.utils.formatCurrency adds the site currency's symbol.
  • ctx.formatDate(value) — dd/mm/yyyy for a pt* language, ISO otherwise.
  • ctx.formatDateTime(value) — the value as stored.
  • ctx.formatNumber(value, decimals = 2).

What a print can read ​

Printing loads the document the way a read does: the user needs read permission on it, and user access scopes apply, so a document outside the user's scopes is refused like any other read.

Before the standard layout or a template sees the document, Password and Vault fields are removed, and so is every field above the user's permission level (permlevel), in the parent and in child rows (a child row is judged by the parent's access); the standard layout also drops their labels and child-table columns. See field-permissions. Nothing else is removed. The standard layout also skips Hidden fields, but a custom template receives them, so a template must not print a hidden field it should not show. A template's own ddcore.db calls are server code and are not filtered.

Letter Head ​

The core Letter Head DocType carries the branding a print wraps around its content. Its fields are letter_head_name, is_default, disabled, align (Left, Center or Right), image (an attached logo) and header_html and footer_html. System Manager edits them; every user can read them.

Only one Letter Head is the default: saving an enabled one with is_default set clears the flag on the others. Saving a disabled one as the default does not touch the others' flag — a disabled Letter Head never becomes, or clears, the default, since prints must not silently lose their letterhead. Should several defaults exist anyway (written by SQL, say), the most recently modified enabled one is used.

The header is the logo, then header_html; the footer is footer_html; both follow align. header_html and footer_html are inserted unescaped, as written. The header is placed once before the content and the footer once after it: they are not repeated on each page, and there are no page numbers.

HTTP ​

EndpointReturns
GET /api/print/formats/{doctype}[{name, label, default}], standard first
GET /api/letterheadsenabled Letter Heads, [{id, is_default, disabled}], default first
GET /api/print/{doctype}/{id}the HTML
GET /api/print/{doctype}/{id}/pdfthe PDF

The two lists require a signed-in user, and the format list also requires read permission on the DocType. The two print endpoints check read permission on the document, as described above.

The two print endpoints take the same query parameters:

  • format — standard (the default) or a template name.
  • letterhead — a Letter Head id, none for no Letter Head, or empty for the default. An id that does not exist or is disabled is a ValidationError.
  • lang — the print language; without it, the request's language.
  • page_format — A4 (the default) or Letter, in any case. Anything else is a ValidationError.
  • landscape — 1 or true for landscape.

page_format and landscape are written into the HTML's @page rule, which is what the browser's print dialog and Chrome follow; a PDF command follows them if the tool reads @page. The PDF endpoint also takes download: 1 or true sends the file as an attachment named <doctype>-<id>.pdf; otherwise it is inline.

PDF ​

The renderer is chosen on the first PDF request and kept for the life of the process, so a change to the environment or a browser installed later needs a restart. In order:

  • DDCORE_GOTENBERG_URL set: the HTML is posted to Gotenberg's /forms/chromium/convert/html, with the paper size and orientation as form fields. No margins are sent, so Gotenberg applies its own. The request times out after 60 seconds. At most 5 renders run at once.
  • DDCORE_PDF_COMMAND set: the command runs through sh -c (cmd.exe /c on Windows), with {in} and {out} replaced by the paths of a temporary HTML file and the PDF to write, for example weasyprint {in} {out}. At most 3 renders run at once.
  • Otherwise, a Chrome, Chromium, Brave or Edge binary found in the usual install locations or on PATH runs headless with --print-to-pdf. At most 3 renders run at once.

A request waits for a free slot for as long as the request lasts. The limits are fixed.

When no renderer is available, or Gotenberg cannot be reached or does not start answering within the 60 seconds, the PDF endpoint answers 503 UnavailableError. Any other renderer failure — Gotenberg answering with an error, Chrome or the command failing — is a 500.

From server code ​

ddcore.print.pdf renders a document's print to PDF and stores it as a private File attached to that document, which is what ddcore.sendMail({ attach }) takes:

ts
// in a job, after the agreement is firmed
const pdf = ddcore.print.pdf({ doctype: "Portal Payment", id: doc.id, format: "portal.agreement_contract", language: "pt-BR" });
ddcore.sendMail({ template: "portal.agreement_contract", to: doc.agreement_email, args: { … }, attach: [pdf.id],
  reference: { doctype: "Portal Payment", id: doc.id }, key: `agreement-contract:${doc.id}` });

It takes { doctype, id, format?, letterHead?, language?, pageFormat?, landscape?, filename?, ignorePermissions? }:

  • format, letterHead, pageFormat and landscape mean what the HTTP parameters format, letterhead, page_format and landscape mean: standard, the default Letter Head ("none" for none), A4, portrait.
  • language defaults to the current language, then the site's.
  • filename defaults to <format>-<id>.pdf, or <doctype>-<id>.pdf for the standard format. The stored name is random, as for any upload.
  • Printing needs read on the document, and attaching the File needs write on it, as an upload does. ignorePermissions: true skips both. A job usually needs it.

The File is attached to the document, so everyone who reads the document can read the PDF. A PDF printed with ignorePermissions holds every field, including those above permission level 0, which some of those readers cannot see on the form. Print with the reader's own permissions, or keep such fields off the template.

The PDF is rendered on the caller's transaction, by the renderer the PDF endpoint uses, under the same limits. A rollback deletes the File and its bytes. A render can take as long as Gotenberg's 60 seconds, and the transaction stays open while it runs, so call it from a job rather than a request. When no renderer is available it throws UnavailableError, and a job's retry covers a renderer that is down for a while.

Desk ​

A saved document's form has a Print button, also in its actions menu, that opens /app/<workspace>/<doctype>/<id>/print. The page shows the HTML in an iframe, with pickers for the format, the Letter Head (preselecting the default) and the language. The language picker offers a fixed list: Português (Brasil), English and Español. The desk does not send page_format or landscape.

Print calls the iframe's contentWindow.print(), falling back to window.print(). Download PDF fetches the PDF endpoint; on a 503 it shows a hint to use the browser's print instead, and on any other error it shows the error's message.

MIT Licensed · ddcore (Data Driven Core) · Development documentation (main)