Skip to content

Report Packs Internals 2 The Layout Engine

Ed Mozley edited this page Oct 2, 2026 · 1 revision

Report Packs internals, part 2: the layout engine

Series: 1. The designer Β· 2. The layout engine Β· 3. PHP and SQL Β· overview: Report Packs developer guide

This part covers how a pack's design and its data become pages: every line of text, table cell, bar and picture at an exact position in millimetres. It then covers how those pages become both the SVG you edit on screen and the PDF you send, and why the two cannot disagree.

Files: assets/js/report-packs/engine.js (layout), charts.js (Chart.js at a fixed size), render-svg.js and render-pdf.js (the two renderers), and runtime.js (data, the logo, chart pictures and export).


Contents

  1. The one idea
  2. Units and constants
  3. Measuring text with the PDF's own fonts
  4. Rich text layout
  5. Primitives: the engine's output
  6. Measuring blocks
  7. Splitters: blocks that run onto the next page
  8. Pagination
  9. Rendering to SVG
  10. Rendering to PDF
  11. Charts: one layout, two resolutions
  12. The runtime
  13. Extending the engine
  14. Traps

1. The one idea

Every report designer has to answer where does this line break, and where does this page end?, and it has to answer identically on screen and in the file. Lay the screen out with the browser and the PDF with a PDF library, and they will disagree. A table that ends on page 2 on screen will spill onto page 3 in the PDF, because the browser's Arial is a fraction wider than the PDF's Helvetica and one cell wraps.

So exactly one function decides layout:

                     design + block data + context
                                  β”‚
                                  β–Ό
              engine.js   RPEngine.paginate(design, dataMap, ctx)
                                  β”‚   every line break, every page break, every position
                                  β–Ό
     { pages: [ { type, items: [ {id, x, y, w, h, draw:[prim…], chart, rich} ],
                  headerRich, footerRich, coverRich, toc, number } ],
       geometry, headings, headerH, footerH, colW, gutter }
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β–Ό                            β–Ό
            render-svg.js                  render-pdf.js
         (pages on screen, as SVG)       (jsPDF: the file)
           decides nothing                 decides nothing

The renderers are deliberately boring. They walk the same primitives in the same order, draw each at the coordinates given, and never measure or break anything. Any decision added to one renderer would be a place for the two to disagree.

paginate() is a pure function: no network, no DOM, no state beyond two caches (character widths, section 3). That purity is what lets the designer re-run it on every change (part 1, section 5), and lets the toolbox preview lay out a one-block copy of the design with the same code.


2. Units and constants

Everything is in millimetres. Font sizes are points, converted with PT:

const PT = 25.4 / 72;      // mm per point
const LINE = 1.28;         // line height, as a multiple of font size
const GUTTER = 5;          // mm between grid columns
const ROW_GAP = 4.5;       // mm between rows of blocks
const PAGE_SIZES = { A4: [210, 297], Letter: [215.9, 279.4], Legal: [215.9, 355.6], A3: [297, 420] };
function pageGeometry(design) {
    const [w0, h0] = PAGE_SIZES[design.page.size] || PAGE_SIZES.A4;
    const land = design.page.orient === 'landscape';
    const pw = land ? h0 : w0, ph = land ? w0 : h0;
    return { pw, ph, m: design.page.margin, cw: pw - m.l - m.r };    // cw = content width
}

The grid. The content width is divided into 12 columns with 11 gutters:

const colW = (G.cw - GUTTER * 11) / 12;
const spanW = span => span * colW + (span - 1) * GUTTER;     // a block `span` columns wide

On A4 portrait with the default 16mm side margins, the content width is 178mm, a column is 10.25mm, and a half-width block (span 6) is 86.5mm.


3. Measuring text with the PDF's own fonts

The PDF uses the three standard PDF fonts (Helvetica, Times, Courier). They need no embedding and are present in every PDF reader. The engine measures with their metrics, through jsPDF, never with the browser's fonts:

let M = null;                         // a jsPDF used only for its font metrics
const widthCache = new Map();         // "font|style" -> Map(char -> units)

function charUnits(font, style, ch) {
    const key = font + '|' + style;
    let m = widthCache.get(key);
    if (!m) { m = new Map(); widthCache.set(key, m); }
    let w = m.get(ch);
    if (w === undefined) {
        if (!M) M = new window.jspdf.jsPDF({ unit: 'pt' });
        M.setFont(font, style);
        w = M.getStringUnitWidth(ch);
        if (!w && ch !== '​') w = M.getStringUnitWidth('n');   // no glyph: keep layout sane
        m.set(ch, w);
    }
    return w;
}

