Paragon

Excel reports

A workbook is the one output that is not a printed page, and the template is read differently to produce it. What reaches a sheet, how it is named, and how a column says what it holds are all here; the rest of writing the template is Authoring a report.


Excel is not the other three in a different wrapper. PDF, Word and PowerPoint all hold the same printed pages — Word and PowerPoint literally carry a picture of each one — and that is what makes them exact. A workbook of pictures would be worthless: the reason anyone asks for Excel is to sort, filter and total the thing.

So an Excel report is read differently. Its tables become sheets, and everything else is left behind. Write the report as you would any other and it will still preview as a page — but only what is inside a <table> reaches the workbook. A heading, a logo and a chart have no cell to be in.

Sheets, and what they are called

One sheet per table, in page order, taking its name from the first of these that says anything:

<table data-sheet="Payroll">          <!-- what you named it -->
<table><caption>Payroll</caption>     <!-- the caption -->
<table>                               <!-- the page it is on -->

Excel will not take two sheets with the same name, will not take : \ / ? * [ ], and truncates at 31 characters. All three are handled for you — a colliding name gets a (2).

Header rows

Rows in <thead> are headers, and so is any row made entirely of <th>. They come out bold on a grey band, the panes freeze under them, and a single unmerged header row also gets a filter. Header rows further down the table are styled as headers but freeze nothing.

Merged cells

colspan and rowspan become merged cells, and cells below a rowspan shift right exactly as they do on the page. A span that reaches past the end of the table is clamped to where the table actually ends, which is what a browser does with it too.

What a cell holds

A cell is text unless the markup says otherwise. The engine will not guess: 00713 may be a number or a part code that has to keep its leading zero, and 3.10 may be a version rather than a decimal. A wrong guess silently rewrites data nobody asked it to touch, so the template declares what a column holds and everything undeclared stays exactly as it printed.

data-type is what declares it:

data-type Stored as Number format, taken from how the page printed it
(absent) the text, exactly —
number a number #,##0.00 — grouping and decimals as shown
currency a number "$"#,##0.00, #,##0.00" USD" — the symbol kept on the side it was on
percent the fraction — 15.2% is stored as 0.152 0.0%
date the day count Excel keeps dates in dd mmm yyyy, matching how it was written
time the fraction of a day that has passed hh:mm
duration a span, not a time of day [h]:mm — 30:00 stays thirty hours
boolean a real boolean TRUE/FALSE, read from Yes/No/true/1/on
<td data-type="currency">$1,234.50</td>
<td data-type="date">04 Mar 2026</td>
<tr data-type="number">…</tr>          <!-- a whole row -->
<table data-type="number">…</table>    <!-- or a whole table -->

The attribute is read from the cell, then the row, then the table, so a column is usually declared once. data-format="0.000" states an Excel format outright and overrides the derived one.

A cell that does not parse stays text. n/a in a money column is a word, not a zero — which is the reason typing is declared rather than sniffed.

Inside a cell, markup becomes text: <br> and a closing </p>, </div> or </li> become line breaks, entities are decoded, and HTML indentation is collapsed away. A table nested inside a cell stays in that cell as its rows on separate lines — it does not become a sheet of its own.

Styling

The styling is the report's own. Alignment, background colour, text colour, bold and italic come from the stylesheet, because the page is loaded and laid out exactly as it would be printed and the cells are read afterwards. text-align: end becomes right in a left-to-right report and left in an Arabic one; a class, a descendant selector and a :nth-child rule all work, because nothing here is matching selectors — the browser already did.

A cell that was never given a colour comes back without one rather than white, and a cell that was never aligned is left on Excel's own default rather than pinned to a side the author did not ask for. What does not come across is anything a cell cannot be: borders are the engine's own thin grid, and a background image, a gradient or a border radius has nowhere to go.

Generating one

Excel still goes through the browser, then — it just is not printed at the end of it. So a report that needs awaitsReadySignal needs it here too, and a font that is missing is missing in a workbook exactly as it is in a document. What it does not pay for is the print itself.

It is the one format an author cannot check by looking at the preview, because the preview is a page and the output is a grid. Use Document to get the real workbook.

A report set to Excel whose pages hold no <table> raises EmptyWorkbookException at generation, rather than handing back a file that opens on an empty grid and looks like the data went missing.

Before you generate one

  • Every table meant to be a sheet is a <table>, and named
  • Every column that is not text carries a data-type
  • Charts signal readiness — see Waiting for several asynchronous things
  • Checked as a real workbook rather than in the preview