<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Philipp Staudt</title>
    <description>The latest articles on DEV Community by Philipp Staudt (@staudtit).</description>
    <link>https://dev.to/staudtit</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4151750%2F45962f8b-b2d8-499d-ba65-ca2fa51297d4.png</url>
      <title>DEV Community: Philipp Staudt</title>
      <link>https://dev.to/staudtit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/staudtit"/>
    <language>en</language>
    <item>
      <title>The preview that lies, and the one that doesn't</title>
      <dc:creator>Philipp Staudt</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:37:36 +0000</pubDate>
      <link>https://dev.to/staudtit/the-preview-that-lies-and-the-one-that-doesnt-1je7</link>
      <guid>https://dev.to/staudtit/the-preview-that-lies-and-the-one-that-doesnt-1je7</guid>
      <description>&lt;p&gt;Every document editor has a preview, and the PDF is always made somewhere else. The question worth asking of any preview is not whether it looks right, but whether page 1 ends where the PDF's page 1 ends. A browser window has no pages at all. Switching it to print styles (&lt;code&gt;@media print&lt;/code&gt;) changes the styles and still has no pages. The browser's print dialog does paginate, but with its own margins and, unless you untick them, its own header and footer — not the ones your API renders.&lt;/p&gt;

&lt;p&gt;Our editor has a flow preview for writing and a paged preview for layout. The paged one uses &lt;a href="https://pagedjs.org" rel="noopener noreferrer"&gt;Paged.js&lt;/a&gt;, which paginates HTML in the browser following the CSS paged-media rules; the PDF comes from headless Chromium on our render servers. So we measured whether the two agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;One short report: a heading, a banner, and 24 cards. Each card is &lt;code&gt;height: 22mm&lt;/code&gt; with &lt;code&gt;padding: 5mm&lt;/code&gt; and a border, the way a template sets a box to a height — and each is kept whole with &lt;code&gt;break-inside: avoid&lt;/code&gt;. A4 with margins of 20 mm at the top and bottom and 18 mm at the sides, and a page-number footer. We printed it with our render worker, laid it out in Chromium with Paged.js as it ships and with our paged preview, and counted the cards on every page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnrf9tc180waohpoxikl3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnrf9tc180waohpoxikl3.webp" alt="Page 1 three times: Paged.js as shipped with nine short cards, the paged preview with six, the PDF with six" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Cards per page&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Paged.js as it ships (left)&lt;/td&gt;
&lt;td&gt;9 · 10 · 5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Our paged preview (middle)&lt;/td&gt;
&lt;td&gt;6 · 7 · 7 · 4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The &lt;a href="https://formfeed.dev/blog/preview-that-matches-the-pdf/cards.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt; (right)&lt;/td&gt;
&lt;td&gt;6 · 7 · 7 · 4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The preview and the PDF agree card for card. Paged.js as it ships gets every page wrong and the page count too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that made the difference
&lt;/h2&gt;

&lt;p&gt;The two previews differ in one thing. Paged.js's own stylesheet contains &lt;code&gt;.pagedjs_pagebox * { box-sizing: border-box }&lt;/code&gt;. It is meant for the boxes Paged.js builds around each page, but the selector reaches everything inside a page, your template included. Under &lt;code&gt;border-box&lt;/code&gt;, a height includes the padding and the border; under the browser's default &lt;code&gt;content-box&lt;/code&gt;, which Chromium uses when it prints, they come on top. So each card is 22 mm tall in Paged.js as it ships and a little over 32 mm in the PDF, and Paged.js fits nine where the PDF fits six.&lt;/p&gt;

&lt;p&gt;Our preview rewrites that one rule as the stylesheet arrives, before anything is laid out, so that it applies only to Paged.js's own boxes (&lt;code&gt;[class*="pagedjs_"]&lt;/code&gt;). Your elements keep the box model they will have in the PDF. The same thinking applies to our print reset: the preview's header and footer boxes are left out of it, because Chromium's header and footer templates get none of it either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case no preview can show
&lt;/h2&gt;

