spool2pdf.

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

MethodPathAuthReturns
POST/api/renderNone The PDF, or a JSON envelope on request
GET/api/healthNone {"status":"ok","version":"..."}
GET/api/optionsNone 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

OptionDefaultMeaning
paperA4A3, A4, A5, LETTER, LEGAL, TABLOID, LEDGER
landscape0Swap width and height
linesautoLines per page. Omitted, it fits to the page height
leftMargin25Left origin, in points
topMargin40Top margin, in points

Type

OptionDefaultMeaning
fontCourierCourier, Helvetica or Times. Ignored if a font file is supplied
bold0Bold weight of the chosen base font
pointSize10Type size, in points
vertSpace12Leading, baseline to baseline, in points
tab8Columns per tab stop

Text handling

OptionDefaultMeaning
encodinglatin-1Code page used to decode uploaded bytes
stripControl1Remove embedded printer control codes
skipLines0Blank the first N lines of the document, in place
skipEachPage0Blank the first N lines of every page, in place
trimTrailingBlanks1Drop trailing whitespace-only lines

Pagination

OptionDefaultMeaning
formFeeds1Treat 0x0c as a page break
splitOnStart a new page at every line carrying this marker
splitRegex0Treat splitOn as a regular expression
trimBlankPages1Drop wholly blank pages from each end of the document
pagesallall, first, last, 3, 2-4, 3-, -2, 1,3,5-7

Canvas, properties and security

OptionDefaultMeaning
canvasModeautoauto or cycle, see below
title author subject keywords creatorDocument properties, written to both DocInfo and XMP
userPasswordRequired to open the document
ownerPasswordRequired to change permissions
allowPrint allowCopy allowEdit allowAnnotate1Set 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:

ModeBehaviour
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."
  }
}
CodeStatusMeaning
empty_body400No document was sent
no_document400The request had options but no text
bad_json400The body is not a JSON object
bad_type400text was not a string
bad_base64400A base64 field would not decode
double_encoded400The document is base64 that was encoded twice
bad_encoding400Unknown code page, or the text is not valid in it
unknown_option400An option name that does not exist. The reply lists the valid ones
bad_option400A valid option with an unusable value
too_large413Document, canvas or font over the size limit
render_failed500Something unexpected. Nothing useful is leaked; the detail is logged