PDF Writer Developer Guide - edmozley/freeitsm GitHub Wiki
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.
- Why PDFKit and not jsPDF: PDF exports moved to PDFKit
- What a module, a bundle and vendoring are: JavaScript modules and vendoring explained
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.
| 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) |
// 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| 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 |
-
create(),save()andoutput()are async. Forgettingawaitonoutput('blob')hands on a Promise, not a file. -
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.
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
-
Letters take their script's font: a code point in the Devanagari range uses
deva-normal, one in the Tamil rangetaml-normal, everything else (Latin, Greek, Cyrillic) the main font for the family and style. -
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. -
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. -
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.
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 throughFitsmPdf.measure(), cached per font, style and text.
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.
- Load the writer on the page:
<script src="../assets/js/pdf-writer.js?v=1"></script>. Nothing else - never load PDFKit directly. -
const doc = await FitsmPdf.create(opts, everythingThePdfWillSay). - Draw with the calls in section 3;
await doc.save(name). - Logos: the organisation's own -
brandingCustomLogoUrl()in PHP gives its URL ornull(never FreeITSM's own logo on someone's document);brandingLogoUrl()falls back to FreeITSM's. - Look at the result with Polish and Hindi in it before calling it done.
| 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 |
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.
awaitcreate(),save()andoutput().-
Pass the text to
create(), or other scripts print as boxes. - Never add up character widths - measure whole strings.
-
_font.fontis 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 onlyend- the writer does: a failure while finishing emitserrorand neverend, andsave()would wait forever. -
Headless Chrome's
--virtual-time-budgetand 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.