diff --git a/backend/migrations/20261027170000_consent_pdf.js b/backend/migrations/20261027170000_consent_pdf.js
new file mode 100644
index 0000000..a065f64
--- /dev/null
+++ b/backend/migrations/20261027170000_consent_pdf.js
@@ -0,0 +1,18 @@
+/**
+ * Stored PDF of the PIS/ICF a physician consented to.
+ * @param {import('knex').Knex} knex
+ */
+export async function up(knex) {
+ await knex.schema.alterTable('consents', (table) => {
+ table.string('consent_pdf_key', 512).nullable().after('answers');
+ });
+}
+
+/**
+ * @param {import('knex').Knex} knex
+ */
+export async function down(knex) {
+ await knex.schema.alterTable('consents', (table) => {
+ table.dropColumn('consent_pdf_key');
+ });
+}
diff --git a/backend/src/pdf/consentHtml.js b/backend/src/pdf/consentHtml.js
new file mode 100644
index 0000000..ec87f7b
--- /dev/null
+++ b/backend/src/pdf/consentHtml.js
@@ -0,0 +1,190 @@
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { CONSENT_STATEMENTS } from '../schemas/consent.js';
+import { escapeHtml } from './agreementHtml.js';
+
+const PIS_PATH = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ '../../../files/docs/pis.md',
+);
+
+const pisSource = readFileSync(PIS_PATH, 'utf8');
+
+/** Needs a font with the ₹ sign; Noto Sans or DejaVu Sans on Linux servers. */
+const FONT_STACK = 'Calibri, Carlito, "Noto Sans", "DejaVu Sans", Arial, sans-serif';
+
+/** The PIS section that starts page 2 in the original document. */
+const PAGE_TWO_HEADING = 'What will you be expected to do?';
+
+/**
+ * @param {string} source
+ */
+function documentMeta(source) {
+ const line = source.split('\n').find((item) => item.startsWith('**Document:**')) ?? '';
+ const version = /\*\*Document:\*\*\s*([^|]+)/.exec(line)?.[1]?.trim() || 'PIS and ICF V01';
+ const date = /\*\*Date:\*\*\s*([^|]+)/.exec(line)?.[1]?.trim() || '';
+ return { version, date };
+}
+
+/**
+ * @param {string} text
+ */
+function inline(text) {
+ return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '$1');
+}
+
+/**
+ * Lines ending in two spaces are hard line breaks, as in Markdown.
+ * @param {string[]} lines
+ */
+function paragraph(lines) {
+ return lines
+ .map((line, index) => {
+ const content = inline(line.trimEnd());
+ const last = index === lines.length - 1;
+ return last ? content : `${content}${/ {2}$/.test(line) ? '
' : ' '}`;
+ })
+ .join('');
+}
+
+/**
+ * Render `files/docs/pis.md` the way the original PIS lays it out: the
+ * document line becomes the page header, "Survey Title" runs inline with the
+ * title, and the "Background" label is not shown.
+ * @param {string} source
+ */
+export function pisBodyHtml(source = pisSource) {
+ const blocks = source
+ .split(/\n\s*\n/)
+ .map((block) => block.split('\n').filter((line) => line.trim() !== ''))
+ .filter((lines) => lines.length > 0);
+ /** @type {string[]} */
+ const html = [];
+ let surveyTitle = false;
+
+ for (const lines of blocks) {
+ const first = lines[0].trim();
+
+ if (first.startsWith('# ') || first.startsWith('**Document:**')) {
+ continue;
+ }
+
+ if (first.startsWith('## ')) {
+ const heading = first.slice(3).trim();
+
+ if (heading === 'Survey Title') {
+ surveyTitle = true;
+ } else if (heading !== 'Background') {
+ const pageBreak = heading === PAGE_TWO_HEADING ? ' class="page-break"' : '';
+ html.push(`
Survey Title: ${paragraph(lines)}
`); + surveyTitle = false; + continue; + } + + html.push(`${paragraph(lines)}
`); + } + + return html.join('\n'); +} + +/** + * @param {unknown} iso + */ +function displayDate(iso) { + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso ?? '')); + return match ? `${match[3]}/${match[2]}/${match[1]}` : ''; +} + +/** + * @param {{ participantName: string, consentDate: string, answers: RecordONLINE
INFORMED CONSENT FORM (ICF)
I have read and understood the information provided above regarding this survey. I have had the opportunity to ask questions and have received satisfactory answers to questions.
+Participant Name: ${escapeHtml(consent.participantName)}
+Date: ${escapeHtml(displayDate(consent.consentDate))}
+ONLINE
PARTICIPANT INFORMATION SHEET (PIS)
No signature
)} +
+ {application.consent.participantName} · consent date{' '}
+ {application.consent.consentDate}
+
+ Submitted at {application.consent.submittedAt}
+
+ {consentError} +
+ ) : null} + > + ) : ( +Consent not submitted
+ )} {canDecide ? (