Skip to content

Documents and printing

Status: 🧪 Experimental (@Experimental, documents API 2026-10)

Business apps produce documents: the folio a guest signs, an invoice, a registration card, a monthly report. Not “export this listing” — a document the screen builds, that the user previews, prints or keeps. Mateu delivers documents; it does not produce them. You get:

  1. an action returns a Document and the client shows it, downloads it or prints it;
  2. UICommand.print() prints the current page, without menus, toolbars or buttons;
  3. a DocumentRenderer port (HTML in, PDF bytes out) that your application implements with the library of its choice — Mateu ships no implementation.
@UI("/folio/:id")
public class FolioPage {
// YOUR DocumentRenderer bean (see "Producing the bytes"); or build the bytes right here
DocumentRenderer renderer = MateuBeanProvider.getBean(DocumentRenderer.class);
@Toolbar @Label("View invoice")
Document invoice() {
return Document.pdf("invoice-" + id + ".pdf", renderer.render(html())); // opens in a new tab
}
@Toolbar @Label("Download")
Document download() {
return Document.attachment("bookings.csv", "text/csv", csvBytes()); // the browser saves it
}
@Toolbar @Label("Print folio")
Document print() {
return Document.pdf("folio.pdf", renderer.render(html())).printed(); // straight to the print dialog
}
}
Factory / witherWhat the user gets
Document.pdf(name, bytes) / Document.inline(name, type, bytes)A new tab with the browser’s viewer (a PDF previews inline).
Document.attachment(name, type, bytes) / .downloaded()A download, saved under name.
.printed()The document is loaded in a hidden frame and the print dialog opens for it — no tab.
Document.lazy(name, type, () -> bytes)The bytes are produced when the browser fetches them, not while the action runs. Downloaded by default; .showInline() to preview.

A Document can also travel in a list with other results — List.of(new Message("Invoice ready"), document).

Document, DocumentRenderer and PageSetup are in io.mateu:uidl, so a framework-agnostic UI module can use them; the renderer implementation lives in your application.

DocumentTransport
up to 256 KB (JVM system property mateu.documents.inline-max-bytes; DocumentStore.InlineMaxBytes in C#, documents.inline_max_bytes in Python)Inline, base64, inside the action response — one round trip, nothing kept on the server.
larger, or lazyParked on the server; the response carries a URL, <baseUrl>/mateu/v3/documents/<token>, that the browser fetches once.

Why both: base64 inflates the bytes by a third and the whole response is held in memory on both sides, which is fine for a 40 KB invoice and wrong for a 30 MB report. A URL also lets the browser stream the file into its PDF viewer or its download manager.

The download URL is a capability, not a session endpoint: a new tab or an <a download> cannot carry the bearer token the API calls carry. So the token is 256 random bits, minted only inside the response to an action the user was allowed to run, single-use, and valid for 5 minutes (system property mateu.documents.ttl-seconds). An unknown, spent or expired token answers 404. The response is Cache-Control: no-store, X-Content-Type-Options: nosniff, and an HTML/SVG/XML document shown inline is served with Content-Security-Policy: sandbox, so a document built from user data cannot run script on your origin. The file name reaches Content-Disposition sanitised (no CR/LF, quotes or path; the exact name as RFC 5987 filename*).

The endpoint exists on every adapter — Spring MVC, WebFlux, Micronaut, Quarkus, Helidon MP — and on the ASP.NET Core and FastAPI ports. On Spring it is a filter ordered before Spring Security, for the reason above; if a gateway in front of the app requires authentication on every path, let /**/mateu/v3/documents/* through.

@Toolbar @Label("Print")
UICommand print() {
return UICommand.print();
}

The client opens the browser’s print dialog with a print stylesheet that leaves out the app chrome: navigation, header, menus, buttons, floating buttons, overlays, notifications. The same stylesheet applies to the browser’s own Ctrl+P. Long scrolling areas print in full.

  • Mark anything else to leave out with the class mateu-no-print or data-mateu-print="hide".
  • Keep a button on paper with data-mateu-print="show".

For a printout with its own layout (letterhead, totals, page numbers), return a Document.printed() instead — the page and the paper rarely want the same layout.

Mateu does not render documents. Build the bytes with any library — directly in the action, or behind the DocumentRenderer port so screens depend on an interface rather than on a library:

@Component
public class PdfBoxRenderer implements DocumentRenderer { // your application's code
@Override
public byte[] render(String html, PageSetup setup) {
try (var pdf = new PDDocument(); var out = new ByteArrayOutputStream()) {
var page = new PDPage(PDRectangle.A4);
pdf.addPage(page);
try (var cs = new PDPageContentStream(pdf, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 780);
cs.showText(Jsoup.parse(html).text()); // lay out what your documents need
cs.endText();
}
pdf.save(out);
return out.toByteArray();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}

PageSetup carries the usual layout parameters (A4/Letter, landscape, margins, title, a running header/footer with {page}/{pages}/{title}) so callers can state them without knowing the library; an implementation honours what it can. The demo’s DemoPdfRenderer (demo/demo-admin-panel) is a complete small example with page numbers and an embedded font.

Choosing a library — check the licence against your product’s:

LibraryLicenceNotes
Apache PDFBoxApache-2.0Low-level drawing API; you lay out the page yourself.
JasperReportsLGPL-3.0Report designer + templates; strong for tabular reports.
A template engine (Thymeleaf, FreeMarker, Mustache…) + headless Chromium (e.g. Playwright for Java)Apache-2.0 (+ the browser)Full HTML/CSS fidelity; heavy: a browser per server.
openhtmltopdfLGPL-2.1HTML/CSS 2.1 + paged media to PDF.
OpenPDFLGPL-2.1 / MPL-2.0A PDF library (iText 4 fork).
iText 7AGPL-3.0 / commercialNeeds a commercial licence for a closed-source app.
RendererInline documentAttachmentprinted()UICommand.print()
Vaadin (web)New tab (if a popup blocker refuses it: downloaded instead)DownloadHidden frame + print dialogPrint stylesheet + dialog
Redwood (VB)New tab (same fallback)DownloadHidden frame + print dialogPrint stylesheet + dialog
React NativeURL: system browser sheet (preview, share, print). Bytes: share sheet (iOS) / the app that opens the type (Android)SameSame as inline — print from the sheetNot available on mobile: announced to the user
IntelliJTemp file opened with the OS viewerSave dialogThe OS print service for the type, else openedThe rendered panel through the system print dialog

The ports: return a Document from an action in C# (Document.Pdf(...), Document.Lazy(...), .Printed(), UICommandDto.Print()) and Python (Document.pdf(...), Document.lazy(...), .printed(), UICommand.print()). The same wire, the same endpoint (app.MapMateu(...) / add_mateu(...) add it); as in Java, producing the bytes is the application’s job.

demo/demo-admin-panel → /documents-demo: an invoice previewed, downloaded and printed, a large report fetched by URL, and the page printed without chrome. Its PDFs come from DemoPdfRenderer, the demo’s own DocumentRenderer on Apache PDFBox (a demo dependency, not a Mateu one).