Gotenberg alternative
Gotenberg puts a PDF converter behind an HTTP API. It’s an MIT-licensed Docker image, written in Go, that wraps Chromium and LibreOffice. You post an HTML file and get a PDF back from Chromium, or you post a Word, Excel, or PowerPoint file and LibreOffice converts it. It also merges and splits PDF files, writes metadata, and converts to PDF/A. Because it’s HTTP, any language can call it, and teams with a Python worker and a Node API can share one.
The cost is a second service. You run a container with two office-grade programs in it. You size it, secure it, and keep it up, so your app can turn data into a document. This page compares that with a library that renders in your own process, using the same purchase order.
The same purchase order, twice
Start Gotenberg, with Chromium and LibreOffice inside, on port 3000:
docker run --rm -p "3000:3000" gotenberg/gotenberg:8Then render the order to HTML and post it to the Chromium route:
import { writeFileSync } from 'node:fs'
const order = await getPurchaseOrder(id)
const form = new FormData()
form.append('files', new Blob([orderHtml(order)], { type: 'text/html' }), 'index.html')
form.append('files', new Blob([footerHtml(order)], { type: 'text/html' }), 'footer.html')
form.append('paperWidth', '8.27') // A4, in inches; the default is Letter
form.append('paperHeight', '11.7')
form.append('marginBottom', '0.8') // the footer draws inside the margin
form.append('printBackground', 'true')
const response = await fetch('http://gotenberg:3000/forms/chromium/convert/html', { method: 'POST', body: form })
if (!response.ok) throw new Error(`Gotenberg answered ${response.status}`)
writeFileSync('purchase-order.pdf', new Uint8Array(await response.arrayBuffer()))The request is the easy part. orderHtml() is your template, so the loop, the totals, the number
formats, and the escaping all live there. Gotenberg requires the main file to carry the name
index.html. The footer is a second full HTML document, styled inline, with Chromium filling in
pageNumber and totalPages spans. And the defaults are Letter paper, 0.39-inch margins, and no
background colours, so a few fields always come along.
Now the same order as a quario definition. This is the file the card on the guides page renders:
{
"data": "$.order.lines[*]",
"aggregates": { "subtotal": "sum:[email protected] * @.unitPrice" },
"style": { "size": 10 },
"header": [
{ "type": "text",
"value": "Purchase order {{ $.input.order.number }}",
"style": { "size": 22, "bold": true } },
{ "type": "split",
"style": { "spaceBefore": 6, "spaceAfter": 18 },
"slots": [
{ "type": "text", "value": "To {{ $.input.order.supplier }}", "width": 60,
"style": { "size": 9, "bold": false } },
{ "type": "text", "value": [{ "value": "Deliver by " }, { "value": "{{ $.input.order.deliverBy }}" }],
"style": { "size": 9, "bold": false, "align": "right", "format": "date" } }
] }
],
"detail": {
"header": { "style": { "bold": true,
"borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" } },
"columns": [
{ "header": "SKU", "value": "{{ @.sku }}", "style": { "family": "mono", "size": 8 }, "width": 14 },
{ "header": "Item", "value": "{{ @.item }}", "width": 44 },
{ "header": { "value": "Qty", "style": { "align": "right" } }, "value": "{{ @.qty }}",
"style": { "align": "right" }, "width": 10 },
{ "header": { "value": "Unit", "style": { "align": "right" } }, "value": "{{ @.unitPrice }}",
"style": { "align": "right", "format": "currency" }, "width": 15 },
{ "header": { "value": "Amount", "style": { "align": "right" } }, "value": "{{ round(@.qty * @.unitPrice, 2) }}",
"style": { "align": "right", "format": "currency" }, "width": 17 }
],
"total": {
"style": { "align": "right" },
"rows": [
{ "cells": [
{ "value": "Subtotal", "span": 4 },
{ "value": "{{ round($.subtotal, 2) }}", "style": { "format": "currency" } }
] },
{ "cells": [
{ "value": "VAT 21%", "span": 4 },
{ "value": "{{ round($.subtotal * 0.21, 2) }}", "style": { "format": "currency" } }
] },
{ "style": { "bold": true,
"borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
"cells": [
{ "value": "Order total", "span": 4 },
{ "value": "{{ round($.subtotal * 1.21, 2) }}", "style": { "format": "currency" } }
] }
]
}
},
"page": {
"footer": [
{ "type": "text",
"value": "Purchase order {{ $.input.order.number }} · page {{ page.number }} of {{ page.total }}",
"style": { "size": 8, "align": "right" } }
]
}
}And the code that renders it, as a PDF and as a Word file, inside the process that already has the data:
import { writeFileSync } from 'node:fs'
import { quario } from 'quario'
import { pdf } from '@quario/pdf'
import { docx } from '@quario/docx'
import definition from './purchase-order.report.json' with { type: 'json' }
const report = quario({ locale: 'en-IE', currency: 'EUR' }).report(definition)
const data = await getPurchaseOrder(id) // your JSON, from an API or DB
writeFileSync('purchase-order.pdf', await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data))
writeFileSync('purchase-order.docx', await report.render(docx({ page: { size: 'A4' } }), data))There’s no network hop and no HTML template. The line amounts and the totals are expressions the
engine evaluates, wrapped in round(…, 2), and "format": "currency" presents them. The footer
reads page.number and page.total. A Word copy of the same order would mean a second template for
Gotenberg’s LibreOffice route. Here it comes from the same definition.
What a document service costs
Gotenberg’s own documentation covers what running it takes. A few things to plan for:
- The image is large. The
gotenberg/gotenberg:8image is around 700 MB compressed on Docker Hub for linux/amd64. The Chromium-only variant is around 500 MB, and the LibreOffice-only one around 440 MB. - Two programs to keep healthy. Gotenberg restarts Chromium after 100 conversions and
LibreOffice after 10 by default. It reports both on a
/healthroute for your orchestrator. - Requests have a clock. The API gives up after 30 seconds by default, and a long document on a busy container can reach it.
- The API is open until you close it. Basic authentication is off by default, and Chromium may
fetch private network addresses unless you set
CHROMIUM_DENY_PRIVATE_IPS. Gotenberg documents both. Your HTML template still has to escape every value, because a customer name that reaches it unescaped is markup a browser renders.
None of this rules Gotenberg out. It’s the bill for running a converter as a service, and it’s worth reading before the first document ships.
What changes when you switch
The document becomes data. The HTML template is code, and the PDF depends on how Chromium prints
it. A quario definition is JSON you store, version, and review. It can come from an editor or an AI
agent as safely as from your repository. Expressions parse into closures. There’s no eval, no
new Function and no code generation, so a definition someone else wrote can’t run anything. Data
is never markup, so there’s nothing to escape. How that holds →
One definition reaches four document shapes. Gotenberg turns HTML into PDF through Chromium,
and a Word template through LibreOffice. The quario definition renders to an HTML fragment with
@quario/html, a PDF with @quario/pdf, an Excel workbook with @quario/xlsx and a Word document
with @quario/docx. The supplier’s copy and your accounts team’s spreadsheet never drift apart.
The deployment gets smaller. quario is a JavaScript dependency of the service that has the data. It runs in Node, the browser and edge runtimes, with no container of its own. A license covers one production deployment, and the documents it renders are never counted.
The bytes repeat. The PDF target writes no creation timestamp. The same definition and data produce a byte-identical file, so you can cache a PDF, hash it, or compare it in a test.
When to keep Gotenberg
Gotenberg is the better choice when:
- You convert files you didn’t write. A contract a customer uploads as
.docxor a spreadsheet someone emails you needs LibreOffice. quario doesn’t read Office files or HTML. - The document is a web page you already have. A page your app already renders prints through Chromium without a second layout.
- Your stack isn’t JavaScript. Gotenberg serves any language over HTTP. quario is a JavaScript library, so a Python or Go service would call it through a small Node service of your own.
- You need its PDF tooling, such as merging, splitting or PDF/A conversion.
- The budget is zero. Gotenberg is MIT. quario is commercial past evaluation.
Reach for quario when the documents come from your own data, such as purchase orders, invoices, statements, and reports. It earns its place when Gotenberg is there only to turn that data into PDF and Word files.
FAQ
Can I use quario and Gotenberg together?
Yes. Render your own documents with quario, and keep Gotenberg for the files people send you. quario
can also hand Gotenberg an HTML fragment from @quario/html, wrapped in your own print stylesheet,
if a design needs Chromium’s CSS. Puppeteer PDF shows that hybrid with a
local browser.
Does quario need a headless browser or LibreOffice?
No. @quario/pdf typesets the pages itself, and @quario/docx writes the Word file directly. Neither
starts another program.
Can I use quario for free?
Evaluating it’s free, with no time limit. You get the full engine and need no key, and evaluation output carries a watermark. Shipping it in an application or running it in your business needs a license for each production deployment. Its users, agents, and renders are never counted, and replicas of one service count once. See pricing →