Quick start
The body is the document. Options go in the query string.
curl --data-binary @report.txt \ 'https://api.spool2pdf.app/api/render?paper=A4&pointSize=9' \ -H 'X-Api-Key: YOUR_KEY' \ -o report.pdf
To add a letterhead, switch to multipart so you can attach it:
curl -X POST https://api.spool2pdf.app/api/render \
-H 'X-Api-Key: YOUR_KEY' \
-F text=@report.txt -F canvas=@letterhead.pdf \
-F encoding=cp850 -F pointSize=9 \
-o report.pdf
Endpoints
| Method | Path | Auth | Returns |
|---|---|---|---|
POST | /api/render | API key | The PDF, or a JSON envelope on request |
GET | /api/health | None | {"status":"ok","version":"..."} |
GET | /api/options | None | Every option and its default, generated from the running code |
The render response carries X-Pdf-Pages and
X-Pdf-Bytes. Add ?response=json, or send
Accept: application/json, to get
{pdfBase64, pages, bytes, width, height} instead. A query
parameter beats the header if both are present.
Sending a document
Raw body
Any content type other than JSON or multipart. The body is the document and every option comes from the query string. This is the mode to use from a shell, because it makes the service a pipe.
acctrpt -m 08 | curl --data-binary @- '.../render?pointSize=9' -o out.pdf
multipart/form-data
File parts: text (also accepted as document or
file), canvas (or background) and
font. Options are ordinary form fields. text may
also be sent as a form field rather than a file.
application/json
For platforms that cannot post raw bytes. Options may sit at the top level or
nested under options; both work.
{
"text": "already-decoded string",
// or, for raw bytes in a legacy code page:
"textBase64": "<base64 of the file>",
"canvasBase64": "<base64 of the letterhead PDF>",
"fontBase64": "<base64 of a monospace TTF>",
"options": { "paper": "A4", "encoding": "cp850" }
}
Send raw text in text, or base64 of the raw bytes in
textBase64, never base64 of base64. Some connector platforms
re-encode anything placed in a byte field, so pass file content straight
through rather than encoding it yourself first. Doubly encoded input is
detected and rejected rather than rendered as one unreadable line.
All options
Every option is optional. Names resolve case-insensitively, so
pointSize, pointsize and POINTSIZE are
the same option. An unrecognised name is an error rather than something
quietly ignored. GET /api/options returns this table generated
from the running code.
Page
| Option | Default | Meaning |
|---|---|---|
paper | A4 | A3, A4, A5, LETTER, LEGAL, TABLOID, LEDGER |
landscape | 0 | Swap width and height |
lines | auto | Lines per page. Omitted, it fits to the page height |
leftMargin | 25 | Left origin, in points |
topMargin | 40 | Top margin, in points |
Type
| Option | Default | Meaning |
|---|---|---|
font | Courier | Courier, Helvetica or Times. Ignored if a font file is supplied |
bold | 0 | Bold weight of the chosen base font |
pointSize | 10 | Type size, in points |
vertSpace | 12 | Leading, baseline to baseline, in points |
tab | 8 | Columns per tab stop |
Text handling
| Option | Default | Meaning |
|---|---|---|
encoding | latin-1 | Code page used to decode uploaded bytes |
stripControl | 1 | Remove embedded printer control codes |
skipLines | 0 | Blank the first N lines of the document, in place |
skipEachPage | 0 | Blank the first N lines of every page, in place |
trimTrailingBlanks | 1 | Drop trailing whitespace-only lines |
Pagination
| Option | Default | Meaning |
|---|---|---|
formFeeds | 1 | Treat 0x0c as a page break |
splitOn | — | Start a new page at every line carrying this marker |
splitRegex | 0 | Treat splitOn as a regular expression |
trimBlankPages | 1 | Drop wholly blank pages from each end of the document |
pages | all | all, first, last, 3, 2-4, 3-, -2, 1,3,5-7 |
Canvas, properties and security
| Option | Default | Meaning |
|---|---|---|
canvasMode | auto | auto or cycle, see below |
title author subject keywords creator | — | Document properties, written to both DocInfo and XMP |
userPassword | — | Required to open the document |
ownerPassword | — | Required to change permissions |
allowPrint allowCopy allowEdit allowAnnotate | 1 | Set any to 0 to withhold that permission |
Geometry
Text is placed on a fixed grid rather than flowed. The first baseline sits at
y = pageHeight - topMargin - vertSpace
measured in PDF points, with the left origin at leftMargin, a
monospace advance across the line and vertSpace between
baselines. Every value is in points, so a report tuned once keeps its
registration on any paper size that shares the same origin.
To match an existing document, start from its point size and leading, then adjust the margins until an overlay of the two lines up. Rendering the new output into one colour channel and the original into another makes any drift obvious: where they agree the result is neutral, where they do not you get a coloured fringe.
The canvas
The canvas is the letterhead or pre-printed form the text is laid over. Supply it as a PDF. It is placed at its own size, never rescaled to fit, because scaling a letterhead by a fraction of a percent is exactly the drift that shows up as misregistration against a pre-printed design.
A multi-page canvas maps onto the output according to canvasMode:
| Mode | Behaviour |
|---|---|
auto |
Page N takes canvas page N, and the last canvas page repeats for everything beyond it. A one-page canvas therefore backs the whole document, and a two-page canvas gives letterhead then continuation stationery with nothing to configure. |
cycle |
Canvas pages repeat in a loop, for multi-part stationery where the sequence itself matters. |
The canvas is stored once and referenced from each page, so output size stays flat however many pages the run produces.
Page selection uses original page numbers rather than positions in the output, so rendering a document in slices and merging the slices downstream produces the same result as rendering it in one call.
Encoding
Legacy print streams are single-byte. cp850 is usual in Europe
and cp437 in the US; both carry box-drawing characters and
currency symbols at byte values that mean something else in Latin-1 and are
invalid in UTF-8. Set encoding to the code page the system
actually emits. Any codec Python knows is accepted.
Decoding is strict. A wrong code page produces an error naming the offending byte and its offset, rather than scattering replacement characters through an invoice where nobody notices until a customer does.
The option applies to bytes. If you send text as a JSON string
it has already been decoded by your client, and the option has nothing left
to do. To hand over raw code-page bytes, use textBase64 or a
multipart file part.
Pagination
Pages break on form feeds, on a content marker, or on line count, and all three can apply at once.
Form feeds. 0x0c starts a new page. An eject at the very
start or end of the file is treated as an instruction to move the paper
rather than as a page, so a file that opens by ejecting to top of form does
not gain a blank sheet in front of it. Blank pages inside the document are
kept, because those are usually deliberate. Set
trimBlankPages=0 to keep every one.
Markers. splitOn starts a new page at every
line containing the marker, and that line becomes the first line of the new
page, because that is what these markers are. Matching is a substring test by
default, since a marker rarely sits alone at column 0. Set
splitRegex=1 for a regular expression, which is searched rather
than anchored, so anchor it with ^ yourself if you mean to.
Blanking without moving. skipLines and
skipEachPage blank lines in place rather than deleting them, so
everything below keeps its row on the grid. That distinction matters on a
positional form, and it means hiding a marker line does not change where
pages begin.
Fonts
The built-in fonts are Courier, Helvetica and Times, which cover Latin-1 and
nothing else. Attach a monospace TTF or OTF as the font part to
go further: it is embedded and subsetted, which adds roughly 25KB once, and
unlocks box-drawing characters and full Unicode.
It must be monospace, or columns will not align. Its weight is whatever the
file carries, so bold is ignored when a font file is supplied.
Security
Encryption is AES-256 only. The 40-bit and 128-bit RC4 modes older tools still offer are broken, and PDF 2.0 removed both, so there is no option to select them.
Setting a password or withholding any permission engages encryption. A
userPassword is required to open the document; an
ownerPassword is required to change its permissions.
If you restrict a document without supplying an ownerPassword,
one is generated and discarded. An empty owner password lets any viewer lift
every restriction with a single click, which is how this feature usually
ships. Supply your own if you need to unlock the document later.
Extraction by assistive technology is always permitted and is not exposed as an option. Withholding it helps nobody who can already read the page, and PDF 2.0 deprecates the bit.
Errors
Errors are JSON, with a stable machine-readable code.
{
"error": {
"code": "bad_encoding",
"message": "document is not valid utf-8: byte 0x9c at offset 6. Legacy spool files are usually cp850 or cp437 rather than utf-8; set the 'encoding' option to match."
}
}
| Code | Status | Meaning |
|---|---|---|
empty_body | 400 | No document was sent |
no_document | 400 | The request had options but no text |
bad_json | 400 | The body is not a JSON object |
bad_type | 400 | text was not a string |
bad_base64 | 400 | A base64 field would not decode |
double_encoded | 400 | The document is base64 that was encoded twice |
bad_encoding | 400 | Unknown code page, or the text is not valid in it |
unknown_option | 400 | An option name that does not exist. The reply lists the valid ones |
bad_option | 400 | A valid option with an unusable value |
too_large | 413 | Document, canvas or font over the size limit |
render_failed | 500 | Something unexpected. Nothing useful is leaked; the detail is logged |