function textWidth(str, st) {
    const style = styleKey(st.b, st.i);                  // normal | bold | italic | bolditalic
    let u = 0;
    for (const ch of str) u += charUnits(st.font, style, ch);
    return u * st.size * PT;                             // units are per 1pt of font size
}
  • Per character, cached for ever. Measuring a word is a loop of Map lookups after the first sight of each character. for...of iterates by code point, so a character outside the Basic Multilingual Plane is one character.
  • No kerning on either side. getStringUnitWidth sums each character's advance width, and jsPDF places standard-font text the same way, so the measurement matches what the PDF draws.
  • A missing glyph measures as an n. A character the font can't draw would otherwise measure as zero width and collapse on screen. Giving it a nominal width keeps the line sane; the designer flags it anyway.

What the fonts can't do

The standard fonts are WinAnsi: Latin-1 plus 27 extra characters (€, curly quotes, dashes, Ε , Ε’...). That covers English and Western European languages, including Spanish accents and Γ±, but not Greek, Cyrillic, CJK or emoji.

const WINANSI_EXTRA = 'β‚¬β€šΖ’β€žβ€¦β€ β€‘Λ†β€°Ε β€ΉΕ’Ε½β€˜β€™β€œβ€β€’β€“β€”Λœβ„’Ε‘β€ΊΕ“ΕΎΕΈ';
function hasUnsupported(str) {
    for (const ch of String(str)) {
        const c = ch.codePointAt(0);
        if (c > 0xff && WINANSI_EXTRA.indexOf(ch) < 0) return true;
    }
    return false;
}

The designer shows a warning in its status bar when any pack text fails this check (rt.checkUnsupported()), and render-pdf.js writes ? instead of garbage (section 10). Embedding a Unicode font (Noto) is the way to lift the limit, at a cost of several hundred KB per face per pack, plus its own metrics for the engine to measure with.


4. Rich text layout

Text boxes, the header, the footer and the cover are stored as paragraphs of runs (part 3 has the full schema):

{ t: 'p'|'h1'|'h2'|'h3'|'li'|'img', a: 'left'|'center'|'right'|'justify', l: 'ul'|'ol', lv: 0-4,
  r: [ { x: 'text' } | { fld: 'page'|'pages'|'date_from'|'date_to'|'today'|'title'|'company' },
       + b, i, u, s, c: '#rrggbb', hl: '#rrggbb', sz: points ] }

layoutDoc(doc, width, ctx) returns {height, lines, images}, with every line's y, h, baseline and its positioned, styled pieces. It works paragraph by paragraph.

Paragraph by paragraph

p.t === 'img'   β†’ the logo: width p.w mm (capped at the box), height from the logo's
                  aspect ratio, aligned left/centre/right. y += h + 1.5
headings        β†’ size Γ— 1.8 / 1.45 / 1.2, bold, the theme's heading colour,
                  and space above (0.6 Γ— the size) unless it is the first paragraph
list items      β†’ indent lv Γ— 6mm; marker "1." "2." for ol, β€’ and β—¦ alternating by level for ul;
                  text starts 5mm after the marker; numbering restarts when the list
                  does, and deeper counters reset when a shallower item comes
everything      β†’ tokenise β†’ break into lines β†’ align β†’ justify β†’ merge runs
space after     β†’ 0.45 Γ— the size for a paragraph, 0.15 Γ— for a list item

Tokenising

tokenise(runs, base, ctx) turns runs into tokens: words, spaces and hard line breaks. Each token carries its own computed style and width:

let text = r.fld ? fieldValue(r.fld, ctx.fields) : (r.x || '');   // fields become their values here
text.split(/(\n)/).forEach(part => {
    if (part === '\n') { out.push({ br: true, st }); return; }
    part.split(/(\s+)/).forEach(w => {
        if (!w) return;
        const space = /^\s+$/.test(w);
        const tx = space ? ' ' : w;                  // any run of whitespace is one space
        out.push({ text: tx, st, space, w: textWidth(tx, st) });
    });
});

Fields are resolved during layout, so {page} on page 7 is laid out as 7, and the {date_from} in the header is the real formatted date. A run's own size (r.sz) scales with the paragraph, so a 9pt run inside a heading grows with the heading.

