Report Packs Internals 2 The Layout Engine - edmozley/freeitsm GitHub Wiki

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 font in the PDF 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)   (FitsmPdf/PDFKit: 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 (beyond a scratch canvas for measuring before the PDF fonts load, section 3), no state beyond two caches (text 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 is printed by FreeITSM's shared PDF writer (assets/js/pdf-writer.js, window.FitsmPdf, on PDFKit) in bundled Noto fonts, embedded in the file. The pack's font names helvetica, times and courier still work: the writer maps them to Noto Sans, Noto Serif and Noto Sans Mono, and splits text into runs so each piece is set in the font that has its letters (the Noto script fonts in assets/fonts/noto-sans-scripts for Devanagari, Tamil, Arabic and the rest, fetched only when the text needs them). The engine measures with those fonts, through the writer, never with the browser's:

const widthCache = new Map();         // "font|style|text" -> width in points at size 1
let probe = null;                     // a canvas, for widths before the writer is ready

function textWidth(str, st) {
    const style = styleKey(st.b, st.i);                  // normal | bold | italic | bolditalic
    const W = window.FitsmPdf;
    if (W && W.isReady()) {
        const key = st.font + '|' + style + '|' + str;
        let u = widthCache.get(key);
        if (u === undefined) {
            u = W.measure(str, st.font, style, 1);       // the whole string, shaped
            if (widthCache.size > 20000) widthCache.clear();
            widthCache.set(key, u);
        }
        return u * st.size * PT;                         // width scales with size
    }
    // Not ready yet: the browser's measure, replaced on the next layout
    probe = probe || document.createElement('canvas').getContext('2d');
    probe.font = (st.i ? 'italic ' : '') + (st.b ? 'bold ' : '') + st.size + 'pt ' + (FONT_CSS[st.font] || FONT_CSS.helvetica);
    return probe.measureText(str).width * 25.4 / 96;
}
  • Whole strings, after shaping. FitsmPdf.measure() asks PDFKit's shaping engine (fontkit) for the width of the string as it will print: joined letters, reordered vowel signs and all. In Hindi ΰ€•ΰ₯ΰ€· is narrower than ΰ€• + ΰ₯ + ΰ€·, so adding up single characters, as the engine did when the PDF used jsPDF's standard fonts, is wrong for any script whose letters join. Never go back to it.
  • Cached per font, style and text at size 1, so a word seen once is a Map lookup after that. The cache is emptied if it passes 20,000 entries, so a very long pack can't grow it without limit.
  • Before the fonts arrive, the canvas measures. The writer loads PDFKit and the fonts asynchronously. For the first moment on the designer page they aren't there, so widths come from the browser's canvas in the matching family. rt.prepareFonts() (in runtime.js) waits for FitsmPdf.ready() with all of the pack's text, and the designer lays out again when the fonts arrive, this time with the real widths. Export always calls prepareFonts() before its last layout, so a PDF is never laid out with canvas widths.

What the fonts can't do

Every language FreeITSM ships prints. The bundled fonts have no Chinese, Japanese or Korean characters, which would print as empty boxes, so hasUnsupported() now flags only text no bundled font covers:

function hasUnsupported(str) {
    return window.FitsmPdf ? !window.FitsmPdf.supported(str) : false;
}

Arabic and Hebrew letters join, but punctuation and spacing in a line that mixes right-to-left and left-to-right text can come out in the wrong order. For the why and how of the writer, see PDF exports moved to PDFKit (#254) and PDF writer - Developer Guide.

The designer shows a warning in its status bar when any pack text fails this check (rt.checkUnsupported()). Until #254 the PDF used the standard WinAnsi fonts, which held Western European text only, and render-pdf.js printed ? for everything else; it now writes the text as it is (section 10).


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 (Noto Sans has no β—¦ - the PDF writer takes it from Noto Sans Mono);
                  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 between whole graphemes
}

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 rather than overflowing - between whole graphemes (the characters people see, from Intl.Segmenter, falling back to code points where a browser has none), never inside a joined letter, and each growing piece is measured as a whole string. 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. 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 the Noto font in the PDF, 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 the shared PDF writer (window.FitsmPdf, on PDFKit) in unit: 'mm'. It is async: the writer loads PDFKit and the fonts the text needs before it hands back a document.

const doc = await window.FitsmPdf.create({
    unit: 'mm', format: [G.pw, G.ph], orientation: G.pw > G.ph ? 'landscape' : 'portrait',
}, textOf(layout));                       // every string the PDF will hold, so the right fonts are fetched
doc.setProperties({ title: opts.title || 'Report', creator: 'FreeITSM' });
  • Text is real PDF text in the embedded Noto fonts: searchable, selectable, copyable and sharp at any zoom. The same styleKey() maps bold and italic to the writer's font styles, and each run is drawn at the baseline the engine measured.
  • safe() is kept for callers but returns the text unchanged. Until #254 it turned every character outside WinAnsi into ?; the writer's fonts print it now.
  • 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). The 'FAST' argument is left over from jsPDF - the PDF writer ignores it, because PDFKit always stores pictures compressed.

Export is rt.exportPdf(): load the context and anything not yet fetched, wait for the PDF fonts (rt.prepareFonts()), lay out once more, await build, then await doc.save() (async in the writer) 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 the PDF writer can't embed (PDFKit takes PNG and JPEG). 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 the PDF: with the old jsPDF standard fonts 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).
  • Never measure a character at a time. Measure the whole string through textWidth(): a joined letter in Hindi or Tamil is not the sum of its parts. Where text must be cut (a word longer than the line), cut it between graphemes (graphemes(), Intl.Segmenter), never by str[i], which splits surrogate pairs, or by code point, which splits a letter from its vowel sign.
  • 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.

⚠️ **GitHub.com Fallback** ⚠️