Skip to content

Submission PDF Export Developer Guide

Ed Mozley edited this page Sep 19, 2026 · 1 revision

Submission PDF Export β€” Developer Guide

Shipped in 2.2.0, asked for by a user who needed an audit trail: "the ability to download forms as PDFs and printing to maintain an audit trail." A form you can file, not just a row you can read.

What a record contains

The operator's own logo, the form title, the submission number, who submitted it and when, the approval trail, and every answer β€” including answers to questions since removed from the form.

The approval block is the point of the document. A list of answers with no decision on it is a form, not a record.

Generated client-side with jsPDF, vendored at assets/js/vendor/jspdf.umd.min.js. Nothing is uploaded and no server-side PDF tooling is required β€” see Offline and Air-Gapped Installs.

One implementation, three entry points

assets/js/form-pdf.js is the only place that decides what a record looks like. It is used by:

Where How
a submission's detail panel Export as PDF
a row in the submissions list the download icon, without opening anything
a collection single, bundle, or separate, across several forms

πŸ”‘ Why it is a shared module. All of it began inside forms/submissions.php, which shows the submissions of one form. A collection shows several and needs identical documents; a second copy would have drifted the first time a field type changed. forms/submissions.php keeps the function names as one-line delegates so its call sites are unchanged.

⚠️ The one signature difference. Everything takes an explicit form ({ title, fields }) instead of closing over a page-global formData. On a collection page every row may belong to a different form, so "the form" can no longer be a property of the page.

The logo is the whole cost of a bundle

doc.addImage(logo, 'PNG', margin, y, w, h, 'brandlogo', 'FAST');
//                                        ^alias      ^deflate

Both parts are load-bearing. An uncompressed branding PNG took a one-page document to 1.45 MB. The alias is what makes bundles affordable β€” without it jsPDF re-embeds the same picture per record. Measured: 59 KB for one submission, 68 KB for four, about 3 KB per extra record instead of 56 KB. loadBrandLogo() also caches the Image promise so eighty records do not fetch it eighty times.

A logo that fails to load resolves to null and is skipped. A missing logo must never cost somebody their record.

Filenames

Software Request - Ed Mozley - 19.09.2026.pdf

The date uses the reader's own preference, so a US install files it as 09.19.2026 without the export knowing anything about locales.

  • πŸ”‘ fmtDate, not fmtNaiveDate. submitted_date is a real instant stamped by the server and must convert into the viewer's zone β€” the opposite of a rota day. Both helpers exist and picking wrongly is silently wrong for everyone outside UTC. See Timezones and Time Handling.
  • ⚠️ A date template is not a filename. Three of the nine shipped templates contain a slash, which no filesystem accepts, so separators become dots rather than being stripped. A trailing dot or space is trimmed because Windows refuses those, and the whole name is capped at 180 characters to leave room for the extension and the (1) a browser adds on a collision.
  • Verified against all nine date preferences plus a submitter name made entirely of illegal characters.

⚠️ A field answer of type datetime goes the other way β€” FormLogic.formatDateValue(), naive, never shifted. Two kinds of date in one document.

Several at once

Neither option needs a zip library.

  • Bundle β€” one document, addPage() between records.
  • Separate β€” a run of doc.save() with a 150 ms beat between them. Fired back to back, browsers drop all but the first few: the downloads are queued by the page, not by the click. A confirm appears above ten files, because a browser asks once before accepting a run of them.

Traps this feature actually hit

  • πŸ”΄ brandingLogoUrl() undefined β€” includes/branding.php was never required. A PHP fatal is served as HTTP 200 with the page truncated mid-<script>, so every bit of JavaScript died: empty table, dead button, no error anywhere.
  • πŸ”΄ The selection reset belongs at the TOP of the redraw. It was at the foot, and the redraw returns early when the list is empty β€” so filtering down to nothing left the bar offering to export rows that had gone.
  • ⚠️ The selection bar is shown with a class, not the hidden attribute. An element carrying its own display rule ignores hidden entirely.
  • ⚠️ Measuring any of this in a headless browser: the modal and the buttons animate, and getBoundingClientRect includes transforms while getComputedStyle returns interpolated values mid-transition. Disable transitions before measuring; a timer will lie on a slower run.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally