Cloud|

PDF and templates

Render documents from application data with shared template and PDF services.

10 min read Updated 2026-10-20 #pdf#templates#gotenberg

Cloud can render HTML as PDF through the deployment's Gotenberg service.

The application owns the document data and HTML. Cloud owns connection settings, authentication, timeouts, and size limits. Operators set the Gotenberg URL in the gotenberg.url setting or bootstrap it with GOTENBERG_URL; without it, rendering fails with the not_configured code.

The Max input bytes setting (gotenberg.max_html_bytes, default 1 MiB) limits HTML, headers, footers, images and fonts together in one render request. The request timeout (gotenberg.timeout_ms) is also capped by Gotenberg's own --api-timeout (default 30 s).

Render HTML

ts
import { renderHtmlToPdf } from "@k2b/cloud/services";

const result = await renderHtmlToPdf({
  html: "<!doctype html><html><body><h1>Stock report</h1></body></html>",
  title: "Stock report",
  headerHtml: null,
  footerHtml: "<p>Inventory</p>",
});

return new Response(result.pdf, {
  headers: {
    "Content-Type": result.contentType,
    "Content-Disposition": 'attachment; filename="stock-report.pdf"',
  },
});

html is required. headerHtml and footerHtml are optional.

title is the document title that PDF viewers show in their tab or window, usually the document's name. The HTML's own non-blank <title> takes precedence. Pass a title whenever the HTML may lack one: without either, viewers show the random file name the renderer gave the upload.

Cloud sends the HTML to Gotenberg with background printing and CSS page sizes enabled. The result contains PDF bytes and the returned content type.

HTML renders offline

Cloud renders every HTML document, header, and footer offline, including Markdown, Liquid templates, and Factur-X invoices. Before the HTML reaches Gotenberg, Cloud removes scripts, noscript content, frames, embedded objects, base and meta elements, links other than stylesheets, and the MathML (math) and SVG (foreignObject, desc) elements that hold HTML. It then adds a Content Security Policy that allows inline styles, plus named assets and data: URLs for stylesheets, images, and fonts.

As a result, JavaScript does not run and remote URLs are not loaded. Pass images, fonts, and CSS as named assets or embed them as data: URLs. Hyperlinks stay clickable in the PDF, and inline SVG shapes and images still render. The rendering mode stays the caller's: a leading doctype still applies, and HTML without one renders in quirks mode.

Render untrusted Markdown

Use renderMarkdownToPdf() for a deterministic Markdown document with a code-owned print preset:

ts
import { renderMarkdownToPdf } from "@k2b/cloud/services";

const result = await renderMarkdownToPdf({
  markdown: "# Stock report\n\n| Item | Remaining |\n| --- | ---: |\n| Cable | 4 |",
  title: "Stock report",
  templateId: "report",
  customCss: "h1 { color: #244f75; }",
});

The available A4 presets are document, report, and compact. Optional customCss is applied after a selected preset and can override it. Omit templateId to use custom CSS as the complete stylesheet; omit both fields to use document. CSS is limited to 32 KiB. Raw HTML stays inert. Markdown image references become safe links, so the renderer never fetches them. CSS imports, URLs, and other external resources are rejected. The document then goes through the same bounded, offline HTML renderer as any other HTML, so a Markdown PDF fetches nothing. Markdown has no <title>, so pass title, such as the note or file name, for PDF viewers to show.

Render your own Markdown dialect

renderMarkdownToPdf() knows GitHub-flavored Markdown and the shared info blocks (:::note, :::warning, and the other types). They print with a type icon and a light tint. The icon and title use the tone colour; body text keeps the preset's text colour. When the application's documents use their own blocks, render them with the application's renderer and wrap the resulting HTML with buildPresetPdfHtml():

ts
import { buildPresetPdfHtml, renderHtmlToPdf } from "@k2b/cloud/services";

const html = buildPresetPdfHtml({
  html: renderNoteHtml(note),
  templateId: "document",
  css: ".callout { border-left: 4px solid #2563eb; }",
  customCss: input.customCss,
});
const result = await renderHtmlToPdf({ html, title: note.title });

It applies the same presets and customCss rules as renderMarkdownToPdf(). html becomes the document body as given, except for <wbr> break opportunities in long table-cell runs, so text that the application replaces afterwards must not rely on staying contiguous inside table cells. Sanitize it in the application renderer. css styles the application's own elements. It comes after the preset and before customCss, so a person's custom CSS can still override it. With customCss but no templateId, css still applies while the preset is left out. The print styles for info blocks come with every document, before css, so an application renderer that emits info blocks through renderMarkdownInfoBlock() from @k2b/ui prints them like Markdown PDFs.

In every document (each preset, no template, or custom CSS only), table cells keep words whole so short values such as Nr. stay on one line; custom CSS can override the cell rule. Words of up to 20 characters stay whole; a longer run without a space, such as a long URL or code, gets a break opportunity every 10 characters so the table fits the page. Because words stay whole, a table with many columns of long words can still be wider than the page; split it or let custom CSS break cells anywhere.

The result renders offline like any other HTML: the offline policy replaces the Content Security Policy that buildPresetPdfHtml() writes. html and css can therefore embed images and fonts as data: URLs or reference files passed as assets to renderHtmlToPdf(). Remote URLs still do not load, and customCss still cannot load any resource.

The service owns conversion only. Callers still own authentication, authorization, request limits, filenames, response headers, and persistence.

MarkdownPdfError.code is bad_input, invalid_css, or external_asset_unsupported. These errors are safe to translate into a bounded caller-owned API response. Gotenberg failures continue to use GotenbergRenderError.

Render a Liquid template

Use renderLiquidTemplate() from @k2b/cloud/shared to render text or HTML in the server or browser. Its LiquidTemplateOptions separate time and size budgets:

Option Default Budget
renderTimeoutMs 1_000 Milliseconds per render
renderMaxBytes 300_000 UTF-8 bytes of final output; captures use the larger of this cap and 300,000 bytes
memoryLimit 2_000_000 LiquidJS allocation units for ranges and filters, plus expanded text added by array-building filters
templateMaxBytes 200_000 UTF-8 bytes of template source

Time and output limits are enforced during rendering; output exactly at the byte limit is allowed. Rendering is synchronous and blocks the application's event loop. Time checks run on each template, loop iteration (including empty bodies), filter call, and operator. Custom filters must finish promptly: a synchronous filter cannot be interrupted, and its elapsed time is checked when it returns.

LiquidTemplateError.reason identifies budget failures as render_timeout, render_too_large, or render_memory_limit. Other syntax, variable, and filter errors remain ordinary rendering errors. Applications may pass their existing output budgets through renderMaxBytes without changing the time budget. Array-building filters (push, concat, unshift, and map) also charge memoryLimit for the expanded text they add.

Use renderTemplatePdfPreview() when an operator edits a Liquid template and needs one structured result for both template and PDF errors:

ts
const preview = await renderTemplatePdfPreview({
  htmlTemplate: "<h1>{{ item.name }}</h1>",
  pageCssTemplate: "@page { size: A4; margin: 20mm; }",
  data: { item },
});

if (!preview.ok) {
  return c.json(preview.error, preview.error.status);
}

return new Response(preview.pdf.pdf, {
  headers: { "Content-Type": preview.pdf.contentType },
});

The input may include header, footer, and page CSS templates, and a title that names the PDF when the rendered HTML has no <title>. It also accepts custom Liquid filters. The rendered body, header, footer, and page CSS render offline like any other HTML.

The result separates the template phase from the pdf phase. Template budget failures include code: "render_timeout", "render_too_large", or "render_memory_limit", matching LiquidTemplateError.reason. Do not expose template stack traces to end users.

Merge PDFs

mergePdfs() accepts one or more Uint8Array PDF files and returns one PDF. Cloud preserves input order.

An empty file list fails with bad_input.

Handle renderer errors

GotenbergRenderError.code is one of:

Code Meaning
bad_input A PDF merge request has no files
not_configured The renderer URL or limits are invalid
html_too_large HTML exceeds the deployment limit
pdf_too_large Output exceeds the deployment limit
request_failed The renderer could not be reached
bad_response The renderer returned an unsuccessful response
timeout The request exceeded its timeout

Treat configuration and availability failures as operational errors. See Runtime configuration and Troubleshooting.

Authorize access to the document data before rendering.

Assets, paper options and file attachments

renderHtmlToPdf also accepts assets: Array<{ name, data: Blob }>, tagged, and page: { format, landscape, margin: { top, right, bottom, left } }. Supported formats are A4, A3, A5, Letter and Legal. When page is supplied, omitted values use A4 portrait and 15 mm margins; dimensions are sent explicitly instead of preferring CSS page size. Existing callers without page retain CSS page sizing. Avoid conflicting @page rules. Asset names are plain filenames, unique without case distinctions, and cannot replace the reserved HTML or factur-x.xml files. HTML, headers, footers and assets share maxHtmlBytes.

ts
import { attachPdfFiles } from "@k2b/cloud/services";

const result = await attachPdfFiles({
  document: existingPdfBlob,
  attachments: [{
    name: "details.xml",
    data: new Blob([xml], { type: "application/xml" }),
    relationship: "Data",
  }],
}, { signal: request.signal });

relationship defaults to Unspecified; other values are Source, Data, Alternative, and Supplement. MIME type comes from the Blob, falling back to application/octet-stream. The source PDF and attachment bytes together must fit maxPdfBytes. Attachment names in one request must be unique.

renderFacturXHtmlToPdf({ html, xml, conformanceLevel, ... }) uses the same HTML options, embeds factur-x.xml, requests PDF/A-3b and supplies Factur-X metadata. The default conformance level is EN 16931. XML shares the HTML input budget. It takes no title: the PDF/A conversion drops the document title, so viewers show the invoice's file name. The caller owns XML generation and validation; embedding alone is not invoice certification. These features require a Gotenberg version supporting the corresponding embedding/Factur-X fields; the local stack uses 8.36.0.

HTML, Factur-X and attachment operations accept { signal } as their second argument and combine it with the configured timeout. Output is bounded while reading the response. ...WithConfig variants accept an explicit Gotenberg configuration and optional test transport. They never choose an app's storage location or grant access to a source file.

The server helpers accept application-owned HTML and render it offline. Applications exposing them to untrusted input must still enforce authentication and input budgets. Assistant Studio's worker API does not expose arbitrary Gotenberg endpoints or connection settings. Operators must keep Gotenberg's file access limited to its working directory, /tmp, and should keep Gotenberg itself offline; see Deployment requirements.

Prepare images for print

Use preparePdfImage() to fit raw image bytes into pixel boxes before adding them to a PDF. It accepts a Uint8Array or Blob and returns one variant per box, in the same order. Images keep their aspect ratio and never scale up.

ts
import { preparePdfImage, renderHtmlToPdf } from "@k2b/cloud/services";

const prepared = await preparePdfImage({
  data: imageBytes,
  boxes: [{ width: 1600, height: 1200 }],
  maxBytes: 2 * 1024 * 1024,
}, { signal: request.signal });
if (!prepared.ok) throw new Error(prepared.code);
const variant = prepared.variants[0];
if (!variant) throw new Error("Image exceeds the encoded byte cap.");

const result = await renderHtmlToPdf({
  html: '<!doctype html><html><body><img src="img-1.jpg"></body></html>',
  assets: [{ name: "img-1.jpg", data: variant.data }],
}, { signal: request.signal });

The same API and types are exported from @k2b/cloud/services/pdf. Each variant has data (a typed Blob), type, width, and height. A null entry means that box's encoding exceeded maxBytes; other boxes still return their variants. The service does not reduce quality to meet the cap.

Opaque images become 8-bit sRGB JPEG at quality 85. Images with any decoded alpha below 255 become PNG with transparency. A fully opaque alpha channel is removed. A JPEG passes through byte for byte only when its EXIF orientation is absent or 1, its colour space is sRGB or grayscale (not CMYK), it has no embedded colour profile, its dimensions already fit the box, and its bytes fit maxBytes. It must also pass a validation decode. A corrupt JPEG never passes through. Other images are re-encoded. EXIF rotation is applied before fitting. Animated GIF and WebP use the first frame; TIFF uses the first page.

Accepted formats are JPEG, PNG, WebP, GIF, TIFF, and AVIF. HEIC/HEIF with HEVC compression returns unsupported_format because the prebuilt libvips has no HEVC decoder. SVG, PDF, and other formats are also unsupported.

Pass 1..8 boxes with integer dimensions of 1..16383 pixels each. Input is limited to 32 MiB; maxBytes is an integer from 1 byte to 32 MiB per variant. Sharp's default input limit of 268,402,689 pixels still applies, counting one page. WebP, GIF, TIFF, and AVIF sources are limited to 16 MiP (about 16.8 MP) because their decoders need the whole image in memory; JPEG and PNG keep sharp's default pixel limit. The largest decoded variant is limited to 16 MiP (64 MiB as RGBA). A full A4 page at 300 dpi needs about 5.8 MP.

The service requires the Linux Cloud runtime. It runs at most two isolated workers per application process; further calls wait in FIFO order. Each worker has a 512 MiB data limit and receives no application credentials. The Bun runtime itself uses about half of that limit. The input and decoded variant caps leave room for codecs and temporary buffers. An image whose decode needs more memory than the worker has returns too_large. Queuing, decoding, and encoding share a 30 s default deadline, matching PDF page decoding. Set { timeoutMs } up to 240 s, the request idle-timeout ceiling. Aborting the signal or reaching the deadline returns timed_out after the child has exited. Invalid boxes, byte caps, and timeouts throw as programming errors.

Code Meaning
unsupported_format The recognized format or compression is unsupported
unreadable The bytes cannot be identified or decoded, or the worker failed
too_large Input bytes, source pixels, decoded pixels, or decoder memory exceed a limit
timed_out The caller aborted or the deadline ended, including queue time

An application image that uses this service ships sharp's libvips (about 18 MB).

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.