&lt;p&gt;Now make the banner "full width" the usual way: &lt;code&gt;width: 100%&lt;/code&gt; plus its &lt;code&gt;padding: 5mm 10mm&lt;/code&gt;. Under &lt;code&gt;content-box&lt;/code&gt; that is 20 mm wider than the space between the margins.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7and1xndcp6dlno0q4je.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7and1xndcp6dlno0q4je.webp" alt="Page 1 from three sources: Paged.js as shipped fits the banner, the Formfeed preview lets it run past the right margin, and in the PDF everything is smaller and six cards became seven" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each picture in this post is page 1 of the same document, from three sources — not three pages. Paged.js as it ships does not even show the problem: under its &lt;code&gt;border-box&lt;/code&gt; rule the padding counts inside the 100 %, and the banner fits. Our preview shows what the document asks for: the banner runs past the right margin. The &lt;a href="https://formfeed.dev/blog/preview-that-matches-the-pdf/wide-banner.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt; does something else. Chromium scales the whole document down until its widest element fits the page — here to 89.7 %, so the 10 pt card titles come out at 8.97 pt — and does it on every page, not only the one with the banner. Smaller pages hold more: 7 · 8 · 8 · 1 cards instead of 6 · 7 · 7 · 4.&lt;/p&gt;

&lt;p&gt;No preview that lays out at 100 % can reproduce that, ours included. What it can do is find the cause, and ours now says it in words: the preview measures the widest element after every layout, and when one reaches past the margin, the editor lists it under Problems — how many millimetres too wide, and to what size the PDF will shrink (here 20 mm and 89.7 %) — with a link to the tag in the template. Give such an element &lt;code&gt;box-sizing: border-box&lt;/code&gt;, or leave its width alone — a block is as wide as its container without being told.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we take from it
&lt;/h2&gt;

&lt;p&gt;A preview earns trust by breaking where the PDF breaks, and that is a property you can measure: same document, same page size, count what lands on each page. Ours does, until a template asks Chromium to shrink. For the last word, the editor's &lt;strong&gt;True render&lt;/strong&gt; sends the draft through the same worker that printed the PDFs above, and costs nothing.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.formfeed.dev/getting-started/editor-tour" rel="noopener noreferrer"&gt;editor tour&lt;/a&gt; shows the preview modes and True render, and the &lt;a href="https://formfeed.dev/examples/invoice?utm_source=devto&amp;amp;utm_content=preview-that-matches-the-pdf" rel="noopener noreferrer"&gt;invoice example&lt;/a&gt; is a document to try them on.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Measured on 21 September 2026 with Chromium 153.0.8010.12 on the render worker and Paged.js 0.4.3.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>css</category>
      <category>html</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Let the agent write the data, not the HTML</title>
      <dc:creator>Philipp Staudt</dc:creator>
      <pubDate>Mon, 05 Oct 2026 10:53:23 +0000</pubDate>
      <link>https://dev.to/staudtit/let-the-agent-write-the-data-not-the-html-24pe</link>
      <guid>https://dev.to/staudtit/let-the-agent-write-the-data-not-the-html-24pe</guid>
      <description>&lt;p&gt;There are two ways to let an AI agent produce an invoice. You can ask it for the invoice: it writes HTML, something prints it, and a PDF comes out. Or you can hand it an invoice template someone has already reviewed and ask it only for the facts — who, what, how many, at what rate — as JSON.&lt;/p&gt;

&lt;p&gt;The first way works in a demo. The second one is the one that survives a second run.&lt;/p&gt;

&lt;h2&gt;
  
  
  A document is a promise to look the same
&lt;/h2&gt;

&lt;p&gt;A model writes by sampling. Ask it twice for "an invoice for these two line items" and nothing ties the second answer to the first: the column order, the wording of the VAT line, whether the payment terms mention a due date, whether the legal footer is there at all. Each run is a new draft that nobody reviewed. For a birthday card that is charming. For an invoice, which in Germany has to carry a list of mandatory details, it means reviewing every single one the agent sends — at which point the agent saved nobody any work.&lt;/p&gt;

&lt;p&gt;A template turns that around. The layout, the legal lines, the arithmetic and the payment code are written once, looked at by a person, versioned and published. What varies between runs is the data, and the data is the part a model is actually good at: pulling a customer name out of an email, the quantities out of a spreadsheet, the rate out of a contract.&lt;/p&gt;

&lt;p&gt;The shape that works has four parts, and our &lt;a href="https://formfeed.dev/integrations/mcp?utm_source=devto&amp;amp;utm_content=let-the-agent-write-the-data" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; has a tool for each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find the template&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list_templates&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learn what it expects&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_template_schema&lt;/code&gt; — the JSON Schema of the data, plus the sample data it was designed with&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check the data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;validate_template&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing: the API checks a stored template without rendering it; ad-hoc HTML is checked inside the MCP server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Render&lt;/td&gt;
&lt;td&gt;&lt;code&gt;render&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing with a test key; with a live key one unit per started five PDF pages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test keys (&lt;code&gt;ff_test_…&lt;/code&gt;) render the same templates through the same pipeline and are never metered; their outputs are kept for 24 hours, and on the Free plan they carry a watermark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the data still goes wrong
&lt;/h2&gt;

&lt;p&gt;Giving the model the data to write does not make the data right. So we took a short invoice template and fed &lt;code&gt;validate_template&lt;/code&gt; the data an agent tends to produce, then rendered each version. We passed the template itself as HTML, which the tool checks offline, inside the MCP server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;table&amp;gt;
  {% for line in invoice.lines %}
  &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;{{ line.description }}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;{{ line.qty }}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;{{ line.price | money }}&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;{{ line.total | money }}&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
  {% endfor %}
&amp;lt;/table&amp;gt;
{% set net = invoice.lines | sum('total') %}
{% set vat = net * invoice.vat_rate / 100 %}
&amp;lt;p&amp;gt;Gesamt {{ (net + vat) | money }}&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the data it was written for, validation reports nothing and the invoice ends in &lt;strong&gt;Gesamt 3.760,40 €&lt;/strong&gt;. Then the agent's versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent leaves out the line totals&lt;/strong&gt;, expecting the template to multiply quantity by price. This one does not. &lt;code&gt;validate_template&lt;/code&gt; answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"diagnostics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unknown-variable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;line.total&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; is not present in the sample data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the rendered invoice ends in &lt;strong&gt;Gesamt 0,00 €&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent calls the list &lt;code&gt;items&lt;/code&gt;&lt;/strong&gt;, because that reads as well as &lt;code&gt;lines&lt;/code&gt; to a model that did not look at the schema. Validation warns five times, once for &lt;code&gt;invoice.lines&lt;/code&gt; and once for each field under it, again with &lt;code&gt;"ok": true&lt;/code&gt;. The rendered invoice has no lines and totals &lt;strong&gt;0,00 €&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent writes the rate as &lt;code&gt;"19 %"&lt;/code&gt;&lt;/strong&gt;, the way it appears on the contract it read. The offline check reports nothing: it knows which names the template reads, not what kind of value belongs in them. When we first ran this, the rendered invoice ended in &lt;strong&gt;Gesamt NaN&lt;/strong&gt;. That was a bug in our &lt;code&gt;money&lt;/code&gt; helper, and it is fixed: &lt;code&gt;money&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt; and &lt;code&gt;numToWords&lt;/code&gt; now stop the render with &lt;code&gt;money: got NaN, the result of a calculation with a value that is missing or not a number&lt;/code&gt; rather than print &lt;code&gt;NaN&lt;/code&gt; on a document. Text that is not a number, such as &lt;code&gt;"on request"&lt;/code&gt;, still prints as it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that knows the values
&lt;/h2&gt;

&lt;p&gt;An agent rarely brings its own HTML. It fills a template stored in Formfeed, and for a stored template &lt;code&gt;validate_template&lt;/code&gt; does not check offline: it asks the API, free and without rendering, to check the version a render would use. That check runs the template once with the data, so the &lt;code&gt;"19 %"&lt;/code&gt; above comes back as an error before anything is printed, and it compares the data with the template's stored JSON Schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"data-validation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"data.invoice.vat_rate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"data.invoice.vat_rate: must be number"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema check needs a stored schema. Every template has one inferred from its sample data, which is what &lt;code&gt;get_template_schema&lt;/code&gt; hands the agent, but only a schema you stored decides what counts as wrong — an inferred one would reject data for leaving out a field the sample happened to have. In the editor, &lt;strong&gt;Store as contract&lt;/strong&gt; in the data and schema panel stores one; with the CLI, a &lt;code&gt;schema.json&lt;/code&gt; next to the template does. Store one for every template an agent fills.&lt;/p&gt;

&lt;p&gt;Two things follow either way. First, &lt;code&gt;ok: true&lt;/code&gt; means &lt;em&gt;nothing will fail&lt;/em&gt;, not &lt;em&gt;the data is right&lt;/em&gt;: missing data is a warning, because a template may deliberately leave optional fields out. An agent should treat every warning as a question to answer before it renders. Second, a check only knows what it has been told. While you set an agent up, connect it with a test key: every render costs nothing, and reading a handful of results checks what no validation can, such as whether the right customer ended up on the invoice.&lt;/p&gt;

&lt;p&gt;The offline results above come from a test in the MCP server's own repository, which runs the real tools with a network stub that fails on any request, so it also shows that ad-hoc HTML and its data never leave the machine the server runs on while they are checked: &lt;a href="https://github.com/formfeed-dev/formfeed/blob/main/packages/mcp/src/lib/validate-agent-data.spec.ts" rel="noopener noreferrer"&gt;validate-agent-data.spec.ts&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to tell the agent
&lt;/h2&gt;

&lt;p&gt;The MCP server already tells a connected agent the order: list, read the schema, validate, render. What it cannot know is how strict to be with your documents. A few lines in the agent's instructions close that gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before rendering a document:
1. Read the template's schema and sample data with get_template_schema, and use the same field names.
2. Call validate_template with your data. Fix every diagnostic, warnings included.
3. Numbers are numbers: 19, not "19 %"; 1200.5, not "1.200,50".
4. Before handing a document on, compare its totals with the data you sent.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model still writes the part that changes. The part that must not change was written by a person, once, and the agent never gets to touch it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://formfeed.dev/agents?utm_source=devto&amp;amp;utm_content=let-the-agent-write-the-data" rel="noopener noreferrer"&gt;agents page&lt;/a&gt; has the setup for Claude Code, Cursor, VS Code, Codex and a dozen more clients and frameworks, and the &lt;a href="https://docs.formfeed.dev/integrations/mcp" rel="noopener noreferrer"&gt;MCP documentation&lt;/a&gt; lists every tool with its arguments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Checked on 5 October 2026. The schema check in &lt;code&gt;validate_template&lt;/code&gt; needs &lt;code&gt;@formfeed/mcp&lt;/code&gt; 0.3.6 or later.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>pdf</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Page breaks in tables: what Chromium honours in 2026</title>
      <dc:creator>Philipp Staudt</dc:creator>
      <pubDate>Wed, 30 Sep 2026 08:34:24 +0000</pubDate>
      <link>https://dev.to/staudtit/page-breaks-in-tables-what-chromium-honours-in-2026-258g</link>
      <guid>https://dev.to/staudtit/page-breaks-in-tables-what-chromium-honours-in-2026-258g</guid>
      <description>&lt;p&gt;A table that fits on one page is easy. The trouble starts at the page break: a header that does not come back, a row cut through the middle, a total printed under the first thirty rows, a footer lying on top of the last line. Most advice about this was written for browsers that paginated differently, and gets copied forward.&lt;/p&gt;

&lt;p&gt;So we measured. We rendered 21 small table documents through our render worker — the same headless Chromium, version 153.0.8010.12, that prints every Formfeed PDF — and read the finished PDFs back with pdf.js to see which page every row, header and total landed on. Every case below links to its PDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;Every Formfeed document starts with a short print reset. Four of its lines are about tables (in effect; the real rules sit in &lt;code&gt;:where()&lt;/code&gt; so that any rule of the template wins):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-collapse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;collapse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;tr&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;thead&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;table-header-group&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;tfoot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;table-footer-group&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;tr&lt;/code&gt; line is ours, not Chromium's: without it, Chromium splits a row wherever the page ends. The cases use A4 with 20 mm margins, ten-point text, and a page-number footer from Chromium's footer template unless they say otherwise. Rows are numbered, and a tall row's lines are numbered too (&lt;code&gt;08·5&lt;/code&gt; is the fifth line of row 8), so a split shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Chromium keeps
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;CSS&lt;/th&gt;
&lt;th&gt;What the PDF shows&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;60 rows&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;the header row repeats at the top of page 2&lt;/td&gt;
&lt;td&gt;&lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/long-table.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rows six lines tall&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tr { break-inside: avoid }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7 whole rows per page, none split&lt;/td&gt;
&lt;td&gt;&lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/tall-rows.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same rows&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tr { break-inside: auto }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rows 8, 16 and 24 split across pages&lt;/td&gt;
&lt;td&gt;&lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/tall-rows-auto.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one row of 90 lines&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tr { break-inside: avoid }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the row starts a new page and then splits anyway; all 90 lines are printed&lt;/td&gt;
&lt;td&gt;&lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/row-taller-than-page.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;groups of six rows, each its own &lt;code&gt;tbody&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tbody { break-inside: avoid }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;page 1 ends after row 30, a group boundary, instead of row 32&lt;/td&gt;
&lt;td&gt;&lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/tbody-groups.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a heading at the foot of a page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;h2 { break-after: avoid }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the heading moves to the next page with its table&lt;/td&gt;
&lt;td&gt;&lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/heading-kept.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the last row alone on a page&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tbody tr:last-child { break-before: avoid }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;row 8 goes along: 7 rows, then 2&lt;/td&gt;
&lt;td&gt;&lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/last-row-kept.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the last two rows as their own &lt;code&gt;tbody&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tbody.tail { break-inside: avoid }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same, 7 and 2&lt;/td&gt;
&lt;td&gt;&lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/last-rows-kept-tbody.pdf" rel="noopener noreferrer"&gt;PDF&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the table inside a flex, grid, &lt;code&gt;overflow: auto&lt;/code&gt;, &lt;code&gt;overflow: hidden&lt;/code&gt; or absolutely positioned wrapper&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;paginates like a bare table, header repeated&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/flex-wrapper.pdf" rel="noopener noreferrer"&gt;flex&lt;/a&gt; · &lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/grid-wrapper.pdf" rel="noopener noreferrer"&gt;grid&lt;/a&gt; · &lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/overflow-auto-wrapper.pdf" rel="noopener noreferrer"&gt;auto&lt;/a&gt; · &lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/overflow-hidden-wrapper.pdf" rel="noopener noreferrer"&gt;hidden&lt;/a&gt; · &lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/absolute-wrapper.pdf" rel="noopener noreferrer"&gt;absolute&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row surprised us most, because every one of those wrappers turns up in advice on why a table stopped paginating. In Chromium 153 none of them stops it.&lt;/p&gt;

&lt;p&gt;The two keep-together rules are the ones worth having in every template. Without them, the heading stays behind on its own:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fasfs3hq1mg8aketznjw5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fasfs3hq1mg8aketznjw5.webp" alt="A section heading alone at the foot of page 1, its table starting on page 2" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;and the last row of a table ends up alone on a page of its own:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1ekltiq0i6khvtysgun.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1ekltiq0i6khvtysgun.webp" alt="Rows 1 to 8 on page 1, row 9 alone under a repeated header on page 2" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;break-before: avoid&lt;/code&gt; on the last row, Chromium takes the row before it along, so no page holds a single row:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fld7nk6d5zgdfjxgt3kg5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fld7nk6d5zgdfjxgt3kg5.webp" alt="Rows 1 to 7 on page 1, rows 8 and 9 on page 2" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And this is what a tall row looks like when nothing asks Chromium to keep it together — the amount stays on page 1 and the last two lines of the description go to page 2:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhc3mmewhxlg9lwnsv8m.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhc3mmewhxlg9lwnsv8m.webp" alt="Row 8 split across two pages, its lines 5 and 6 under the repeated header on page 2" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to get it wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The total on every page
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;tfoot&lt;/code&gt; looks like the place for a total. But a &lt;code&gt;tfoot&lt;/code&gt; is a footer group, and Chromium repeats it at the foot of every page the table reaches, just as it repeats &lt;code&gt;thead&lt;/code&gt; at the top. The grand total turns up under row 31 on page 1:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5hl9efdjw12uufl6x39.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5hl9efdjw12uufl6x39.webp" alt="The total 7,200.00 printed at the foot of page 1, after row 31 of 60" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two fixes. Put the total in a last &lt;code&gt;tbody&lt;/code&gt; of its own. Or keep the &lt;code&gt;tfoot&lt;/code&gt; and turn it into an ordinary row group, &lt;code&gt;tfoot { display: table-row-group; }&lt;/code&gt; — then it prints once, on the last page, &lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/tfoot-once.pdf" rel="noopener noreferrer"&gt;as long as it comes after the &lt;code&gt;tbody&lt;/code&gt; in the markup&lt;/a&gt;. HTML also allows the &lt;code&gt;tfoot&lt;/code&gt; before the &lt;code&gt;tbody&lt;/code&gt;; with the override, such a total &lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/tfoot-first-once.pdf" rel="noopener noreferrer"&gt;prints above the first row&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The repeat cannot carry a running subtotal either. What Chromium repeats is a copy of the same content, so a "carried forward" line that sums the rows above it on each page is not something a &lt;code&gt;tfoot&lt;/code&gt; can do.&lt;/p&gt;

&lt;h3&gt;
  
  
  A row cut in half
&lt;/h3&gt;

&lt;p&gt;Wrap the table in &lt;code&gt;display: inline-block&lt;/code&gt; — a common way to centre it or shrink it to its content — and Chromium stops paginating the table inside. It prints the box across the page edge as it is: row 33 is cut through the middle, and page 2 has no header.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcbbqp6oqhumsi9nxjm2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcbbqp6oqhumsi9nxjm2.webp" alt="Row 33 sliced horizontally at the foot of page 1, its lower half at the top of page 2, no header on page 2" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A block wrapper does not do this. &lt;code&gt;thead { display: block; }&lt;/code&gt;, which some scrolling-table CSS sets, keeps the rows whole but &lt;a href="https://formfeed.dev/blog/page-breaks-in-tables/thead-display-block.pdf" rel="noopener noreferrer"&gt;loses the repeated header&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The row under the footer
&lt;/h3&gt;

&lt;p&gt;A footer made with &lt;code&gt;position: fixed; bottom: 0&lt;/code&gt; does repeat on every page. The table does not know it is there. Row 32 slides underneath, and since the table thinks row 32 fitted, page 2 carries on with row 33 — row 32 is never printed whole:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh61r87z8u87gt0q0raig.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh61r87z8u87gt0q0raig.webp" alt="A fixed footer covering the lower half of row 32; page 2 starts at row 33" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The clean fix is not to put the footer in the page at all. Chromium's own footer template is printed in the bottom margin, where content never reaches; every other case in this post has one, and none lost a row to it. If the footer has to live in the page, the repeat that caused the first problem solves this one: an empty &lt;code&gt;tfoot&lt;/code&gt; row as tall as the footer repeats on every page and keeps the rows clear of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;tfoot&amp;gt;&amp;lt;tr&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"reserve"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;td&lt;/span&gt; &lt;span class="na"&gt;colspan=&lt;/span&gt;&lt;span class="s"&gt;"3"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;&amp;lt;/tfoot&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;tr&lt;/span&gt;&lt;span class="nc"&gt;.reserve&lt;/span&gt; &lt;span class="nt"&gt;td&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdb82wp73sqab558tsiwc.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdb82wp73sqab558tsiwc.webp" alt="Row 31 is the last on page 1, clear of the fixed footer; page 2 starts at row 32" width="800" height="704"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep rows whole with &lt;code&gt;tr { break-inside: avoid }&lt;/code&gt;, and groups of rows with a &lt;code&gt;tbody&lt;/code&gt; each and &lt;code&gt;break-inside: avoid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep headings with their table: &lt;code&gt;h2 { break-after: avoid }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep the last row company: &lt;code&gt;tbody tr:last-child { break-before: avoid }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Put totals in a last &lt;code&gt;tbody&lt;/code&gt;, or make the &lt;code&gt;tfoot&lt;/code&gt; a &lt;code&gt;table-row-group&lt;/code&gt; after the &lt;code&gt;tbody&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;inline-block&lt;/code&gt; around a table, no &lt;code&gt;display: block&lt;/code&gt; on its &lt;code&gt;thead&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Footers go in the footer template, not in &lt;code&gt;position: fixed&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  In Formfeed
&lt;/h2&gt;

&lt;p&gt;The print reset above is on by default and every line of it can be overridden. The Footer tab of the editor is Chromium's footer template, so a footer made there sits in the margin. The editor's paged preview is built to lay out like the PDF, and &lt;strong&gt;True render&lt;/strong&gt; sends the template through the same worker these cases went through. The &lt;a href="https://docs.formfeed.dev/templates/writing-templates#page-breaks" rel="noopener noreferrer"&gt;page-break section of the docs&lt;/a&gt; has the reset, and the &lt;a href="https://formfeed.dev/examples/report?utm_source=devto&amp;amp;utm_content=page-breaks-in-tables" rel="noopener noreferrer"&gt;report example&lt;/a&gt; is a two-page document with a chart and a table of monthly figures.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Rendered and measured on 21 September 2026 with Chromium 153.0.8010.12. Chromium's layout changes between versions; we re-run the cases when the worker's Chromium does.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>css</category>
      <category>html</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
