HTTP server mode
In this mode the binary stays resident and accepts conversions over HTTP. Your application no longer has to start a process, and load balancing can be left to a load balancer.
sghtmltopdf server --listen 127.0.0.1:8080 --font NotoSansJP-Regular.ttf
# → prints `listening on 127.0.0.1:8080` to standard output
curl --data-binary @invoice.html \
'http://127.0.0.1:8080/pdf?page-size=A4&margin-top=20mm&toc' \
-o invoice.pdf
For a long-lived process, the Docker image with Japanese fonts bundled is the easy path; with no arguments it starts as this server.
docker run --rm -p 8080:8080 ghcr.io/waka/sghtmltopdf
Startup options
| Option | Default | Description |
|---|---|---|
--listen <ADDR:PORT> | 127.0.0.1:8080 | The address to listen on. :0 picks a free port automatically |
--workers <N> | CPU cores | How many worker threads convert at the same time |
--max-queue <N> | Workers × 4 | How many requests may wait to be accepted; beyond that, 503 |
--max-body-size <BYTES> | 4194304 (4 MiB) | The maximum request body size |
--timeout <SECS> | 30 | Seconds granted to one request: the queue wait plus the conversion (exceeding it returns 504) |
--font <PATH> and the other font options | Cannot be changed per request | |
--enable-local-file-access, --allow <PATH>, --allow-remote-assets | All denied | Only when you allow them explicitly |
There is no authentication and no TLS. Put a reverse proxy in front of it before exposing it to the outside.
Endpoints
| Method and path | Description |
|---|---|
POST /pdf | Convert the HTML in the body to PDF and return it as application/pdf |
POST /pdf?stream=1 | The same, but sent with chunked transfer encoding as each page becomes final |
GET /healthz | ok |
GET /version | sghtmltopdf <version> |
Query parameters
Take any long CLI option, drop the --, and use it as a query key. Values are interpreted exactly as on the CLI, because they go through the same parser.
Only the options on the allowlist can be given. They cover things that may vary per request and touch neither the server’s filesystem nor the network: page geometry, PDF metadata, header/footer text, the look of the table of contents, and so on. Anything else returns 400.
| Query | The equivalent CLI option |
|---|---|
?page-size=A4 | --page-size A4 |
?margin-top=20mm | --margin-top 20mm |
?toc | --toc (no value means true) |
?grayscale=1 / =true | --grayscale |
?grayscale=0 / =false | The same as not passing it |
Values may be percent encoded, with %XX and +.
See the CLI reference for what each option means.
Options that can only be set when the server starts
The following are not on the allowlist; passing them returns 400. Options that take a local path, the output destination, access control and logging can only be set when the server starts.
font, font-index, gothic-font, gothic-font-index, serif-font, serif-font-index,
mono-font, mono-font-index, output, cover, header-html, footer-html,
user-style-sheet, base-url, allow, enable-local-file-access,
disable-local-file-access, allow-remote-assets, log-level, quiet
Status codes
| Code | When |
|---|---|
| 200 | Success, with Content-Type: application/pdf |
| 400 | An unknown or forbidden query key, a malformed value, or an empty body |
| 404 | An unknown path |
| 405 | A method that is not allowed |
| 413 | The body is larger than --max-body-size |
| 500 | Rendering failed |
| 503 | The queue is full, beyond --max-queue |
| 504 | --timeout exceeded (queue wait, or the conversion took too long) |
Streaming
- Input: the request body is not read to the end first; it is fed to the engine 64KiB at a time.
- A large HTML file never sits in memory as a whole
- Output: by default the PDF is buffered and returned with a
Content-Length. With?stream=1it is sent with chunked transfer encoding as each page becomes final
curl --data-binary @big.html 'http://127.0.0.1:8080/pdf?stream=1' -o out.pdf
Combined with the engine’s own streaming mode, selected with ?streaming, input, rendering, and output all become incremental.
Estimating memory
The memory a conversion needs is roughly proportional to the size of the input. Measured on an optimized build:
| Factor | Unit cost | What bounds it |
|---|---|---|
| Number of elements | 472 B to 1210 B per node | Node limit (500,000) |
| Amount of text | About 185 MiB per 1 MiB of input | --max-body-size |
Both limits are set so that the worst case stays around 600 to 750 MiB by default. Workers convert concurrently, so the whole process needs that figure multiplied by the number of workers. With the defaults (--workers is the CPU core count) on an 8-core machine the worst case is about 6 GiB, so tune --workers or --max-body-size to your container’s memory limit.
Exceeding the node limit returns 400. Adding ?streaming releases each processed part as it goes, which makes the node limit much harder to hit.
Known limitations
--timeoutcovers the queue wait plus the conversion. Expiry is checked per fed chunk, per top-level element and per written page, so it is noticed at most one such interval late. It does not look inside a single layout call--timeoutcovers the queue wait plus the conversion. The conversion checks for expiry per fed chunk, per top-level element and per written page, so it notices at most one such interval late. It does not look inside a single layout call- Measured: with a heavy 10 MiB HTML and
--timeout 2, actual responses took 2.1 to 5.2 seconds (including the time to drop the large DOM after aborting). It never returns sooner than the value you setMeasured: with a heavy 10 MiB HTML and--timeout 2, actual responses took 2.1 to 5.2 seconds (including the time to drop the large DOM after aborting). It never returns sooner than the value you set - With
?stream=1, a failure after the headers are sent leaves the status at 200; the pipe closes and the client receives an incomplete PDF. A bad query, an empty body, and an oversized body are all detected before the headers go out, so those still return 400 or 413 - With
?stream=1the input is read to the end first, because the HTTP library treats reading the body and writing the response as mutually exclusive. Streaming the input and the output at the same time is not possible
Using it from Rails
The Ruby gem has a server_url setting that delegates conversion to this server. See Ruby / Rails.