Resumarc is a resume builder. Every template in it is a
single React component that draws three things: the card in the gallery, the
live preview in the editor, and the printed PDF. There is no separate print
stylesheet. What you arrange on screen is what comes out of the file, because it
is literally the same component rendered at a different size.
That constraint is the good part of the design and it is also how we found the
worst bug we have shipped.
The symptom
While testing exports before launch we downloaded a two-page resume. Page one had
the header and then an empty main column. Page two had all the content. Page
three was blank.
It would not reproduce on the next document. Then it did, with the original one,
in one template. Then five more templates did the same thing with that same
document, and a dozen did not. Changing the font size
fixed it. Changing it back broke it again, in a different place.
Anything that moves when you change a font size is a layout bug, and anything
that only happens on paper is a fragmentation bug. Fragmentation is the CSS term
for what happens when a box has to be split across pages, and it is a part of the
spec that browsers implement unevenly and quietly.
The cause
Chromium will not place a page break in the space between the items of a column
flexbox.
Not "prefers not to". It moves the entire flex container to the next page
instead. So a column laid out like this:
<div className="flex flex-col gap-4">
<Section name="summary" />
<Section name="workHistory" />
<Section name="education" />
</div>
is, as far as pagination is concerned, one indivisible block. If the page
boundary lands inside one of those gap-4s, or in the margin under a section,
Chromium gives up on splitting and pushes the whole column onto the next page.
The first page keeps the header, which is outside the column, and is otherwise
empty. The document then runs one page longer than it needs to, which is where
the trailing blank page came from.
That is why it looked random. Whether it happened depended on where the page
boundary fell, which depended on the length of the content, which depended on the
font size and the template. Six templates out of the set had geometry that put a
boundary in a gap for that particular document.
The fix
A grid fragments between its rows, and through its row gaps, the way normal block
flow does. So every box in every document renderer that stacks its children
vertically became a grid:
<div className="grid grid-cols-1 content-start gap-4">
That is the whole fix. It is one line per box, and there were a few hundred boxes
across 130+ resume layouts, 20+ cover-letter layouts and the shared components
they are built from. The mapping was mechanical:
| Column flexbox | Grid |
|---|---|
flex flex-col gap-N |
grid grid-cols-1 content-start gap-N |
items-center / items-start (cross axis) |
justify-items-center / -start
|
justify-center / justify-between (main axis) |
content-center / content-between
|
a flex-1 child filling the rest of the column |
grid-rows-[auto_1fr] on the container |
The two grid-specific classes are doing real work, and neither is decoration.
content-start stops the rows stretching to fill a container taller than its
content. A sidebar next to a longer main column is exactly that container, so
without it every section in the sidebar grows a little and the spacing drifts.
grid-cols-1 is more interesting than it looks. Tailwind compiles it to
minmax(0, 1fr), and that 0 minimum is the min-width: 0 that a stretched flex
item used to get for free. Without it, an unbreakable string β and a resume is
full of them, because people put profile URLs in their contact block β sets the
column's minimum width and pushes the whole layout wider. So the migration fixed a
second bug that had been papered over with truncate in a few places.
Two things stayed flex on purpose. flex-col-reverse has no grid equivalent,
because grid has no reversed flow, and a couple of entry variants use it to put
dates above a job title while keeping the source order for screen readers. And any
row that wraps stays flex flex-wrap. Neither of those holds sections, so neither
is on the pagination path.
Making it stay fixed
A migration you can undo by typing three characters is not finished. The rule is
enforced now:
// eslint.config.mjs
{
selector: "Literal[value=/(?:^|\\s)flex-col(?:\\s|$)/]",
message:
"A document renderer stacks with `grid grid-cols-1 content-start`, not " +
"`flex flex-col` β Chromium cannot page-break between column-flex items.",
}
scoped to the three directories that hold document renderers, with
flex-col-reverse deliberately still allowed. There is one occurrence of the
string flex-col left in those directories today and it is inside a comment.
Alongside it, a test renders every layout with a fully populated document and
asserts the structural invariants the PDF depends on. The valuable thing that
test taught us is that a sweep which passes on the first run has not
been verified. Ours went green across every layout immediately, which felt like
success and was actually a bug in the assertion. Injecting a deliberate violation
into one template failed exactly one layout and named the offending element, and
the template was then restored. Now the green means something.
The other half: margins that repeat per page
Once the columns fragmented correctly, the next problem was the page margin.
A resume template often paints its sidebar. A painted column has to run to the
edge of the paper on every page, while its text stops short of the edge on every
page. A @page { margin } cannot do that, because it reserves space outside the
box, so the paint stops short too and you get a white band across the top of every
page after the first. Padding on the document's own box does not work either:
Chromium re-applies the box's top padding at each break, but the background still
starts where the box starts.
What does work is putting the margin inside the column as a transparent border,
and telling Chromium to repeat it on every fragment:
[data-page-column] {
border-top: 12mm solid transparent;
border-bottom: 12mm solid transparent;
background-clip: border-box;
box-decoration-break: clone;
margin-top: -12mm;
}
box-decoration-break: clone is the load-bearing line. By default a fragmented
box's borders are drawn only at its true start and end; clone draws them on
every fragment, so each page gets 12mm of transparent border at the top and
bottom. background-clip: border-box keeps the paint running underneath that
border, so the sidebar reaches the paper's edge while only its content stops
short.
The negative margin-top is the trick that makes the first page right. A header
band or a photo is supposed to sit hard against the top of page one, with no gap.
Margins are not cloned onto fragments, so a single negative top margin cancels
the border on the first fragment only and leaves it in place on every page after.
One declaration, two different behaviours, which is exactly the kind of thing you
find by reading the fragmentation spec rather than by guessing.
One warning that cost us an afternoon: pull up the box, not its first child. For
a single column they are the same thing. For a row of cells they are not, and only
the first cell rises while the ones beside it sit a gap lower.
Was the shared-renderer constraint worth it?
Yes, and not for the expected reason.
The assumption was that the win would be effort, one component instead of two. The real win is
that it makes a whole category of bug impossible to ship quietly. If the preview
and the PDF were separate code paths, this bug would have been a PDF-only defect
that nothing on screen could reveal, and the fix would have been a print-only
override that drifts from the screen version over the following year. Because
there is one box model, the fix had to be correct on screen too, and could be
verified by looking at a page instead of by exporting a file.
It also means the constraint is load-bearing rather than aspirational. The ESLint
rule and the layout sweep are not hygiene. They are the things that keep 150+
templates from silently diverging into two rendering systems.
The PDFs themselves are produced by handing the same page to Chromium through
Gotenberg with emulatedMediaType=print and printBackground=true, which is the
last piece of why this works: it is the same engine that laid the page out on
screen, so there is nothing to reconcile.
If you want to see what the layouts actually look like, they are at
resumarc.com/resume-templates. Happy to
answer anything about the rendering in the comments.
Top comments (0)