Skip to content

PDF Writer Developer Guide

Ed Mozley edited this page Nov 7, 2026 · 1 revision

PDF writer - Developer Guide

How FreeITSM makes every PDF: one file, assets/js/pdf-writer.js (window.FitsmPdf), built on PDFKit with bundled Noto fonts. Read this before adding a PDF export or changing one.


1. The shape of it

an export (knowledge.js, form-pdf.js, report-packs/render-pdf.js ...)
        β”‚  doc.text(), doc.rect(), doc.autoTable(), doc.save()   - jsPDF-shaped calls
        β–Ό
assets/js/pdf-writer.js     window.FitsmPdf
        β”‚  splits text into runs, picks a font for each, converts units, draws tables
        β–Ό
assets/js/vendor/pdfkit/pdfkit.bundle.js     PDFKit 0.20.2 (+ fontkit: shaping)
        β”‚
assets/fonts/noto-*         Noto Sans / Serif / Sans Mono, Noto Sans <Script>

The writer looks like jsPDF on purpose. The exports were written against jsPDF; with a jsPDF-shaped layer each needed a few lines changed, not a rewrite. And if the library ever changes again, only this file changes - no export has to know.

2. Files

File What
assets/js/pdf-writer.js The writer. A plain script - pages load it with <script src=".../assets/js/pdf-writer.js?v=1">
assets/js/vendor/pdfkit/pdfkit.bundle.js PDFKit and its dependencies as one ES module. Loaded by the writer with import() the first time a PDF is made, never by a page
assets/js/vendor/pdfkit/LICENSE.txt Every package inside the bundle, with its licence
assets/fonts/noto-sans/ Noto Sans Regular, Bold, Italic, BoldItalic - Latin, Greek, Cyrillic (also used by asset labels)
assets/fonts/noto-serif/ Noto Serif, four styles - stands in for Times
assets/fonts/noto-sans-mono/ Noto Sans Mono Regular and Bold - stands in for Courier
assets/fonts/noto-sans-scripts/ Regular and Bold of Noto Sans Devanagari, Bengali, Gujarati, Gurmukhi, Kannada, Malayalam, Tamil, Telugu, Arabic, Hebrew, Thai
tests/pdf-writer.php + tests/pdf-writer-probe.html The test (section 9)

3. Using it

// 1. Make a document. ASYNC: the writer loads PDFKit and the fonts first.
//    The second argument is everything the PDF will contain - the writer
//    looks at it to fetch only the fonts it needs (Hindi -> Devanagari...).
const doc = await FitsmPdf.create(
    { unit: 'mm', format: 'a4', orientation: 'portrait' },
    article.title + ' ' + bodyText
);

// 2. Draw - the same calls jsPDF had, in the document's unit, y = BASELINE.
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.setTextColor(30, 30, 30);
const lines = doc.splitTextToSize(article.title, 180);
doc.text(lines, 15, 20);

doc.autoTable({ startY: 40, head: [['Name', 'Value']], body: rows });

// 3. Finish. ASYNC too - await it.
await doc.save('article.pdf');            // a download
const blob = await doc.output('blob');    // or the file, e.g. to attach to an email

Every call

Call Notes
FitsmPdf.create(opts, text) opts: unit (mm default, pt, cm, in, px), format (a3, a4, a5, letter, legal, tabloid, or [w, h] in the unit), orientation (p/portrait, l/landscape). Resolves to a document
FitsmPdf.ready(text) Loads PDFKit and the fonts text needs, without making a document. For code that measures first (Report Packs)
FitsmPdf.measure(str, family, style, size) Width in points, shaped. Synchronous - ready() must have finished
FitsmPdf.isReady() Whether measure() can be called yet
FitsmPdf.supported(text) False when the text holds a script no bundled font covers (Chinese, Japanese, Korean)
FitsmPdf.scriptsIn(text) The scripts text needs, plus the interface language's
doc.setFont(family, style) Family: helvetica/sans, times/serif, courier/mono. Style: normal, bold, italic, bolditalic
doc.setFontSize(pt) Β· setTextColor Β· setDrawColor Β· setFillColor Colours as (r, g, b), (grey), '#rrggbb' or [r, g, b]
doc.setLineWidth(w) Β· setLineDashPattern([dash, gap], phase) In the document's unit; [] turns dashes off
doc.text(textOrLines, x, y, { align }) y is the baseline. An array is drawn line under line (1.15 x font size apart). align: 'center'/'right' makes x the centre or the right edge
doc.splitTextToSize(text, width) Wraps at spaces with the real font; a word wider than the line breaks between whole characters (never inside a joined letter)
doc.getTextWidth(str) Β· getStringUnitWidth(str) Width in the unit; width divided by font size
doc.rect Β· roundedRect Β· circle Β· line Style 'S' (outline, default), 'F' (fill), 'FD' (both)
doc.addImage(src, format, x, y, w, h, alias) src: a data URL, an <img> or a <canvas>. The same image (same data, or the same alias) is stored once however often it is placed
doc.addPage(format?, orientation?) Β· setPage(n) Β· getNumberOfPages() Every page stays open until the end, so footers can be stamped afterwards
doc.autoTable(options) Β· doc.lastAutoTable.finalY See section 6
doc.setProperties({ title, subject, author }) The PDF's document properties
doc.save(filename) Β· doc.output(type) Async. output: 'blob' (default), 'arraybuffer', 'datauristring'
doc.internal.pageSize.getWidth() / getHeight() In the unit
doc.pk The PDFKit document underneath, for something the writer does not offer - prefer adding it to the writer

The two differences from jsPDF that matter

  1. create(), save() and output() are async. Forgetting await on output('blob') hands on a Promise, not a file.
  2. Hand create() the text. Without it, text in Hindi, Tamil and so on falls back to the main font and prints as empty boxes (with a console warning). The interface language's script is always loaded, so the app's own words (footers, labels) are covered.

4. How text gets the right font

A font only holds shapes for some scripts - Noto Sans has Latin, Greek and Cyrillic, but not Devanagari. So the writer splits every string into runs, each drawn in the font that has its letters (runs() in the writer):

"Status: ΰ€•ΰ₯ΰ€·ΰ€€ΰ₯ΰ€°ΰ€Ώΰ€― ΰ€•ΰ€Ώ ΰ€•ΰ€Ώΰ€€ΰ€Ύΰ€¬ΰ₯€"
   β””β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  Noto Sans  Noto Sans Devanagari
  1. Letters take their script's font: a code point in the Devanagari range uses deva-normal, one in the Tamil range taml-normal, everything else (Latin, Greek, Cyrillic) the main font for the family and style.
  2. Neutral characters - spaces, punctuation, digits, joiners, the danda ΰ₯€ - take the script of the letters around them: both sides if they agree, or the only side there is (a ΰ₯€ at the end of a sentence). That keeps a whole Hindi phrase in one run, so fontkit shapes it as a whole.
  3. But only a font that has the character. If the chosen font lacks it (Noto Sans Bengali has no :), the main font is tried, then the scripts on either side. Without this, punctuation printed as empty boxes.
  4. Symbols the main font lacks come from another bundled font that has them. Noto Sans has no β—¦ (Report Packs' nested list bullet), β—‹, β– , β–ͺ or β†’; Noto Sans Mono has all of them, so it is tried first, then Sans, then Serif. Nothing bundled has βœ“, which prints as a box.

Each run is then drawn with PDFKit, which runs it through fontkit's shaper: ΰ€• + ΰ₯ + ΰ€· becomes ΰ€•ΰ₯ΰ€·, ΰ€Ώ moves in front of its letter, marks stack. Runs are placed one after another using their measured widths.

Styles: script fonts come in Regular and Bold only, so italic falls back to regular and bold-italic to bold. Mono has no italic.

Fonts are fetched lazily. The ten main font files (Sans, Serif, Mono) load the first time a PDF is made; a script's two files load only when the text passed to create()/ready() contains that script. All files are cached for the life of the page, so the second PDF is instant.

5. Measuring

measure() and getTextWidth() ask PDFKit for the width of each run after shaping and add the runs up. That matters for wrapping (splitTextToSize), alignment and - above all - Report Packs, whose layout engine places every line itself:

TRAP: never add up single characters. It was right for Helvetica and is wrong for any script whose letters join: ΰ€•ΰ₯ΰ€· is narrower than ΰ€• + ΰ₯ + ΰ€· (the test checks it). Report Packs' engine used to sum per-character widths; it now measures whole strings through FitsmPdf.measure(), cached per font, style and text.

6. Tables - autoTable()

The subset of jsPDF-AutoTable's options FreeITSM's exports use, so their table code did not change:

Option
startY Where the table starts
head, body Arrays of rows of cell text
margin { left, right, top, bottom } Default 40 pt each, as AutoTable
styles fontSize, cellPadding, fontStyle, textColor, fillColor, halign
headStyles, alternateRowStyles Header row; every other body row's fillColor
columnStyles { index: { cellWidth, fontStyle, halign, textColor, fillColor } } A fixed cellWidth keeps that width
didParseCell(data) data.section ('head'/'body'), data.row.index, data.column.index, data.cell.raw, data.cell.styles - change the styles to colour a cell (Morning Checks colours each status)

How it lays out: columns without a fixed width share the rest by content (natural widths, never below the longest word), cells wrap with the real font, rows that would cross the bottom margin start a new page, and the header repeats on every page. doc.lastAutoTable.finalY is where the table ended. Not supported: row spans, column spans, borders, didDrawCell.

7. Adding a PDF export

  1. Load the writer on the page: <script src="../assets/js/pdf-writer.js?v=1"></script>. Nothing else - never load PDFKit directly.
  2. const doc = await FitsmPdf.create(opts, everythingThePdfWillSay).
  3. Draw with the calls in section 3; await doc.save(name).
  4. Logos: the organisation's own - brandingCustomLogoUrl() in PHP gives its URL or null (never FreeITSM's own logo on someone's document); brandingLogoUrl() falls back to FreeITSM's.
  5. Look at the result with Polish and Hindi in it before calling it done.

8. Which exports use it

Export File Notes
Knowledge article assets/js/knowledge.js buildArticlePdf() Real tables; logo from window.KB_PDF_LOGO (brandingCustomLogoUrl())
Report Packs assets/js/report-packs/render-pdf.js, engine.js, runtime.js, designer.js Engine measures through FitsmPdf.measure(); rt.prepareFonts() loads fonts before layout; the designer re-lays out when a new script's font arrives
Form submissions assets/js/form-pdf.js Table questions through autoTable
Morning Checks morning-checks/index.php saveToPDF() didParseCell colours the status column
Data tables (Assets) assets/js/data-table.js exportPDF()
Asset labels assets/js/asset-labels/render.js Text stays a 600 dpi picture by design (uploaded label fonts, preview = print)
Change Management assets/js/change-management.js changePdfDocument() Pictures of the page (html2canvas)
Network Mapper, Process Mapper assets/js/network-mapper.js, process-mapper.js A picture of the diagram
Organisation chart people/org.php A picture of the chart

9. Testing

php tests/pdf-writer.php

Copies tests/pdf-writer-probe.html into the app root under a random name, opens it in headless Chrome or Edge through the local web server (FREEITSM_BASE_URL, default http://localhost/freeitsm-app), reads its checks and deletes the copy. 20 checks: scripts detected; a Hindi sentence is one run; a missing colon falls back; a ΰ₯€ at the end of a sentence stays in Devanagari; a β—¦ comes from Noto Sans Mono; bold, italic and mono map correctly; ΰ€•ΰ₯ΰ€· measures narrower than its parts; wrapping fits; drawing calls; a table over pages with a coloured column; page numbers; the file is a PDF; an image placed twice is stored once; a sensible size; and nothing fetched from outside the server (air-gap).

It saves the PDF it made and prints the path - open it and look. That is the real test of text rendering.

tests/security-findings/run.php (section F6) checks jsPDF is not bundled and PDFKit is the version VENDOR.md records.

10. Traps

  • await create(), save() and output().
  • Pass the text to create(), or other scripts print as boxes.
  • Never add up character widths - measure whole strings.
  • _font.font is a PDFKit internal - the writer uses it to ask whether a font has a character. A PDFKit update could rename it; the "colon" and "ΰ₯€" checks in the test would then fail.
  • Listen for PDFKit's error, not only end - the writer does: a failure while finishing emits error and never end, and save() would wait forever.
  • Headless Chrome's --virtual-time-budget and big transparent PNGs - testing only. PDFKit decodes them in a background worker, which runs in real time while virtual time races ahead, so a timeout in the test page fires before the work is done. In a real browser it is fine. Use real time for such tests.
  • Copying Indian-script text out of a PDF can come back in a different order; what prints is right.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally