-
-
Notifications
You must be signed in to change notification settings - Fork 29
Report Packs Internals 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).
- The one idea
- Units and constants
- Measuring text with the PDF's own fonts
- Rich text layout
- Primitives: the engine's output
- Measuring blocks
- Splitters: blocks that run onto the next page
- Pagination
- Rendering to SVG
- Rendering to PDF
- Charts: one layout, two resolutions
- The runtime
- Extending the engine
- Traps
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.
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 wideOn 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.
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
Maplookups after the first sight of each character.for...ofiterates by code point, so a character outside the Basic Multilingual Plane is one character. -
No kerning on either side.
getStringUnitWidthsums 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.
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.
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.
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
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.
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.
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.
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).
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.
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.
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) |
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 boxheightmm 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.
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: 3andw: 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
_detailgets 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, ordata.emptywhen 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.
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:
-
tableSplittertakes rows whileh + 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. -
textSplittertakes whole lines of a laid-out rich-text document. It rebases each part's lineys to 0, carries the logo images that fall inside the slice, and hands back arichPart, 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.
paginate(design, dataMap, ctx) in five steps.
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.
const measured = design.blocks.map(b => measureBlock(b, dataMap[b.id], spanW(blockSpan(b)), theme, ctx));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.
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.
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.
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.
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
}-
textLengthpins 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 whateverFONT_CSSresolves 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. -
textContentonly. 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.jsuses. CSStext-decorationwould place them differently from the PDF. -
geometricPrecisiontells 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).
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, andbaseline: '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 ifris set, dashed ifdash, 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.jsexplains 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()).
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('|');RPRuntime.create(apiBase, logoUrl) holds everything a pack needs besides its design. One instance is shared by the designer and export.
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, thenpump()runs up to 4 fetches at once againstblock_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'sscheduleRender(part 1), except quiet jobs: the toolbox preview'sfetchBlock()shares the cache but doesn't redraw the pages. -
dataMap()gives the engine{blockId: entry}, with a still-pending entry asundefined, which the engine draws as a loading placeholder. -
refresh()(the Data tab's Refresh button) clears both caches and refetches everything.
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.
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.
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:
- a
measureX()inengine.js, returning primitives (or a chart box) and, if it can run across pages, a splitter implementingstart/take - a
caseinmeasureBlock() - 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).
-
Chart.js with
responsive: falsereadscanvas.widthas its CSS size. Hand it the backing size (wPx Γ scale) and every chart lays outscaletimes 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 letdevicePixelRatiodo the scaling. -
Never let a renderer measure. It is tempting to fix a slightly long line in
render-svg.jswith the browser'sgetComputedTextLength(). 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 andeditor.jsboth do). -
for...of, notstr.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.jsinto 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 β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96