Paragon
Examples Word template LTR

Customer Account Statement (Word template)

This one is not an HTML page at all. The template is a real Word file, written in Word with {{ }} placeholders left in it — so the people who own the document keep owning it. There is nothing to photograph here because nothing on this server lays a document out: generate it and open the file itself.

The source

The template itself is a Word file, so it is read by opening it rather than by scrolling it here. What is below is what travels beside it: the web parts — ordinary HTML pages, each photographed into the document at the size of the picture whose alt text names it — and the stylesheets they are dressed in. Every marker a document understands is one page.

parts/ageing.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!--
    A donut drawn straight onto a canvas, with no library at all.

    Here to show that a part is only a web page: it can bring amCharts, it can bring nothing, it can
    be a div and some css. The engine loads it at the size the picture occupies and photographs it,
    and what happens in between is entirely the author's business.
-->
<style>
  html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #fff; }
  body { font: 11px "Segoe UI", Tahoma, sans-serif; color: #1f2430; }
  #chart { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>

<script id="report-data" type="application/json">{{json}}</script>

<canvas id="chart"></canvas>

<script>
(function () {
  'use strict';

  var model = JSON.parse(document.getElementById('report-data').textContent);
  var canvas = document.getElementById('chart');

  // A canvas holds pixels rather than shapes, so it is the one thing on the page that does not
  // sharpen when the picture is printed. The backing store is asked for three times the css size,
  // which is about what a document is rasterised at.
  var scale = 3;
  var width = canvas.clientWidth;
  var height = canvas.clientHeight;

  canvas.width = width * scale;
  canvas.height = height * scale;

  var g = canvas.getContext('2d');
  g.scale(scale, scale);

  // Money outstanding, split by how large the invoice is. Amounts arrive formatted, so the
  // separators come out before they are read as numbers.
  var buckets = [
    { label: 'Under 500',  max: 500,      total: 0, fill: '#c8d4dc' },
    { label: '500 – 1k',   max: 1000,     total: 0, fill: '#8fa8b8' },
    { label: '1k – 3k',    max: 3000,     total: 0, fill: '#4f7285' },
    { label: 'Over 3k',    max: Infinity, total: 0, fill: '#a32b1c' }
  ];

  (model.lines || []).forEach(function (line) {
    var amount = parseFloat(String(line.amount).replace(/,/g, '')) || 0;
    for (var i = 0; i < buckets.length; i++) {
      if (amount < buckets[i].max) { buckets[i].total += amount; return; }
    }
  });

  var shown = buckets.filter(function (b) { return b.total > 0; });
  var grand = shown.reduce(function (sum, b) { return sum + b.total; }, 0) || 1;

  function money(value) {
    return value >= 10000
      ? '$' + Math.round(value / 1000) + 'k'
      : '$' + Math.round(value).toLocaleString('en-US');
  }

  // The ring on the left, what it is made of on the right.
  var outer = Math.min(height * 0.44, width * 0.30);
  var inner = outer * 0.58;
  var cx = outer + 10;
  var cy = height / 2;

  var angle = -Math.PI / 2;

  shown.forEach(function (bucket) {
    var sweep = (bucket.total / grand) * Math.PI * 2;

    g.beginPath();
    g.arc(cx, cy, outer, angle, angle + sweep);
    g.arc(cx, cy, inner, angle + sweep, angle, true);
    g.closePath();
    g.fillStyle = bucket.fill;
    g.fill();

    // A hairline in the paper's own colour, so neighbouring slices read apart.
    g.strokeStyle = '#fff';
    g.lineWidth = 1.5;
    g.stroke();

    angle += sweep;
  });

  // The whole of it, in the hole.
  g.fillStyle = '#1f2430';
  g.textAlign = 'center';
  g.font = '700 13px "Segoe UI", Tahoma, sans-serif';
  g.fillText(money(grand), cx, cy + 1);

  g.fillStyle = '#5b6470';
  g.font = '9px "Segoe UI", Tahoma, sans-serif';
  g.fillText('OUTSTANDING', cx, cy + 13);

  // The legend, one row per slice, aligned down the right.
  var left = cx + outer + 18;
  var rowHeight = 17;
  var top = cy - (shown.length * rowHeight) / 2 + rowHeight / 2;

  g.textAlign = 'left';

  shown.forEach(function (bucket, i) {
    var y = top + i * rowHeight;

    g.fillStyle = bucket.fill;
    g.beginPath();
    g.arc(left + 4, y - 3, 4, 0, Math.PI * 2);
    g.fill();

    g.fillStyle = '#1f2430';
    g.font = '10px "Segoe UI", Tahoma, sans-serif';
    g.fillText(bucket.label, left + 14, y);

    g.fillStyle = '#5b6470';
    g.font = '700 10px "Segoe UI", Tahoma, sans-serif';
    g.textAlign = 'right';
    g.fillText(money(bucket.total), width - 4, y);
    g.textAlign = 'left';
  });

  // Nothing here is asynchronous, but the signal is given all the same rather than left to a
  // timeout — the same call a page makes when it has finished drawing.
  window.paragonEngine.ready();
})();
</script>

</body>
</html>
parts/status.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!--
    One column chart, drawn with amCharts 5, for a picture in the Word template.

    A part is an ordinary little page: it is loaded at exactly the size the picture occupies in the
    document, draws into the whole of it, and says when it has finished. Nothing about it knows it
    is destined for a document rather than a screen.
-->
<style>
  html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #fff; }
  #chart { width: 100%; height: 100%; }
</style>
<script src="assets/js/vendor/amcharts5-5.14.4.js"></script>
</head>
<body>

<!-- The payload, read the same way every chart in this product reads it. -->
<script id="report-data" type="application/json">{{json}}</script>

<div id="chart"></div>

<script>
(function () {
  'use strict';

  var model = JSON.parse(document.getElementById('report-data').textContent);
  var FONT = '"Segoe UI", Tahoma, sans-serif';
  var INK = 0x1f2430;
  var RULE = 0xe2e8f0;

  // What is outstanding under each status, off the invoice lines the statement already carries.
  // Amounts arrive formatted, so the separators come out before they are read as numbers.
  var order = ['Paid', 'Open', 'Overdue'];
  var colours = { Paid: 0x2e6b3a, Open: 0x8a6d1f, Overdue: 0xa32b1c };
  var tally = {};

  (model.lines || []).forEach(function (line) {
    var amount = parseFloat(String(line.amount).replace(/,/g, '')) || 0;
    tally[line.status] = (tally[line.status] || 0) + amount;
  });

  // The label is worked out here rather than left to amCharts to format. Its bullet labels do not
  // take the root's number format and the inline formatNumber syntax does not survive being written
  // inside a javascript string, so the simplest thing that is certainly right is a plain field.
  var data = order
    .filter(function (status) { return tally[status] !== undefined; })
    .map(function (status) {
      return {
        status: status,
        total: tally[status],
        label: '$' + Math.round(tally[status]).toLocaleString('en-US')
      };
    });

  var root = am5.Root.new('chart');

  // The Animated theme is deliberately not loaded — a chart caught mid-appear is a quarter-drawn
  // chart in the document — so only the rules this chart needs are set.
  var rules = am5.Theme.new(root);
  rules.rule('Label').setAll({ fontFamily: FONT, fontSize: 11, fill: am5.color(INK) });
  rules.rule('AxisLabel').setAll({ fontSize: 10.5 });
  rules.rule('Grid').setAll({ stroke: am5.color(RULE), strokeOpacity: 1 });
  rules.rule('AxisLine').setAll({ stroke: am5.color(RULE), strokeOpacity: 1 });
  root.setThemes([rules]);
  root._logo && root._logo.dispose();

  root.numberFormatter.set('numberFormat', "'$'#,###");

  // A canvas holds pixels, so it does not sharpen when the document is printed. Ask the backing
  // store for three times the css size, which is about what a page is rasterised at.
  if (root._renderer && typeof root._renderer.resolution === 'number') {
    root._renderer.resolution = 3;
    root.resize();
  }

  var chart = root.container.children.push(am5xy.XYChart.new(root, {
    paddingLeft: 2, paddingRight: 8, paddingTop: 10, paddingBottom: 0,
    layout: root.verticalLayout
  }));

  var xRenderer = am5xy.AxisRendererX.new(root, { minGridDistance: 20 });
  xRenderer.grid.template.setAll({ visible: false });

  var xAxis = chart.xAxes.push(am5xy.CategoryAxis.new(root, {
    categoryField: 'status',
    renderer: xRenderer
  }));

  var yRenderer = am5xy.AxisRendererY.new(root, {});
  yRenderer.labels.template.setAll({ fontSize: 9.5 });

  var yAxis = chart.yAxes.push(am5xy.ValueAxis.new(root, {
    min: 0,
    renderer: yRenderer
  }));

  var series = chart.series.push(am5xy.ColumnSeries.new(root, {
    name: 'Outstanding',
    xAxis: xAxis,
    yAxis: yAxis,
    valueYField: 'total',
    categoryXField: 'status'
  }));

  series.columns.template.setAll({
    width: am5.percent(52),
    strokeOpacity: 0,
    cornerRadiusTL: 2,
    cornerRadiusTR: 2
  });

  // Each column takes its status's colour — the same three the rows are shaded with.
  series.columns.template.adapters.add('fill', function (fill, target) {
    var status = target.dataItem && target.dataItem.get('categoryX');
    return colours[status] !== undefined ? am5.color(colours[status]) : fill;
  });

  // The value above each column, since the axis alone is hard to read at this size.
  series.bullets.push(function () {
    return am5.Bullet.new(root, {
      locationY: 1,
      sprite: am5.Label.new(root, {
        text: '{label}',
        fontSize: 10,
        fontWeight: '700',
        centerX: am5.p50,
        centerY: am5.p100,
        dy: -4,
        populateText: true
      })
    });
  });

  xAxis.data.setAll(data);
  series.data.setAll(data);

  // No animation, for the reason above.
  series.appear(0, 0);
  chart.appear(0, 0);

  // What the renderer waits for, and the same call a page makes. Held open until amCharts says it
  // has painted a frame, so the photograph is of a finished chart.
  var release = window.paragonEngine.pending();

  root.events.once('frameended', function () { release(); });
  window.paragonEngine.ready();
})();
</script>

</body>
</html>
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.