Paragon
Examples Excel LTR

Regional Sales

Those are this report's own pages, printed by the engine from the template and the payload below. The workbook is not a picture of them: the tables on those pages arrive as cells you can sort, filter and total, and nothing else comes across.

The first sheet of Regional Sales, as the engine prints it.

The source

This is the whole thing — the template, the pages that print from it, and the stylesheet they are dressed in, as HTML with {{ }} bindings. Nothing is generated from a designer format; what you see here is what the engine renders.

template.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">

    <link rel="stylesheet" href="assets/css/report.css">
  </head>
  <body>

<script id="report-data" type="application/json">{{json}}</script>
  </body>
</html>
page1.html
 <!--
    Sheet one. Two header rows: the year spans two columns across, the region label spans two rows
    down, and both become merged cells. The cells under a rowspan shift right on their own - "First
    half" lands in column C, not column A.

    The header is merged, so this sheet gets frozen panes but no filter: Excel takes a filter over
    merged cells and then behaves oddly, which is worse than not offering one.

    The figures carry data-type, so they arrive as numbers rather than as text that looks like
    numbers - they add up, sort and chart. The header cells above them carry nothing and stay text,
    because "First half" is not a number and declaring a column does not make its label one.
-->
<table data-sheet="Sales by region">
    <thead>
        <tr>
            <th rowspan="2">Region</th>
            <th rowspan="2">Manager</th>
            <th colspan="2">2026 ({{report.currency}})</th>
            <th colspan="2">2025 ({{report.currency}})</th>
            <th rowspan="2">Growth</th>
        </tr>
        <tr>
            <th>First half</th>
            <th>Second half</th>
            <th>First half</th>
            <th>Second half</th>
        </tr>
    </thead>
    <tbody>
        {{#each regions}}
        <tr>
            <td>{{name}}</td>
            <td>{{manager}}</td>
            <td class="num" data-type="number">{{number firstHalf "N0" "en-US"}}</td>
            <td class="num" data-type="number">{{number secondHalf "N0" "en-US"}}</td>
            <td class="num muted" data-type="number">{{number priorFirstHalf "N0" "en-US"}}</td>
            <td class="num muted" data-type="number">{{number priorSecondHalf "N0" "en-US"}}</td>
            <td class="num {{#if (gt growth 10)}}up{{/if}}" data-type="percent">{{number growth "N1" "en-US"}}%</td>
        </tr>
        {{/each}}
    </tbody>
    <!--
        A row of nothing but <th> is a header row wherever it sits, so the total comes out on the
        same band as the header above. Being at the bottom it freezes nothing: only header rows at
        the top of a table do that.
    -->
    <tfoot>
        <tr>
            <th colspan="2">Total</th>
            <th class="num" data-type="number">{{number totals.firstHalf "N0" "en-US"}}</th>
            <th class="num" data-type="number">{{number totals.secondHalf "N0" "en-US"}}</th>
            <th class="num" data-type="number">{{number totals.priorFirstHalf "N0" "en-US"}}</th>
            <th class="num" data-type="number">{{number totals.priorSecondHalf "N0" "en-US"}}</th>
            <th class="num" data-type="percent">{{number totals.growth "N1" "en-US"}}%</th>
        </tr>
    </tfoot>
</table>

<!--
    Sheet two. One header row, nothing merged in it - so this one gets the filter as well as the
    frozen pane, and opens ready to sort and narrow.

    Note which columns say what they are and which deliberately do not. Issued, Qty, Unit price
    and Amount carry data-type and arrive as real dates and numbers. Code and Revision carry
    nothing: "00713" keeps its leading zero and "3.10" stays a revision instead of turning into
    the tenth of March.

    That is the whole reason typing is declared rather than sniffed. An engine guessing at these
    columns would be right about four of them and quietly wrong about two, and a wrong guess
    rewrites data nobody asked it to touch.
-->
<table data-sheet="Invoice lines">
    <thead>
        <tr>
            <th>Code</th>
            <th>Revision</th>
            <th>Client</th>
            <th>Region</th>
            <th>Issued</th>
            <th class="num">Qty</th>
            <th class="num">Unit price</th>
            <th class="num">Amount</th>
            <th>Status</th>
        </tr>
    </thead>
    <tbody>
        {{#each lines}}
        <tr class="{{#if (eq status "Overdue")}}overdue{{/if}}">
            <td>{{code}}</td>
            <td>{{revision}}</td>
            <!-- A <br> inside a cell becomes a line break inside the cell, not a second row. -->
            <td>{{client}}<br>{{city}}</td>
            <td>{{region}}</td>
            <td data-type="date">{{date issuedOn "dd MMM yyyy" "en-GB"}}</td>
            <td class="num" data-type="number">{{quantity}}</td>
            <td class="num" data-type="currency">{{report.currency}} {{number unitPrice "N2" "en-US"}}</td>
            <td class="num" data-type="currency">{{report.currency}} {{number amount "N2" "en-US"}}</td>
            <td>{{status}}</td>
        </tr>
        {{/each}}
    </tbody>
</table>

<!--
    Sheet three. Every kind a cell can be declared, what the page shows, and what the workbook
    actually stores - which is the thing worth checking, because the two are not the same and the
    difference is the entire point.

    Read this one in Excel with the formula bar open: the middle column is what you see and the
    right-hand column says what is underneath it. Click a cell in the middle column and the formula
    bar shows the stored value, not the text.
-->
<table data-sheet="Cell types">
    <thead>
        <tr>
            <th>data-type</th>
            <th>Written in the page as</th>
            <th>Shown in Excel as</th>
            <th>Stored as</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>none - the default</td>
            <td>1,234.50</td>
            <td>1,234.50</td>
            <td>the text, exactly</td>
        </tr>
        <tr>
            <td>number</td>
            <td>1,234.50</td>
            <td class="num" data-type="number">1,234.50</td>
            <td>1234.5, shown as #,##0.00</td>
        </tr>
        <tr>
            <td>number</td>
            <td>(1,234.50) - an accountant's negative</td>
            <td class="num" data-type="number">(1,234.50)</td>
            <td>-1234.5</td>
        </tr>
        <tr>
            <td>currency</td>
            <td>$1,234.50</td>
            <td class="num" data-type="currency">$1,234.50</td>
            <td>1234.5, shown with the symbol in front</td>
        </tr>
        <tr>
            <td>currency</td>
            <td>1,234.50 USD - the code behind it</td>
            <td class="num" data-type="currency">1,234.50 USD</td>
            <td>1234.5, shown with the code behind</td>
        </tr>
        <tr>
            <td>percent</td>
            <td>15.2%</td>
            <td class="num" data-type="percent">15.2%</td>
            <td>0.152 - Excel means a fraction by a percentage</td>
        </tr>
        <tr>
            <td>date</td>
            <td>04 Mar 2026</td>
            <td data-type="date">04 Mar 2026</td>
            <td>46085, the day count Excel keeps dates in</td>
        </tr>
        <tr>
            <td>date</td>
            <td>2026-03-04</td>
            <td data-type="date">2026-03-04</td>
            <td>46085 - the same day, shown as it was written</td>
        </tr>
        <tr>
            <td>time</td>
            <td>09:30</td>
            <td data-type="time">09:30</td>
            <td>0.395833 - the part of the day that has gone</td>
        </tr>
        <tr>
            <td>duration</td>
            <td>30:00 - elapsed, not a time of day</td>
            <td data-type="duration">30:00</td>
            <td>1.25 days, shown as [h]:mm so it stays 30 hours</td>
        </tr>
        <tr>
            <td>boolean</td>
            <td>Yes</td>
            <td data-type="boolean">Yes</td>
            <td>TRUE - the type a filter offers two choices for</td>
        </tr>
        <tr>
            <td>number + data-format</td>
            <td>1,234.50 with data-format="0.000"</td>
            <td class="num" data-type="number" data-format="0.000">1,234.50</td>
            <td>1234.5, shown exactly as the author asked</td>
        </tr>
        <tr>
            <td>number, and not one</td>
            <td>n/a</td>
            <td class="num" data-type="number">n/a</td>
            <td>the text - a word in a money column is a word, not a zero</td>
        </tr>
    </tbody>
</table>
assets/css/report.css
/*
    None of this reaches the workbook. Excel takes the tables and writes the cells; the styling of
    a sheet is the engine's own - bold headers on a band, thin borders, frozen panes, columns
    widened to their text. What this file is for is the preview, so the report can be read and
    checked on the page before anyone opens the file it produces.
*/

body {
    margin: 0;
    padding: 14mm 12mm;
    font-family: "Segoe UI", system-ui, sans-serif;
    font-size: 10pt;
    color: #1f2430;
}

table {
    width: 100%;
    margin-bottom: 10mm;
    border-collapse: collapse;
}

/* Repeated on every printed page, and the reason a long table stays readable when it breaks. */
thead {
    display: table-header-group;
}

tr {
    break-inside: avoid;
}

th,
td {
    padding: 1.8mm 2.4mm;
    border: 1px solid #d9d9d9;
    text-align: start;
    vertical-align: top;
}

/* The same grey band the sheet uses, so the preview and the workbook agree about what a header is. */
th {
    background: #efefef;
    font-weight: 600;
}

thead th {
    text-align: center;
}

/*
    A header that spans both rows of the header sits in the middle of them rather than pinned to
    the top of the first - Region, Manager and Growth, which label the whole two-row band. Excel
    reads this the same way the page does, so the merged cell is centred there too.
*/
thead th[rowspan] {
    vertical-align: middle;
}

thead tr:last-child th,
tbody td {
    text-align: start;
}

.num {
    text-align: end;
    font-variant-numeric: tabular-nums;
}

/* Last year, kept for comparison rather than for reading. */
.muted {
    color: #6b7280;
}

.up {
    color: #217346;
    font-weight: 600;
}

.overdue td {
    background: #fdf2f2;
}

.overdue td:last-child {
    color: #b42318;
    font-weight: 600;
}
shared-assets/css/house.css
/*
  The project's house style: what every report in it looks like before it looks like itself.

  It is in shared-assets rather than in a report because it is the project's, not one report's —
  the palette, the type, the sheet, the running header, and the handful of parts (card, chip, pill,
  meter, table) that every report here is assembled from. A report's own stylesheet composes these
  into its layout; it does not redecide what a card is.

  A template references it as assets/css/house.css, exactly as it references its own files. The
  shared folder is searched behind the report's own, so the path means the same thing in every
  report — and a report that needs its own version keeps a file at that path and wins.
*/

:root
{
  /* Named for the job, not the hue, so a rebrand is this block and nothing else. */
  --ink: #0f172a;
  --ink-soft: #475569;
  --ink-faint: #6b7a90;
  --ink-invert: #ffffff;

  --page: #ffffff;
  --panel: #ffffff;
  --panel-2: #f8fafc;
  --rule: #e2e8f0;
  --rule-soft: #eef2f7;

  --brand: #0b1f3a;
  --brand-2: #12325c;
  --accent: #1d4ed8;
  --accent-soft: #eef3ff;
  --cyan: #0ea5e9;

  --good: #047857;
  --good-soft: #e7f6f0;
  --warn: #b45309;
  --warn-soft: #fdf3e3;
  --bad: #b91c1c;
  --bad-soft: #fdeceb;
  --idle: #64748b;
  --idle-soft: #f1f5f9;

  --radius: 7px;
  --radius-sm: 4px;
}

body
{
  margin: 0;
  color: var(--ink);
  background: var(--page);

  /*
    No Arabic face here, and no Arabic font in shared-assets at all. Naming one for every report in
    the project meant seven reports asked for a typeface none of them loads, so what they actually
    printed in was the second name in the list. The one Arabic report carries the family and the
    files itself.
  */
  font-family: "Segoe UI", Tahoma, sans-serif;
  font-size: 11px;
  line-height: 1.55;

  /* Every figure in this project is read down a column, so digits are the same width. */
  font-variant-numeric: tabular-nums;
  -webkit-font-smoothing: antialiased;
}

/*
  The sheet.

  The engine prints with no page margin, so the edges are the report's own to leave. Cloned decoration
  keeps the padding on every sheet a long page runs onto rather than only the first, and the top
  padding is what the fixed running header sits in — it reserves no room of its own.
*/
.sheet
{
  box-sizing: border-box;
  width: 794px;
  min-height: 1123px;
  padding: 96px 40px 44px;

  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}

/* ---------- the running header, on every sheet ---------- */

/*
  The engine fixes #report-page-header to the top of the sheet; everything below is this project's
  band inside it. A band rather than a line of text: it is the first thing on every page and the
  only thing carrying the mark, and at 76px it is still under a fifteenth of the sheet.
*/
#report-page-header
{
  height: 76px;
  background: linear-gradient(105deg, var(--brand) 0%, var(--brand-2) 62%, #17416f 100%);
  color: var(--ink-invert);
}

.masthead
{
  display: flex;
  align-items: center;
  gap: 14px;
  height: 73px;
  padding: 0 40px;
  box-sizing: border-box;
}

/* The accent edge, under the band. Three pixels is enough to read as deliberate. */
#report-page-header::after
{
  content: "";
  display: block;
  height: 3px;
  background: linear-gradient(to left, var(--accent), var(--cyan) 55%, rgba(14, 165, 233, 0));
}

.masthead .logo
{
  width: 30px;
  height: 30px;
  flex: 0 0 auto;
}

.masthead .titles
{
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.masthead .org
{
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: -.005em;
}

.masthead .subject
{
  font-size: 10.5px;
  color: #a9c2e0;
}

.masthead .meta
{
  margin-inline-start: auto;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 2px;
  text-align: end;
  white-space: nowrap;
}

.masthead .period
{
  font-size: 11px;
  font-weight: 600;
}

/* A quiet outline rather than a filled badge: it is a caveat, not a headline. */
.masthead .classification
{
  padding: 1px 7px;
  border: 1px solid rgba(255, 255, 255, .34);
  border-radius: 999px;
  font-size: 9px;
  color: #cddcee;
}

/* ---------- the page's own heading, under the band ---------- */

.pagehead
{
  display: flex;
  align-items: flex-end;
  gap: 12px;
  padding-bottom: 9px;
  border-bottom: 1px solid var(--rule);
}

.pagehead h1
{
  margin: 0;
  font-size: 19px;
  font-weight: 700;
  letter-spacing: -.015em;
}

.pagehead .lede
{
  margin: 3px 0 0;
  color: var(--ink-soft);
  font-size: 10.5px;
}

.pagehead .stamp
{
  margin-inline-start: auto;
  color: var(--ink-faint);
  font-size: 10px;
  white-space: nowrap;
}

/* ---------- parts ---------- */

.card
{
  box-sizing: border-box;
  padding: 12px 14px 10px;
  background: var(--panel);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
}

.card-head
{
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 8px;
}

.card h2
{
  margin: 0;
  font-size: 12.5px;
  font-weight: 700;
}

.card .note
{
  margin-inline-start: auto;
  color: var(--ink-faint);
  font-size: 9.5px;
  white-space: nowrap;
}

/* A chart draws itself left to right whatever the page does, and its own labels sit inside it. */
.chart
{
  width: 100%;
  direction: ltr;
}

.chip
{
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 1px 7px;
  border-radius: 999px;
  font-size: 9.5px;
  font-weight: 700;
  line-height: 1.7;
  white-space: nowrap;
}

.chip--good { background: var(--good-soft); color: var(--good); }
.chip--bad  { background: var(--bad-soft);  color: var(--bad); }
.chip--warn { background: var(--warn-soft); color: var(--warn); }
.chip--idle { background: var(--idle-soft); color: var(--idle); }

/*
  A status, as a word with a dot rather than as a filled block: eight of them down a table column
  would otherwise be eight blocks of colour competing with the figures beside them.
*/
.pill
{
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 10px;
  font-weight: 600;
  white-space: nowrap;
}

.pill::before
{
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
  flex: 0 0 auto;
}

.pill--good { color: var(--good); }
.pill--warn { color: var(--warn); }
.pill--bad  { color: var(--bad); }
.pill--idle { color: var(--idle); }
.pill--done { color: var(--accent); }

/*
  A proportion, drawn rather than written. Vector, so it stays sharp where a canvas would not — the
  small measures are the ones a reader leans in on.
*/
.meter
{
  height: 5px;
  border-radius: 3px;
  background: var(--rule-soft);
  overflow: hidden;
}

.meter > span
{
  display: block;
  height: 100%;
  border-radius: 3px;
  background: var(--accent);
}

.meter--good > span { background: var(--good); }
.meter--warn > span { background: var(--warn); }
.meter--bad  > span { background: var(--bad); }

/* ---------- tables ---------- */

/*
  Fixed layout and a header group, which is the pair that keeps a long table on the sheet: without
  the first, columns size to their content and the far one runs off the paper; without the second,
  a table that paginates leaves the next sheet as unlabelled columns of numbers.
*/
table.grid
{
  width: 100%;
  table-layout: fixed;
  border-collapse: collapse;
  font-size: 10.5px;
}

table.grid thead { display: table-header-group; }

table.grid tr { break-inside: avoid; }

/*
  Border-box, because a fixed layout takes the width it is given as the whole column: with padding
  added on top instead, six columns quietly cost another 96px and the one column left on auto pays
  for all of it.
*/
table.grid th,
table.grid td { box-sizing: border-box; }

table.grid th
{
  padding: 6px 8px;
  background: var(--panel-2);
  border-bottom: 1px solid var(--rule);
  color: var(--ink-soft);
  font-size: 9.5px;
  font-weight: 700;
  text-align: start;
  white-space: nowrap;
}

table.grid td
{
  padding: 6px 8px;
  border-bottom: 1px solid var(--rule-soft);
  vertical-align: middle;
  overflow-wrap: break-word;
}

table.grid tbody tr:last-child td { border-bottom: 0; }

.num
{
  text-align: end;
  font-weight: 600;
}

/* ---------- the foot of the page ---------- */

.footnote
{
  margin-top: 12px;
  padding-top: 8px;
  border-top: 1px solid var(--rule);
  color: var(--ink-faint);
  font-size: 9.5px;
}

/*
  The running footer, which the browser draws rather than the page.

  It is a document of its own at print time: this stylesheet is read and sent along with it, so type
  and layout arrive, but nothing it would have to fetch does — no webfont, no image, no background.
  So it is set in whatever face the renderer has, and kept to figures and a code, which read the same
  in any of them.
*/
.pagefoot
{
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 0 40px;
  box-sizing: border-box;
  color: #8a97a8;
  font-family: "Segoe UI", Arial, sans-serif;
  font-size: 8.5px;
  letter-spacing: .04em;
}

What it carries

Stylesheets, scripts, images and fonts travel inside the report, so nothing is resolved from the outside while it renders. Anything under shared-assets/ belongs to the project rather than to this report — held once for every report in it, and reached from the template at the same assets/… path as the report's own.