Skip to content

Printable documents (PDF)

The optional tesseraql-pdf module adds a pdf codec behind the standard file-codec SPI: any query-export or file-export route (file transfers) can stream a printable document instead of a tabular file. Putting the jar on the classpath is the whole install — the codec is discovered via ServiceLoader:

Terminal window
tesseraql modules add io.tesseraql:tesseraql-pdf --app .

PDF is output-only: file-import rejects it (TQL-LD-2830).

version: tesseraql/v1
id: users.print
kind: route
recipe: query-export
security:
auth: bearer
policy: users.read
sources:
main:
sql:
file: print.sql
export:
format: pdf
filename: users.pdf
template: print.html # colocated with the route; omit for the built-in grid
columns:
- { name: name, label: Name }
- { name: status, label: Status }

Without a template: the built-in grid lays the rows out as a plain A4 table with repeating column headers and Page n / total numbering — useful for ad-hoc listings. With a template, the document is fully app-designed. The filename: is a template over the request like every export’s (file transfers): a detail route under /orders/{id}/print names its document order-{path.id}.pdf, and a response.file: page’s filename: resolves the same way.

A document printed for a row outside the caller’s reach — a detail route whose header source returns nothing — answers a status, never a blank page or a template error:

export:
format: pdf
template: order.html
statusWhen:
- when: header.rowCount == 0
status: 404

The arms are judged before the template runs (file transfers).

file-export works the same way for asynchronous extraction ({path}/{transferId}/file downloads the finished document), including after: follow-up statements.

A print template is an app-authored XHTML file (well-formed XML, .html), colocated with the route and rendered through the standard template engine before PDF conversion. It renders in the export’s locale: when one is declared (a literal, a request source or the configured default) and in English otherwise, so its #numbers and #dates utilities, ${#locale} and #{…} message expressions read the application’s messages/ catalogs over the framework’s built-ins in that locale; the row values were formatted with the same locale before they reached the model. A template that cannot be rendered fails the export with TQL-LD-2831 naming the template. The model is:

  • main — { rows, rowCount }: the query rows, values already formatted per the column mappings (locale, time zone, format: patterns — the same rules as CSV output), under the same key a route publishes its default result; a template iterates ${main.rows}. The export’s other declared sources: land under their own names (the invoice example below reads header).
  • columns — { name, header } per declared column
  • fontFamilies — the app’s font families as a CSS font-family list, for templates that do not name fonts themselves

A print template renders tables, text, and static markup — not the dashboard charts. Dashboard charts draw client-side (the kit’s chart recipe enhances a server-rendered data table in the browser), and the PDF engine runs no JavaScript, so a chart cannot appear in a PDF report; include the underlying data as a table instead — which is exactly what the chart’s own no-JavaScript fallback shows. If chart-in-PDF demand lands, the path is the kit’s server-side rendering story, not a second chart engine here.

Page-oriented CSS drives the print layout:

<html xmlns:th="http://www.thymeleaf.org">
<head>
<style>
@page {
size: A4;
margin: 20mm 15mm;
@bottom-center { content: "Page " counter(page) " / " counter(pages); font-size: 8pt; }
}
body { font-family: 'TesseraQL Sample Gothic'; font-size: 10pt; }
thead { display: table-header-group; } /* repeat column headers on every page */
</style>
</head>
<body>
<h1>User list</h1>
<table>
<thead><tr><th th:each="column : ${columns}" th:text="${column.header}">h</th></tr></thead>
<tbody>
<tr th:each="row : ${main.rows}"><td th:text="${row.name}">name</td></tr>
</tbody>
</table>
</body>
</html>

Templates are app-authored and confined: the template must live inside the app home — the declaration is refused at lint and boot otherwise (TQL-YAML-1075), and the codec refuses one that reaches it from outside (TQL-LD-2832) — every url(...) it references resolves only to files inside the app home, and the network is never fetched during an export. Data interpolates with th:text, escaped by default.

Fonts under the app home’s fonts/ directory (*.ttf, *.otf) embed automatically, each registered under the family name carried in the font itself — so font-family: 'Noto Sans JP' works as soon as NotoSansJP-Regular.ttf is in fonts/. Registration order is file-name order, deterministic across machines. The examples ship TesseraQL Sample Gothic, a small renamed Noto Sans JP glyph subset (OFL 1.1, see examples/user-admin-app/fonts/README.md); real applications should ship complete fonts.

Rendered documents are normalized so exports stay reproducibility-friendly: the producer is fixed to TesseraQL, creation/modification dates and XMP metadata are dropped, and the trailer /ID derives from a fixed seed. The same rows through the same template yield byte-identical PDFs.

No runtime module depends on the PDF engine — tesseraql-pdf is opt-in, and an application whose codec set lacks it refuses to start (TQL-LD-2801, naming the route or the job step) whichever recipe declares format: pdf; tesseraql lint warns first (TQL-YAML-1408). The bundled renderer is openhtmltopdf (LGPL), replaceable as a drop-in jar via the tesseraql.pdf.engine system property (default openhtml).

A suite case that exercises the route’s extraction SQL proves the data the document renders:

- name: the printable user list extraction returns sato
sql:
file: web/api/users/print/print.sql
params: {}
expect:
rowCount: 1
rows:
- name: sato

The document coverage kind declares every route exporting a printable document and counts it covered when a suite case exercises one of its SQL artifacts; gate it with coverage.thresholds.document.

Code Severity Meaning
TQL-YAML-1005 error a pdf export declares workbook-only options (sheet:, startCell:) or the csv-only bom:
TQL-YAML-1006 error the pdf template is not an .html file, or is missing
Code Meaning
TQL-LD-2830 PDF is output-only; file-import cannot read it
TQL-LD-2831 rendering failed (bad template markup, engine error)
TQL-LD-2832 the template lies outside the app resource root
TQL-LD-2833 tesseraql.pdf.engine names no available engine
TQL-LD-2834 a font under fonts/ cannot be read

A PDF holds its rows, then the whole document as one string, then as one byte array — a paginated layout is not a stream, and the engine needs the whole document to place page breaks and repeat headers. So a printable export runs under maxRows: (TQL-LD-2850 past it), and a large one splits by meaning rather than by row count:

export:
format: pdf
template: invoice.html
filename: invoice-{key}.pdf
splitBy: customer_id # one invoice per customer, bundled as a ZIP
sources:
header:
sql:
file: select-customer.sql

splitBy: keeps everything a single document gets right — page numbers are per invoice, fonts embed once per document, and a footer total is that customer’s total — because the boundary is one the reader already believes in. Chunking one logical table and merging the pieces back would lose all three. See file-transfers.md for the ordering contract and the {key} rule.

sources: is how an invoice reads its customer without denormalizing them onto every line. The procurement example prints three such documents — a quotation, a purchase order and a delivery note under examples/procurement-app/web/api/ — each a header source beside the lines, labels from the application’s catalogs, and a statusWhen: arm for a row outside the caller’s reach.