Typesetting a print-ready paperback with HTML and WeasyPrint

AI

Environment: WeasyPrint (system Python 3, not the venv), CSS Paged Media, B5 trim. The interiors described here went to print in July 2026.

Our book manuscripts are Markdown in a git repository. The print interior has to be a PDF that a print-on-demand service will accept — correct trim size, correct gutter for the page count, no content in the margins, fonts embedded.

Between those two things we put HTML and CSS, rendered by WeasyPrint.

The reason is not that HTML is a good typesetting language. It is that the alternative — a layout application — breaks the one property the rest of the pipeline depends on: that the book can be regenerated from source. When a check finds a problem in chapter 6, the fix goes into the Markdown and the PDF is rebuilt. A hand-adjusted layout file makes that a manual re-import every time.

Diagram: a facing-page spread with the gutter, outer, top and bottom margins labelled, showing that the gutter swaps sides between recto and verso, and that the minimum gutter depends on the final page count
The margins are not a style choice. The gutter minimum is a function of the page count, which is an output of the typesetting.

The box you are printing into

Print-on-demand margin rules are specific and non-negotiable. The interesting one is the gutter — the inner margin, swallowed by the binding — because it scales with how thick the book is:

Page count Minimum gutter
24–150 0.375″ = 9.6 mm
151–300 0.5″ = 12.7 mm
301–500 0.625″ = 15.9 mm
501–700 0.75″ = 19.1 mm

Outer, top and bottom are 0.25″ = 6.4 mm minimum without bleed, 0.375″ with.

Note the circularity. The gutter you need depends on the page count; the page count is an output of the typesetting, which depends on the margins. Set 9.6 mm because you expect 140 pages, land on 156, and you are out of spec — with a fix that changes the page count again.

So don’t design to the minimum. For a B5 book expected to land in the 151–300 band we use:

top right bottom left
recto — odd page 20 15 outer 22 22 gutter
verso — even page 20 22 gutter 22 15 outer

Same four numbers on both; only the side the gutter sits on changes. 22 mm against a 12.7 mm minimum. The headroom absorbs a hundred pages of drift in either direction, and a generous inner margin is what a book looks like anyway.

In CSS this is @page :left and @page :right, which is the part of Paged Media that makes the whole approach viable:

@page :right { margin: 20mm 15mm 22mm 22mm; }   /* gutter on the left  */
@page :left  { margin: 20mm 22mm 22mm 15mm; }   /* gutter on the right */

Four things WeasyPrint will not draw

These cost days, so here they are plainly. When a rule or a shape "disappears", it is almost always one of these.

<ruby> and <rt> are not implemented. This is the expensive one, because it does not fail — it succeeds incorrectly. The ruby text flows into the body copy as if it were ordinary text. A word annotated with its reading prints as the word and the reading run together, with no error, no warning, and no visual signal that anything went wrong other than the text being wrong. We found eleven instances in one chapter, in a proof, by reading it.

The fix is to build ruby yourself:

# ★<ruby>/<rt> は使わない。WeasyPrint が ruby を実装しておらず、
#   <rt> の中身が本文にそのまま流れ込む(「訪《おとず》れた」→「訪おとずれた」)。
#   2026-07-16 に ch01 の11箇所で印字を確認。book-4q.css 側で自前に組む。
s = re.sub(r'([一-鿿]+)《([^》]+)》',
           r'<span class="ruby">\1<span class="rt">\2</span></span>', s)
.ruby { position: relative; display: inline-block; }
.rt   { position: absolute; left: -1.2em; right: -1.2em; top: -.6em;
        font-size: .5em; text-align: center; white-space: nowrap; }

position: absolute inside a flex item resolves the wrong containing block. Absolutely positioned children of flex items land somewhere else on the page, or vanish. A row of marker glyphs positioned over their parent boxes came out scattered into unrelated boxes.

Gradient backgrounds sometimes don’t paint at all. linear-gradient and repeating-linear-gradient — used, in our case, to draw a set of ruled lines as a background. All four lines silently absent.

Inline SVG inside a flex item is unstable — the same containing-block problem, and it is genuinely intermittent: one SVG rendered, another in a different flex context did not.

The safe set, all of it verified in print:

  • Draw rules and shapes with border on the element itself. Not pseudo-elements, not absolute positioning, not gradients.
  • For side-by-side layout, display: table / table-cell is markedly more robust than flex.
  • If you need overlay positioning, put the position: absolute child inside a position: relative parent that is not a flex item. It works correctly there.

The good half: you can measure before you commit

WeasyPrint’s render() returns the laid-out document as a box tree before writing any PDF. So layout decisions that would otherwise be guesses become measurements.

A concrete one: answer options in a drill book should be set in four columns when they fit, two when they don’t, one when they really don’t. That depends on the widest option, in the actual font, at the actual size. So render a fragment and look:

def opt_width(label, text):
    frag = (f'<div class="q"><div class="o"><span style="white-space:nowrap">'
            f'<span class="ol">{label}</span>{esc(text)}</span></div></div>')
    doc = HTML(string=f'…<div class="main">{frag}</div>…').render(
              stylesheets=[CSS(string=_css, base_url=str(HERE))])
    w = []
    def walk(b):
        if type(b).__name__ == "InlineBlockBox":
            w.append(b.margin_width() * PX2PT)
        for ch in getattr(b, "children", []):
            walk(ch)
    for p in doc.pages:
        walk(p._page_box)
    return max(w) if w else 0.0

def cols_for(opts):
    mx = max(opt_width(f"{i+1}.", t) for i, t in enumerate(opts))
    for c in (4, 2, 1):
        if c * (mx + GAP) <= COL_W:
            return c
    return 1

Results are cached, because rendering a fragment per option is not free, and the same option strings recur.

The three traps in reading the box tree

We hit all three, in one afternoon, and every one of them produced a number that looked completely reasonable.

Line boxes point at the same element as their block. Walk the tree naively, match on class, and a <p> with four lines gives you five hits, not one. A document containing four elements with class="d" returned forty-four boxes. Taking [0] is fine. The bug arrives the day you change [0] to sum() — a 22.8 pt heading became 35.8 pt, and the total for a page was inflated by about 13 pt. Match, then do not descend.

box.height excludes margins. Use box.margin_height(). Obvious in retrospect, invisible in a number.

The box tree is in px at 96 dpi, not points. With @page { size: 182mm } you get page.width = 687.9, not the 515.9 you would get in points. Multiply by 0.75. A layout that is "consistently 33% too big" is this and nothing else.

And one procedural trap: to measure a block’s natural height you have to remove the overflow: hidden clamp — at which point the content flows onto a second page and the box you are measuring is split across both. Measure with @page { size: … 4000mm !important } and assert len(doc.pages) == 1.

What that taught us, which was not what we expected

Every one of those was a bug in the measuring code, not the layout. And the measurements came out confident: clean floating-point numbers, no warnings, no anomalies.

On the strength of one of them we sent a chapter back to be compressed. Twice. The text was fine; the ruler was broken. The agent that had produced the original text had, each time, correctly labelled its own page estimate as an estimate — while the "real measurement" that overruled it was wrong.

The rule we had been operating on was measure, don’t eyeball. That rule is wrong, or at least badly incomplete. Both lie. A measurement can be confidently wrong in a way a glance at the page cannot, precisely because it arrives as a number. When the number and the page disagree, suspect the number first — it has more ways to be wrong.

Print the proof. Look at it. Then trust the ruler.


Next: a shorter one — the PDF export in Playwright is 96 dpi, not 72, and everything you specify in millimetres comes out 1.333× too large until you know that.

タイトルとURLをコピーしました