DEV Community

Hannah Whitfield
Hannah Whitfield

Posted on

Automatic Page Layout Is a Search Problem

Every recipe layout system I've seen starts the same way. A designer makes a beautiful two-page spread — photo left, recipe right — then someone tries it with a recipe that has 22 ingredients and 14 steps. The text overflows, and now you have that decision to make automatically, on every page, for a book with sixty of them.

That decision problem, not PDF rendering, is where print automation lives.

Fitting Is a Search Problem, Not a CSS Problem

The naive approach is to lay out the content and let it flow. For a bound book with facing pages that's exactly wrong: a recipe spilling three lines onto a second page wastes a full leaf and pushes every subsequent spread out of parity, so your left-hand photos end up on the right.

Treat each recipe instead as a fitting problem with a small set of levers, and search over them. Ours:

  • ingredient list column count (1 or 2)
  • body type size, quantized (9.5 / 10 / 10.5 pt — never continuous)
  • leading, as a multiple of the baseline grid
  • photo size tier (full-bleed / half / thumbnail / none)
  • an optional, droppable tip box

Then score candidates and take the best fit:

def fit_recipe(recipe, frame):
    best = None
    for cfg in candidate_configs(recipe):     # a few dozen combos, cheap
        box = measure(recipe, cfg)            # height in points
        if box.height > frame.height:
            continue                          # overflow: reject
        slack = frame.height - box.height
        cost = (W_SLACK   * slack                        # prefer full pages
              + W_SIZE    * abs(cfg.size - IDEAL_SIZE)
              + W_DENSITY * density_penalty(cfg)
              + W_PHOTO   * photo_penalty(cfg))
        if best is None or cost < best.cost:
            best = Candidate(cfg, cost)
    return best or fit_recipe_two_page(recipe, frame)
Enter fullscreen mode Exit fullscreen mode

The important detail is quantization. Let type size vary continuously and adjacent recipes land at 9.7pt and 10.3pt — the book looks subtly broken in a way readers feel but can't name. Three or four discrete sizes, applied per section rather than per page, keeps it coherent.

measure() is the part everyone underestimates. You cannot approximate text height with characters / chars_per_line. You need real font metrics and real line breaking, because one long ingredient name can wrap unexpectedly and change block height by a full line. We measure by running the actual layout engine and reading back the height — headless, no rendering, just the box model.

The Baseline Grid Is What Makes It Look Professional

Amateur book layout has text on facing pages that doesn't line up. Professional layout puts every line of body text on a shared invisible grid, so with the book open the lines on page 40 align with those on page 41 — and text doesn't show through from the reverse side.

That means every vertical measurement is a whole multiple of the baseline unit:

:root { --baseline: 12pt; }

body { font-size: 10pt; line-height: var(--baseline); }

h2 {
  font-size: 16pt;
  line-height: calc(var(--baseline) * 1.5);
  margin-top: calc(var(--baseline) * 2);
  margin-bottom: calc(var(--baseline) * 0.5);   /* total stays whole */
}

figure { margin-block: var(--baseline); }
Enter fullscreen mode Exit fullscreen mode

The trap is that images and boxes are arbitrary heights and knock everything off grid. Every non-text block needs its height snapped up to the next multiple:

const snap = (h, baseline) => Math.ceil(h / baseline) * baseline;
Enter fullscreen mode Exit fullscreen mode

Also: line-height positions the em box, not the baseline, so a font with unusual ascent/descent metrics sits off grid even at a correct line-height. Mixing a display face with a body face needs a per-font baseline offset derived from the font's ascender and unitsPerEm. Invisible when correct, ugly when wrong.

Break Control Is Where the Rules Get Domain-Specific

Generic widow and orphan control is table stakes:

p  { orphans: 3; widows: 3; }
h2, h3 { break-after: avoid; }
Enter fullscreen mode Exit fullscreen mode

orphans: 3 means three lines must remain at the bottom of a page before a break; widows: 3 means three must carry over. Two is the common default and too permissive for a cookbook, where one stranded line next to a photo reads as a mistake.

But recipes have rules generic typography doesn't know about:

  • The ingredient list must never split. A cook needs all of it at once. break-inside: avoid on the whole list — and if it doesn't fit, escalate to two pages rather than break it.
  • A numbered step must not split mid-step. After step 4 is fine; inside step 4 is not. So li { break-inside: avoid } but explicitly not on the <ol>.
  • Yield, time and temperature are atomic. Splitting "Bake at 425°F" from "for 20 minutes" across a page turn is a usability failure, not an aesthetic one.
  • A step referencing a photo belongs on that photo's spread. Not expressible in CSS at all — it's a hard filter on candidate configurations.

One more engine behavior: break-inside: avoid on a block taller than the page silently does nothing, and you get an arbitrary break. Guard it with a measurement check. Wiring these constraints into the fitting search and running it across a full book, so page parity holds end to end, is the layout core of CookPress — though the constraint that took longest was the boring one below.

The Incremental Layout Problem

Once a book is composed, editing recipe 12 must not reflow recipes 13 through 60. Otherwise every edit invalidates every proof the author already approved, and review never converges.

The fix is to anchor. Each recipe gets a fixed page allocation once accepted, and re-fitting happens within that allocation using the same search, just a tighter frame. If an edit genuinely cannot fit, surface it as a conflict for the author rather than silently repaginating.

Treating page allocation as immutable state rather than derived output is what made the system usable. It feels wrong — you're giving up global optimality — but a book you can review incrementally beats a tighter book you must re-proof from page one after every typo fix.

Top comments (0)