PDFPipe

Migrating from ReportLab / An imperative drawing API

Migrating off ReportLab, where there are no templates to port

A Python library with a low-level canvas and a higher-level flowable system, where nothing in the layout is markup and the document has to be written as HTML for the first time.

What ports and what does not

The data layer, which is usually most of the code. The layout does not port in either of ReportLab's two modes. Canvas code positions strings at coordinates and there is nothing to translate mechanically. Platypus code builds a list of flowables, Paragraph, Table, Spacer and PageBreak, with a stylesheet of ParagraphStyle objects, and that is closer to a document structure but still not markup: the styles are a different vocabulary and the frame and template machinery has no counterpart. What survives is the function that produced the rows.

What the call becomes

SimpleDocTemplate and its build call, or the canvas save, become a POST to /v1/pdf with html. The pagesize argument becomes options.format, and the margin arguments become options.margin. onFirstPage and onLaterPages, the callbacks that drew page furniture, become options.header_html and options.footer_html or CSS margin boxes. The PageTemplate and Frame machinery, which existed to describe regions of the page, has no counterpart because CSS describes regions differently: a frame becomes a container with a width, and a multi-frame layout becomes columns.

Side by side

The old shape and the new one, with the parts that have no counterpart called out in comments rather than quietly omitted.

python
# Before
from reportlab.platypus import SimpleDocTemplate, Paragraph, Table
from reportlab.lib.pagesizes import A4

def footer(canvas, doc):
    canvas.saveState()
    canvas.setFont("Helvetica", 8)
    canvas.drawString(15 * mm, 15 * mm, f"Page {doc.page}")
    canvas.restoreState()

doc = SimpleDocTemplate(buf, pagesize=A4,
                        topMargin=20*mm, bottomMargin=25*mm)
doc.build([Paragraph("Invoice", styles["Heading1"]),
           Table(rows, colWidths=[80*mm, 30*mm, 30*mm])],
          onFirstPage=footer, onLaterPages=footer)

# After: the same rows function, producing markup.
html = render_template("invoice.html", rows=rows, total=total)

resp = requests.post(
    "https://api.pdfpipe.xyz/v1/pdf",
    headers={"Authorization": f"Bearer {os.environ['PDFPIPE_KEY']}"},
    json={
        "html": html,
        "options": {
            "format": "A4",
            "margin": {"top": "20mm", "bottom": "25mm",
                       "left": "15mm", "right": "15mm"},
            "footer_html": '<div style="font-size:8pt">Page '
                           '<span class="pageNumber"></span></div>',
        },
    },
    timeout=60,
)

# The onFirstPage and onLaterPages callbacks are Python functions that
# drew on the canvas. They become markup. Anything they computed from
# doc state beyond the page number has no equivalent.

The CSS delta, in both directions

What the old engine accepted that a current one does not, and what it refused that a current one wants. The second direction is the one people forget, and it is where the value of the migration is.

  • There is no delta, because there was no CSS. Every style in the document is being written for the first time, and the ParagraphStyle objects are the specification for it: font size, leading, alignment and spacing all have direct CSS counterparts and are worth transcribing rather than reinventing.
  • Table styling moves from ReportLab's TableStyle command list, which addressed cells by coordinate ranges, to CSS selectors. That is a genuine improvement and also the fiddliest part of the conversion.
  • KeepTogether becomes break-inside: avoid, and PageBreak becomes break-before: page. Those two map cleanly and cover most of what the flowable list was doing about pagination.
  • Frames become containers or columns, and the multi-frame page templates become a grid or a columns rule.

What changes without anything erroring

The dangerous list. Each of these produces a different document and no diagnostic, so none of them are caught by a test that only checks the render succeeded.

  • Everything about the appearance, because it is being rebuilt. This migration does not drift, it changes, and treating it as a rewrite is the correct expectation to set.
  • Page count.
  • Fonts, which move from ReportLab's font registration to @font-face.
  • Any conditional drawing the page callbacks did, which is invisible in the flowable list and easy to miss when reading the code for the conversion.

What to diff before cutting over

Diff the extracted text, not the appearance. The appearance is intentionally different, and the thing worth proving is that the same content, the same numbers and the same rows came out. A text diff does that and a visual comparison does not.

The one thing that always breaks

The page callbacks. onFirstPage and onLaterPages drew the letterhead, the rules and the folio, they are ordinary Python functions living well away from the flowable list, and they are the code most likely to be overlooked when somebody reads the module to plan the conversion. The body converts, the document loses its furniture, and it is discovered by looking at a page.

Frequently asked

Do I have to rewrite my templates to leave ReportLab?

Effectively yes, and it is better to plan for that than to discover it. The old tool did not have HTML templates to port, so the document has to be expressed as markup for the first time. The value in the old code is the data layer underneath the drawing, and that part survives untouched.

Can I run both for a while?

Yes, and it is the safest way to do it. Put both behind one internal function that takes your data and returns bytes, switch on an environment variable, and run the new path on real traffic while the old one still serves. You get a diff on real documents rather than on fixtures, and you keep a way back that does not involve a deploy.

What about the documents already generated?

Nothing here changes them. They are files that already exist. What is worth deciding before the cutover is whether a regenerated document has to match the original byte for byte or merely say the same thing, because for anything with a legal or audit character the answer is usually to keep the original file rather than to be able to reproduce it.

Other migrations

The nearest neighbours first, then others that started from the same kind of tool, because the model matters more than the language.

Render one of your existing documents through the playground before changing any code. That comparison is the whole of the risk assessment for this migration.