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' \
-o report.pdf
To add a letterhead, switch to multipart so you can attach it:
curl -X POST https://api.spool2pdf.app/api/render \ -F [email protected] -F [email protected] \ -F encoding=cp850 -F pointSize=9 \ -o report.pdf
Endpoints
| Method | Path | Auth | Returns |
|---|---|---|---|
POST | /api/render | None | 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 |
No credential is required during the beta, so none of the examples carry one. Requests are rate limited per client IP instead. If keys are introduced later they will be additive, and anything written against the current shape will keep working.
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.
cat report.txt | curl --data-binary @- '.../render?pointSize=9' -o out.pdf
Percent-encode spaces and other reserved characters in option values.
A marker like STATEMENT NO has to be sent as
splitOn=STATEMENT%20NO; curl rejects a raw space in a URL
outright rather than encoding it for you. If a value is awkward to encode,
use multipart or JSON instead, where it is an ordinary field.
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.
Both at once. A marker line is a page header, so it usually
sits directly after a form feed and both mechanisms break at the same
boundary. When splitOn is active, blank pages are dropped
wherever they fall, because once you have declared where documents begin an
empty page between two of those boundaries cannot be content. With
splitOn off, a run of form feeds keeps its literal meaning and
\f\f still asks for a blank sheet.
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, so only the glyphs your document
actually uses are stored, and it unlocks box-drawing characters and full
Unicode. The cost is paid once per document rather than per page, and how
much it comes to depends on the font and on how many distinct characters
the document contains.
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.
If you need one, DejaVu Sans Mono is monospace, carries the full box-drawing range, and is freely redistributable (licence). Do not reach for Courier New: it is licensed by its foundry and is not yours to redistribute, whatever your operating system happens to have installed. The tuning tool loads a report drawn entirely in these characters, so you can see the difference by removing the font and rendering again.
Security
Encryption is AES-256 only. The 40-bit and 128-bit RC4 modes defined by earlier versions of the PDF specification are broken: 40 bits is brute forced in seconds, and RC4 is unsafe at any key length. 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, so the restriction would be
decorative. 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.
Common problems
Nearly every support question about legacy print output is one of these. The tuning tool is the fastest way to work through them, because you can see each change land.
The columns do not line up
Something is not monospaced. Check font is Courier
rather than Helvetica or Times, and if you supplied a font file, check that
the font itself is monospace. A proportional font fans the columns out
progressively, so the first few characters look right and the right-hand
edge drifts further wrong down the line.
There is a blank page at the front
Almost always a leading form feed, the eject to top of form that most print
streams open with. It is treated as an eject rather than a page, so this
should not happen; if it does, the first page is not blank but is being
drawn off the visible area. Check topMargin against the page
height, and check lines is not set higher than the page can
hold. trimBlankPages=0 restores every blank page if you
actually want them.
Box drawing, currency or accented names come out as junk
Two separate causes, and you usually have both. First, the bytes are being
decoded with the wrong code page: set encoding to
cp850 or cp437. Second, the built-in fonts only
cover Latin-1 and have no box-drawing glyphs at all, so even correctly
decoded characters have nothing to render with. Attach a monospace font file
that contains them, such as
DejaVu Sans Mono.
The text sits on top of the letterhead
Raise topMargin until the first row clears the design. The text
layer knows nothing about the canvas underneath it, which is deliberate: it
means the same document can be laid over different stationery without
reflowing. If the body then runs off the foot of the page, reduce
vertSpace slightly or set lines explicitly.
Pages break in the wrong place, or not at all
If the file has no form feeds, pagination falls back to line count, which
will not respect document boundaries. Set splitOn to the text
each document starts with, such as a statement number heading. If the marker
only counts at the start of a line, set splitRegex=1 and anchor
it with ^, since matching is a substring search by default.
I need one PDF per statement, not one big file
Render once with splitOn to find out how many pages you get,
then call again per document with pages set to that range. Page
numbers are stable across calls regardless of selection, so the pieces line
up with the canvas exactly as they would have in a single render.
The output file is enormous
It should not be. The canvas is stored once and referenced from every page, so a 200-page run over a 200KB letterhead lands near 110KB rather than 40MB. If you are seeing size grow in proportion to page count, you are merging separately rendered pages downstream with a tool that copies the background into each one; merge with something that preserves shared objects.
Matching an existing pre-printed form exactly
Start from the original's point size and leading, then adjust the margins rather than anything else. To see the drift, render your output into one colour channel of an image and the scanned original into another: where they agree the result is neutral, and where they do not you get a coloured fringe that shows you which way to move. A tenth of a point at a time is usually enough by that stage.
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 |