🖨 Print form PDF
Updated
When what you need is a file, not paper.
Printing and PDF are two different things#
Printing has always worked. The render endpoint returns HTML with
window.print() embedded in it, the human picks "Save as PDF" in the
browser's own dialog and gets a file. No service is needed for that.
What did not work is the other thing: a PDF the server holds. Bytes, to
attach an invoice to a message, to store on a document, to hand to an
integration. EmailMessage has carried attachments from the very start —
there was nothing to produce them with.
A browser does the rendering, and that is a decision, not an accident#
| Option | Why not |
|---|---|
A lightweight engine (wkhtmltopdf and its like) |
the HTML renderer leans on flexbox; engines like that will mangle the layout |
| Our own renderer over the ZR model | a second implementation of one layout next to the 2000 lines of the existing one: the PDF and the preview would drift apart, and every edit to a form would cost two edits |
| The same browser as in Preview | the only thing that makes "the PDF matches what I saw" a fact |
The browser lives in a separate container (chromium in compose). That
way the kernel image does not grow by 400 MB for everyone who has no need of
PDF.
The capability is optional#
With no service the endpoint answers 503 with a hint rather than failing with an explanation:
{ "error": "PDF rendering is not configured on this stand",
"hint": "Start the 'chromium' compose service and set Printing:Pdf:ServiceUrl,
or print from the browser: /render/{documentId}?autoprint=1" }
The configuration:
"Printing": { "Pdf": {
"ServiceUrl": "http://chromium:3000",
"Path": "/pdf",
"TimeoutSeconds": 60,
"Token": ""
}}
The address comes from the configuration only, not from metadata and not from a script: it is the address the server will send the document to, the same reason the host allow-list does not live in the database.
From a script#
This is what the whole thing was done for — attaching an invoice to a message:
var pdf = ScriptServices.Get<IDocumentPdfService>();
if (!pdf.IsAvailable) return; // a stand with no browser
var bytes = await pdf.RenderAsync(formMetaId, invoiceId);
var mail = new EmailMessage { AddressTo = customer.Email, Subject = "Счёт" };
mail.AttachFile($"invoice-{invoice.ID}.pdf", bytes, "application/pdf");
await ScriptServices.Get<IEmailService>().SendAsync(mail);
RenderBoundAsync(documentTypeMetaId, documentId) returns the form bound to
the document type — but only when there is exactly one. With two or more
it returns null: picking "the first" would mean the customer gets a form
chosen alphabetically.
From the interface#
The print dialog → Save PDF. Separate from Print, because printing hands the job over to the local dialog and leaves no file behind.
What is checked and what is not#
The unit tests (PdfRenderServiceTests, with no browser and no container)
close off three ways of lying to the caller: failing silently when the
service is not configured, presenting a remote error as silence, and
returning a "PDF" that is in fact an HTML error page. The last is the most
dangerous of them — otherwise it comes to light at the counterparty.
That is why the response is checked for a %PDF header.
What these tests do NOT check: that Chromium makes a correct PDF. That is Chromium's job, and an end-to-end check requires the container to be running.