Fit a report to one page by measuring it, not by guessing the CSS

Local Fitness · No. 066

Shipped

This release made two generated PDFs in my fitness agent, a daily brief and a workout report card, land on exactly one page every time. Before it, a three-signal brief spilled onto a second page while part of the first page sat empty, and a card with a lot of splits did the same. Neither renderer had any idea how tall its own output was.

The fix generalizes past this repo: if you build documents from HTML and CSS, you can measure the rendered page count and adjust until the document fits, instead of hand-tuning font sizes and praying. Here is how to build that.

The problem with guessing

The usual approach to “make it fit” is to shrink the font, test on today’s data, and move on. That holds until the data changes. A brief with five signals overflows where three fit; a card with eight splits overflows where four fit. You cannot tune one stylesheet to content you have not seen.

The better move is to let the renderer tell you. WeasyPrint’s HTML.render() “lay[s] out and paginate[s] the document, but do[es] not (yet) export it”, returning a Document whose pages attribute is “a list of Page objects”. So len(document.pages) is the page count, available before you write a single byte of PDF. That one number is the whole technique.

Build the density ladder

A “density” is a small set of scalars threaded into one stylesheet, ordered roomiest first. Keep it one stylesheet with parameters, never two sheets: a rule fixed in one place cannot then be missing from the other.

# onepage.py
import weasyprint

DENSITY_PRESETS = (
    {"name": "roomy",   "body_pt": 11.3, "chart_h_pt": 150.0, "gap_em": 0.75},
    {"name": "compact", "body_pt": 10.4, "chart_h_pt": 112.0, "gap_em": 0.55},
    {"name": "dense",   "body_pt": 9.6,  "chart_h_pt": 82.0,  "gap_em": 0.40},
)

chart_h_pt is the load-bearing knob, and this is the least obvious part. Tall images are what break a page, not type size. Capping an image’s height is the only lever that reliably buys vertical room, because a max-width: 100% alone lets the image’s own aspect ratio decide how much page it eats. The cap has to be on height, with width: auto so the aspect is preserved:

# onepage.py, continued
def build_html(cards, density, omitted=0):
    d = density
    note = ""
    if omitted:
        s = "" if omitted == 1 else "s"
        note = f'<p class="omitted">{omitted} card{s} omitted for space.</p>'
    blocks = "".join(
        f'<section><h2>{title}</h2><p>{body}</p>'
        f'<img class="chart" src="{_CHART}" alt="chart"></section>'
        for title, body in cards
    )
    return f"""<!doctype html><html><head><meta charset="utf-8"><style>
      @page {{ size: A4; margin: 1.5cm; }}
      body {{ font-family: sans-serif; font-size: {d["body_pt"]}pt; }}
      section {{ margin-bottom: {d["gap_em"]}em; page-break-inside: avoid; }}
      h2 {{ font-size: 1.1em; margin: 0 0 0.2em 0; }}
      img.chart {{ max-width: 100%; max-height: {d["chart_h_pt"]}pt; width: auto; }}
      .omitted {{ font-size: 0.7em; color: #666; border-top: 1px solid #999; }}
    </style></head><body>{blocks}{note}</body></html>"""

The @page rule sizing the sheet is CSS Paged Media, which WeasyPrint implements; page-break-inside: avoid keeps a card from splitting across the page boundary, which is what makes the page-count signal meaningful in the first place.

Stub the chart as an inline SVG so the file has no external assets and every reader runs the same thing. In your real report this is your rendered chart PNG; here it only has to occupy the height the cap allows:

# onepage.py, continued
_CHART = (
    "data:image/svg+xml;utf8,"
    "<svg xmlns='http://www.w3.org/2000/svg' width='800' height='400'>"
    "<rect width='800' height='400' fill='%23e8e8e8'/>"
    "<polyline points='0,360 200,180 400,240 600,80 800,140' "
    "fill='none' stroke='%23333' stroke-width='6'/></svg>"
)

Fit by measuring

Now the core loop. Render at each density, count the pages, stop at the first one that fits:

# onepage.py, continued
def fit_one_page(render):
    """Lay render(density) out at each density until it fits one page.

    Returns (pdf_bytes, page_count, density_index). A caller that still gets a
    count > 1 knows the ladder is exhausted and it is content, not type size,
    that has to give.
    """
    doc, index = None, 0
    for index, density in enumerate(DENSITY_PRESETS):
        doc = weasyprint.HTML(string=render(density)).render()
        if len(doc.pages) == 1:
            break
    return doc.write_pdf(), len(doc.pages), index

One detail earns its own sentence: call render() once and write_pdf() on that same Document. Document.write_pdf() “paint[s] the pages in a PDF file”; rendering is the expensive half, so you never want to render the winning layout twice. The function takes a callable, not a specific document type, so every report you generate shares one definition of “is this one page”.

Shrink first, then truncate, and say so

The ladder shrinks. But five heavy cards will overflow the densest rung, and at that point type size is out of moves. The honest response is to drop the lowest-priority content and state that you did, never to silently spill onto a second page and never to silently hide a card:

# run_onepage.py
from onepage import DENSITY_PRESETS, build_html, fit_one_page

def render_at_most_one_page(cards):
    """Shrink first, then truncate. Only when the densest rung STILL overflows
    do we drop the lowest-priority card and try again."""
    kept, omitted = list(cards), 0
    while True:
        pdf, pages, index = fit_one_page(
            lambda d: build_html(kept, d, omitted))
        if pages == 1 or len(kept) <= 1:
            return pdf, pages, DENSITY_PRESETS[index]["name"], omitted
        kept, omitted = kept[:-1], omitted + 1

Order your content most-important-first so the tail is the cheapest thing to lose. The omitted count flows into build_html, which prints it on the page. A reader who sees “1 card omitted for space” knows the report is complete as shown; a reader who silently got four of five signals does not.

Run it

Feed it priority-ordered cards and watch both behaviors:

# run_onepage.py, continued
import weasyprint

CARDS = [
    (f"Signal {i}", "A paragraph of the length this kind of report actually "
     "produces, roughly thirty words, so the measured height is honest about "
     "what fits rather than optimistic. It cites a number and says what to do.")
    for i in range(6)
]

def pages_at(cards, density):
    return len(weasyprint.HTML(string=build_html(cards, density)).render().pages)

for n in (4, 6):
    cards = CARDS[:n]
    ladder = "  ".join(
        f"{d['name']}={pages_at(cards, d)}p" for d in DENSITY_PRESETS)
    _pdf, pages, density, omitted = render_at_most_one_page(cards)
    outcome = "shrank" if omitted == 0 else f"shrank + dropped {omitted}"
    print(f"{n} cards | fixed ladder: {ladder} | "
          f"result: {pages} page, {density} density, {outcome}")

Install WeasyPrint with pip install weasyprint (on macOS it needs the Pango libraries from Homebrew on the dylib path, DYLD_LIBRARY_PATH=$(brew --prefix)/lib; on Debian/Ubuntu, apt-get install libpango-1.0-0 libpangocairo-1.0-0). Then python run_onepage.py prints:

4 cards | fixed ladder: roomy=2p  compact=1p  dense=1p | result: 1 page, compact density, shrank
6 cards | fixed ladder: roomy=2p  compact=2p  dense=2p | result: 1 page, dense density, shrank + dropped 1

Read the two lines. With four cards, the fixed ladder shows roomy needs two pages but compact fits, so the loop stops at compact with nothing dropped: pure shrink. With six cards, every density needs two pages, so the loop drops one card and lands on one page at dense: shrink then truncate. Same code, and the document is one page either way.

Gotchas

table > tr never matches, so your row styling silently does nothing. I laid the report out as a two-column table and set table > tr > td { vertical-align: top } to top-align the columns. It had no effect: one column floated in the vertical center of the page under a large void. The trap is that the HTML parser inserts an implicit <tbody> when <tr> elements are direct children of a <table>, and as MDN puts it, “CSS selectors such as table > tr will not select these elements.” The symptom is a rule that appears correct and does nothing. The escape is to drop the child combinator, table td { vertical-align: top }, or write the <tbody> yourself.

max-width alone makes the density ladder a no-op. My first cut capped the chart with max-width: 100% and nothing else, then could not understand why stepping to a smaller density barely changed the page count. A width cap lets the image’s aspect ratio set its height, so the height, which is what fills the page, never moves. The symptom is a ladder that shrinks type but not pages. The escape is to cap max-height and set width: auto, as above.

Fixing one specificity bug can create another. After I switched to a descendant selector, a later table td { padding: 0 } outranked the per-column td.col-plan { padding-left: ... } and stripped a gutter, printing one column hard against the divider. When a broad element rule and a narrow class rule set the same property, the more specific selector wins per the CSS cascade, which is easy to lose track of when you are moving fast. Keep the property that varies per element on the specific selector alone.

Sources

Changelog

  • release: 0.28.0 — one-page PDFs, walk-gated mileage, and one coach voice (dev → main) (#138) (b9dbcb9)