Breaking lines

breakLines(tokens, avail) is greedy, as every word processor is:

for (const t of tokens) {
    if (t.br) { push(true); continue; }                        // hard break
    if (t.space) {
        if (!cur.items.length) continue;                       // no leading space on a line
        cur.items.push(t); cur.width += t.w; continue;
    }
    if (cur.width + t.w <= avail + 0.01) { cur.items.push(t); cur.width += t.w; continue; }
    if (cur.items.length) push(false);                         // the word goes to the next line
    if (t.w <= avail) { cur.items.push(t); cur.width += t.w; continue; }
    // a single word longer than the line: break it by characters
}

push() strips trailing spaces before it ends a line, so a line's width is its ink, which matters for right alignment and justification. A word wider than the whole box (a long URL) is split by characters rather than overflowing. The + 0.01 is a hundredth of a millimetre of tolerance, so that floating-point dust can never push a word that exactly fits onto the next line.

Aligning and justifying

const size = Math.max(base.size, ...ln.items.map(t => t.st.size));   // the tallest run sets the line
const h = size * PT * LINE;
const baseline = y + (h - size * PT) / 2 + size * PT * 0.8;
const slack = avail - ln.width;
let x0 = textX + (p.a === 'center' ? slack / 2 : p.a === 'right' ? slack : 0);

// Justify: share the slack between the spaces, except on a paragraph's last
// line (and lines ended by a hard break), as every editor does.
const spaces = ln.items.filter(t => t.space).length;
const stretch = (p.a === 'justify' && !ln.hard && li < paraLines.length - 1 && spaces) ? slack / spaces : 0;

The baseline sits in the line box so that text is vertically centred in its leading, with descenders accounted for. A line containing one large run grows to fit it.

Merging runs

After positioning, mergeRuns() joins neighbouring pieces that have identical style and touch exactly into one run. A 60-word sentence in one style becomes one <text> element and one doc.text() call instead of 119. Spaces are dropped from the output unless they carry visible decoration (underline, strike or highlight).

Plain text

Table cells and captions use wrapPlain(text, width, st): the same tokeniser and line breaker, without the rich-text model. A cell therefore wraps exactly as a paragraph does.


5. Primitives: the engine's output

Everything on a page is one of a handful of primitives, positioned in mm:

k Fields Drawn as
text x, y (baseline), text, st (font, size, colour, b, i, u, s, hl), w (measured width) one run of text
rect x, y, w, h, fill, optional stroke, r (corner radius), dash a box, pill, bar or tile
line x1, y1, x2, y2, color, width a rule
dots x1, x2, y, color a dot leader (the contents page)

Plus, on a page item: rich, a laid-out layoutDoc result with its origin (text boxes, so the designer can open an editor on exactly those lines), and chart, a chart's box and data (section 11). Each page also has headerRich, footerRich, coverRich, toc (primitives) and the optional rule positions.

Blocks are measured in block coordinates (0,0 at their top-left). offset() moves each primitive to page coordinates when the block is placed. A line with x2: null means to the block's right edge, so a heading's underline doesn't need to know its width in advance.


6. Measuring blocks

measureBlock(block, dataEntry, width, theme, ctx) returns {h, draw, ...} for any block, at the width its span gives it:

Block Measured as
heading one line at 15 / 12.5 / 11pt bold in the heading colour; level 1 gets an underline rule. Always full width.
divider a 3mm-high hairline
spacer its height, nothing drawn
pagebreak zero height; it acts in pagination
text layoutDoc at its width (less 6mm if boxed, with a light fill and border), and splittable by line if it has more than two
data a title line, then by the data's kind (below)

Data blocks

A data block gets a title line (theme size + 1.5pt, bold, the heading colour) unless Show title is off. A snapshot block, such as software inventory that describes now rather than the period, adds an italic note under the title saying so.

Then by what the server sent (part 3 defines the four kinds):

  • Still loading β†’ a dashed grey placeholder at the block's chart height (default 30mm), saying Loading....
  • Error β†’ a pale red box with the message, e.g. You do not have access to Contracts. A shared pack shows a reader without access this instead of the figures.
  • chart β†’ a box height mm tall (default 78mm full width, 68mm otherwise), filled later with a picture (section 11).
  • kpi β†’ tiles, as many per row as fit at about 30mm each (Math.floor((width + 3) / 33)), each 19mm tall with an accent bar. The value is large, with a label and an optional hint below, each truncated with an ellipsis to fit (fit()).
  • table β†’ see below. Splittable by row.
  • uptime β†’ per service: name, description, uptime percentage and downtime, a strip of daily bars, and an incident list. Splittable by service.

