Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

OptionDefaultDescription
--listen <ADDR:PORT>127.0.0.1:8080The address to listen on. :0 picks a free port automatically
--workers <N>CPU coresHow many worker threads convert at the same time
--max-queue <N>Workers × 4How many requests may wait to be accepted; beyond that, 503
--max-body-size <BYTES>4194304 (4 MiB)The maximum request body size
--timeout <SECS>30Seconds granted to one request: the queue wait plus the conversion (exceeding it returns 504)
--font <PATH> and the other font optionsCannot be changed per request
--enable-local-file-access, --allow <PATH>, --allow-remote-assetsAll deniedOnly 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 pathDescription
POST /pdfConvert the HTML in the body to PDF and return it as application/pdf
POST /pdf?stream=1The same, but sent with chunked transfer encoding as each page becomes final
GET /healthzok
GET /versionsghtmltopdf <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.

QueryThe 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 / =falseThe 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

CodeWhen
200Success, with Content-Type: application/pdf
400An unknown or forbidden query key, a malformed value, or an empty body
404An unknown path
405A method that is not allowed
413The body is larger than --max-body-size
500Rendering failed
503The 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=1 it 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:

FactorUnit costWhat bounds it
Number of elements472 B to 1210 B per nodeNode limit (500,000)
Amount of textAbout 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

  • --timeout covers 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--timeout covers 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=1 the 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.