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.

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. The model is:

  • rows — the query rows, values already formatted per the column mappings (locale, time zone, format: patterns — the same rules as CSV output)
  • 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 (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 without the jar a format: pdf route fails loudly at build time (TQL-LD-2801). 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:)
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.