Tables

const total = cols.reduce((s, c) => s + (c.w || 10), 0) || 1;
const widths = cols.map(c => width * (c.w || 10) / total);      // column weights β†’ mm
  • Columns are weights, not millimetres: the data function says w: 3 and w: 1, and the table fills whatever width the block has.
  • Every cell wraps (wrapPlain) and a row is as tall as its tallest cell. Right-aligned columns align each wrapped line.
  • Pills. A cell {pill: 'Major outage', colour: '#d93025'} draws a rounded box with the text in black or white, whichever contrasts. onColour() uses the YIQ brightness formula: above 160, use dark text.
  • Chips. {chips: [{text, colour}...]} wraps a row of pills across lines inside the cell, e.g. the services an incident affected.
  • A detail line. A row with _detail gets a full-width line of text under it, starting from the second column (the incident log's update comments).
  • Stripes on alternate rows when the theme says so, and a hairline under each row.
  • Header in the theme's table colours, and repeated on every page the table continues onto.
  • Empty or footnote: data.note, or data.empty when there are no rows, in italics.

measureTable doesn't produce drawing directly. It returns the header, the rows and the footer as parts, so tableDraw(T, from, to, withFoot) can draw any slice of rows under a header. That is what makes splitting possible.


7. Splitters: blocks that run onto the next page

A block too tall for the space left on a page either moves to the next page whole, or, if it is splittable, puts what fits here and the rest overleaf. Splittable blocks (tables, uptime lists, long text) return a splitter:

split: {
    start: () => state,                       // the beginning
    take(state, room, force) β†’ {
        n,          // how many units (rows, services, lines) went on this page; 0 = none fit
        h,          // the height they take, mm
        draw,       // their primitives (with the title and header if they apply)
        rest,       // the state to continue from, or null when finished
        continued,  // true if this is not the first part
    }
}

The paginator doesn't know what a row is; it asks for as much as fits in room mm. Three splitters implement the protocol:

  • tableSplitter takes rows while h + row.h <= room. The first part includes the block's title; every part includes the table header. The footnote goes with the last part only.
  • unitSplitter (uptime) takes whole services: a service's name, bars and incidents never separate.
  • textSplitter takes whole lines of a laid-out rich-text document. It rebases each part's line ys to 0, carries the logo images that fall inside the slice, and hands back a richPart, so the designer can still edit the part where it sits.

force handles a single unit taller than a whole page (a table row with a 2,000-word comment). Called with force, take returns that one unit even though it doesn't fit, and it overflows rather than looping for ever.


8. Pagination

paginate(design, dataMap, ctx) in five steps.

1. Header and footer heights, measured once with wide numbers

const fieldsAt = (n, total) => Object.assign({}, ctx.fields, { page: n, pages: total, title: ctx.title });
const hdrProbe = layoutPart(design.header, G.cw, theme, { fields: fieldsAt(888, 888), logo: ctx.logo });
const headerH  = hdrProbe ? hdrProbe.height + (design.header.rule ? 2.5 : 0) + 5 : 0;

Page numbers aren't known until the end, but a footer saying Page 8 of 12 is narrower than Page 108 of 112. If the footer wrapped once the real numbers arrived, every page's body would shrink and the whole layout would have to run again. So the header and footer are measured once with 888 of 888 (the widest plausible digits) and always laid out in that space. They can never grow later.

2. Measure every block at its width

const measured = design.blocks.map(b => measureBlock(b, dataMap[b.id], spanW(blockSpan(b)), theme, ctx));

3. Flow blocks into rows

design.blocks.forEach((b, i) => {
    const span = blockSpan(b);
    const solo = b.type !== 'data' && b.type !== 'text' && !(b.type === 'spacer' && span < 12);
    if (!row || solo || row.solo || row.used + span > 12 || b.newRow) {
        row = { items: [], used: 0, solo };
        rows.push(row);
    }
    row.items.push({ b, m: measured[i], col: row.used, span });
    row.used += span;
});

Blocks pack left to right until 12 columns are used. Headings, dividers, page breaks and full-width spacers are solo: always a row of their own. newRow: true starts a new row even when this one has room, which is how dropped below survives a flow layout (part 1, section 7). A row is as tall as its tallest block.

4. Place rows on pages

For each row:

  • A page break starts a new page (unless the current one is empty) and leaves a zero-height marker item, so the designer can draw and select the break.
  • A heading with Start on a new page starts one.
  • The gap: ROW_GAP (4.5mm) above every row except the first on a page.
  • Keep with next: a heading isn't left alone at the bottom of a page. If the heading, the gap and the first part of the next row (its minH, capped at 25mm) don't fit, the heading moves to the next page.
  • If the row fits, place it.
  • If it doesn't fit and can't split, start a new page and place it there. If it is already the first thing on a page and still doesn't fit, place it anyway: it is taller than a page and can go nowhere better.
  • If it doesn't fit and can split (a single splittable block on its row), run the splitter:
let state = it.m.split.start();
while (state) {
    let room = bodyBottom() - y;
    let part = it.m.split.take(state, room);
    if (!part.n && page.items.length) { newPage(); room = bodyBottom() - y; part = it.m.split.take(state, room); }
    if (!part.n) part = it.m.split.take(state, Infinity, 1);    // a single unit taller than a page
    place(it, { draw: part.draw, h: part.h, continued: part.continued, rich: part.richPart || null, more: !!part.rest });
    y += part.h;
    state = part.rest;
    if (state) newPage();
}

If not even one row fits in the space left, it moves to a fresh page before taking anything, so a table never starts with just its title stranded at the foot of a page.

place() records each item with its absolute box, its primitives moved to page coordinates, its chart box and its rich text. It also collects headings (toc !== false, first part only) with the index of the page they landed on, for the contents.

5. Cover and contents go in front, then the page furniture

The cover (if on) and the contents pages are inserted before the body. The contents page count is estimated from the number of headings and the line height:

tocPages = Math.max(1, Math.ceil(headings.length * lh / usable));

Only now is the total page count known, so every page gets its number and its header, footer and cover laid out with the real field values:

  • the header at the top margin, with its rule
  • the footer bottom-aligned to the bottom margin
  • the cover placed a little above the middle of the page ((ph - height) / 2.6), as a title page usually is
  • the contents: the title on the first contents page, then one line per heading, indented by level, with its text truncated so it fits, a dot leader, and the right-aligned page number. Headings know their body page index, so their printed number is that plus the number of front pages, plus one.

Page numbers count every physical page, including the cover and contents, as a printed document does.

What comes out

return { pages: all, geometry: G, headings, headerH, footerH, colW, gutter: GUTTER, spanW };

The designer uses geometry, colW and gutter for its rulers, drop targets and resize snapping (part 1), so those agree with the engine as well.


9. Rendering to SVG

RPRenderSvg.page(pg, G, opts) builds one <svg> per page:

const svg = el('svg', { viewBox: '0 0 ' + G.pw + ' ' + G.ph, width: G.pw + 'mm', height: G.ph + 'mm',
                        'text-rendering': 'geometricPrecision' });

The viewBox is the page in millimetres, so every engine coordinate is used exactly as given. Zoom is only the element's displayed size (part 1).

Text is where the trick is:

function drawText(g, x, y, text, st, w) {
    if (st.hl) el('rect', { x, y: y - st.size * PT * 0.82, width: w, height: st.size * PT * 1.08, fill: st.hl }, g);
    const t = el('text', Object.assign({ x, y, 'xml:space': 'preserve' }, fontAttrs(st)), g);
    t.textContent = text;
    if (w > 0.5 && text.trim()) {
        t.setAttribute('textLength', w.toFixed(3));
        t.setAttribute('lengthAdjust', 'spacingAndGlyphs');
    }
    // underline and strike-through as lines, at the same offsets the PDF uses
}
  • textLength pins each run to the width the engine measured with the PDF font's metrics. On screen the browser draws with Arial (or Liberation Sans, or whatever FONT_CSS resolves to). If that font is a hair wider or narrower than Helvetica, lengthAdjust="spacingAndGlyphs" stretches or squeezes the run by that hair. The line can never wrap differently, because the engine has already broken it; the renderer just fits it to the box. Between metric-compatible fonts the adjustment is very small.
  • xml:space="preserve" keeps internal spaces in merged runs.
  • textContent only. Nothing from a pack is parsed as markup.
  • Underline, strike-through and highlight are drawn as shapes, at the same offsets from the baseline that render-pdf.js uses. CSS text-decoration would place them differently from the PDF.
  • geometricPrecision tells the browser to favour exact glyph positioning over hinting, which matters for text being stretched to a measured width.

The drawing order is header, footer, cover, contents, then the items. Each block's primitives are grouped as <g data-block="id">. Rich text draws list markers, then runs; the logo is an <image> (or a grey box if there is none). Charts are <image> elements showing the cached PNG (section 11).


10. Rendering to PDF

RPRenderPdf.build(layout, opts) is the twin of the SVG renderer: the same primitives, coordinates and order, through jsPDF in unit: 'mm'.

const doc = new window.jspdf.jsPDF({ unit: 'mm', format: [G.pw, G.ph],
                                     orientation: G.pw > G.ph ? 'landscape' : 'portrait', compress: true });
doc.setProperties({ title: opts.title || 'Report', creator: 'FreeITSM' });
  • Text is real PDF text in the standard fonts: searchable, selectable, copyable and sharp at any zoom. The same styleKey() maps bold and italic to jsPDF's font styles, and baseline: 'alphabetic' matches the engine's baseline.
  • safe() turns any character outside WinAnsi into ?, and the β—¦ list marker into -. Writing an unencodable character would otherwise produce garbage or break the text object.
  • Rectangles: fill, stroke or both ('F', 'S', 'FD'), rounded if r is set, dashed if dash, and the dash pattern is reset afterwards so it doesn't leak into the next shape.
  • Dot leaders are drawn as real dots: tiny filled circles every 1.4mm. (The SVG draws the same leader as a round-capped dashed line.)
  • The logo is embedded once. addImage(logo.dataUrl, 'PNG', x, y, w, h, 'rp-logo', 'FAST') gives it an alias, so the header's logo on 40 pages is one image object referenced 40 times. (assets/js/form-pdf.js explains what happens to file size without one.)
  • Charts are PNGs at about 300 dpi (section 11), with 'FAST' compression.

Export is rt.exportPdf(): load the context and anything not yet fetched, lay out once more, build, then doc.save() with a filename made from the pack name and the period, cleaned of characters Windows won't accept in a filename (rt.fileName()).


11. Charts: one layout, two resolutions

Charts are drawn by Chart.js onto an off-screen canvas whose CSS size is fixed in millimetres, then turned into a PNG. On screen the PNG goes into the SVG; in the PDF it is placed at the same box.

const PX_PER_MM = 4;                 // layout scale; resolution is separate

function render(chart, theme, scale, labels) {
    const wPx = Math.max(40, Math.round(chart.w * PX_PER_MM));
    const hPx = Math.max(40, Math.round(chart.h * PX_PER_MM));
    // off-screen holder at left:-10000px, canvas width/height = wPx/hPx (the LAYOUT size)
    ...
    Object.assign(cfg.options, { responsive: false, animation: false, devicePixelRatio: scale,
                                 maintainAspectRatio: false, layout: { padding: fontPx(2) } });

Why this works. Chart.js lays everything out in CSS pixels: legend position, label wrapping, how many axis ticks fit, bar widths. Fix the CSS size in millimetres (4px per mm) and the layout is fixed. Then change only devicePixelRatio for resolution: the screen uses the zoom Γ— the display's DPR (snapped to half steps, see part 1), and the PDF uses 3, which at 4px/mm is about 300 dpi. The chart in the PDF is the chart on screen, only sharper; the legend can't move and the labels can't rewrap.

Font sizes go through the same conversion: fontPx(pt) = pt Γ— PT Γ— PX_PER_MM, with the theme's font family, so chart text matches the body text's size on paper.

Other choices:

  • Doughnut and pie legends go to the right if the chart is at least 95mm wide, otherwise below, unless the block sets its own (Legend: right / bottom / none).
  • Colours: a breakdown with its own colours (ticket statuses and priorities have colours in FreeITSM) uses them; otherwise the palette in order. Multi-series charts colour by series.
  • Line charts hide their points when there are more than 40 labels, which would otherwise turn a year of days into a caterpillar.
  • Tooltips and animation off, because the picture is static.
  • No data: an empty chart is drawn as an italic No data for this period in the middle, not as empty axes.
  • Detached and destroyed. Chart.js draws synchronously with animation off. The result is copied onto a fresh canvas, the chart is destroy()ed and the holder removed, so nothing is left behind in the DOM.

runtime.js caches the PNG by everything that changes it:

const key = [item.id, c.w.toFixed(2), c.h.toFixed(2), scale, JSON.stringify(rt.design.theme),
             JSON.stringify(c.data), JSON.stringify(c.block.legend || '')].join('|');

12. The runtime

RPRuntime.create(apiBase, logoUrl) holds everything a pack needs besides its design. One instance is shared by the designer and export.

Block data: a cache, a queue and promises

rt.data = new Map();   // cacheKey -> {data} | {error} | {pending: Promise}
function keyFor(b) { return JSON.stringify([b.handler, b.opts || {}, rt.design.criteria]); }
  • loadData() walks the design's data blocks. Anything not cached gets a {pending} entry and a queue job, then pump() runs up to 4 fetches at once against block_data.php (part 3). It resolves when every block the design needs is in, which is what export waits for.
  • A fetch in flight is never duplicated. A second request for the same key waits on the same promise.
  • Every arrival calls rt.onChange, the designer's scheduleRender (part 1), except quiet jobs: the toolbox preview's fetchBlock() shares the cache but doesn't redraw the pages.
  • dataMap() gives the engine {blockId: entry}, with a still-pending entry as undefined, which the engine draws as a loading placeholder.
  • refresh() (the Data tab's Refresh button) clears both caches and refetches everything.

Context

loadContext() asks context.php for the header fields for the current criteria (date_from, date_to, today, company), already formatted the viewer's way, plus the resolved range and any company error (This report is set to a company you do not have access to). rt.ctx() packs those with the title and the logo's natural size for the engine.

The logo, rasterised once

const scale = Math.min(4, 1200 / w0);           // up to 1200px wide, at most 4Γ—
c.width = Math.round(w0 * scale); ...
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
try { dataUrl = c.toDataURL('image/png'); } catch (e) { /* tainted: no logo in the PDF */ }

The install's logo may be an SVG, which jsPDF can't embed. It is drawn once onto a canvas at print resolution, keeping its aspect ratio, and kept as a PNG data URL. The screen uses the original URL, which is crisp at any zoom; the PDF uses the PNG. If the logo is served from another origin without CORS, the canvas is tainted, so the PDF goes without a logo rather than failing.


13. Extending the engine

A new data block usually needs no engine change. It returns one of the four kinds (part 3), and the engine already measures and draws those.

A new kind of data (say, a gauge) needs:

  1. a measureX() in engine.js, returning primitives (or a chart box) and, if it can run across pages, a splitter implementing start / take
  2. a case in measureBlock()
  3. no renderer change, if it is expressed in the existing primitives. Prefer that.

A new primitive must be added to both render-svg.js and render-pdf.js, drawn the same way. That is the one place the two renderers can drift apart, so add it to both in the same commit and compare a real PDF against the screen.

Changing the row rule in paginate() step 3 means changing rowOf() in designer.js with it (part 1).


14. Traps

  • Chart.js with responsive: false reads canvas.width as its CSS size. Hand it the backing size (wPx Γ— scale) and every chart lays out scale times bigger with the same fonts: legible on screen at 1Γ—, a third of the size in the 3Γ— PDF. Give it the layout size and let devicePixelRatio do the scaling.
  • Never let a renderer measure. It is tempting to fix a slightly long line in render-svg.js with the browser's getComputedTextLength(). Doing so makes the screen and the PDF two layouts again.
  • Header and footer are measured with 888 of 888. A field that could be wider than three digits (a very long company name in the footer) still fits, because it is measured the same way at both ends. A field whose width depends on the page in some other way would need adding to that probe.
  • Zero-width characters are real characters to jsPDF: the editor's zero-width space measured as nothing on screen and printed as ?. Strip them before layout (the server and editor.js both do).
  • for...of, not str.length, for anything per character. Indexing a string by [i] splits characters outside the Basic Multilingual Plane into halves of a surrogate pair, which would measure as two missing glyphs.
  • pdf.js under headless Chrome: when checking a real PDF by rasterising it in a headless browser, pdf.js's worker doesn't run under virtual time. Load pdf.worker.min.js into the page itself.

Next: Part 3: PHP and SQL. This covers the tables, the API, who may open and share a pack, how a design is checked on every save, the block registry, and how each block's data is fetched with the reader's own rights.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally