diff --git a/scripts/build-sdlc-delivery.sh b/scripts/build-sdlc-delivery.sh
new file mode 100755
index 0000000..b1ad39d
--- /dev/null
+++ b/scripts/build-sdlc-delivery.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
+TOOL_DIR="$SCRIPT_DIR/sdlc-delivery"
+SOURCE_DIR="$ROOT_DIR/sdlc"
+DELIVERY_ROOT="$ROOT_DIR/sdlc-delivery"
+STAGING_DIR="$ROOT_DIR/.sdlc-delivery.staging"
+
+if [[ ! -d "$TOOL_DIR/node_modules/playwright" ]]; then
+ echo "Installing the local PDF renderer..."
+ npm install --prefix "$TOOL_DIR"
+ npx --prefix "$TOOL_DIR" playwright install chromium
+fi
+
+rm -rf "$STAGING_DIR"
+mkdir -p "$STAGING_DIR"
+
+while IFS= read -r -d '' source; do
+ relative="${source#"$ROOT_DIR/sdlc/"}"
+ destination="$STAGING_DIR/${relative%.md}.pdf"
+ echo "Rendering ${relative%.md}.pdf"
+ node "$TOOL_DIR/render-sdlc.mjs" "$source" "$destination"
+done < <(find "$SOURCE_DIR" -type f -name '*.md' -print0 | sort -z)
+
+echo "Verifying staged delivery package..."
+"$SCRIPT_DIR/verify-sdlc-delivery.sh" "$STAGING_DIR"
+
+rm -rf "$DELIVERY_ROOT"
+mv "$STAGING_DIR" "$DELIVERY_ROOT"
+echo "Delivery and verification passed: $DELIVERY_ROOT"
diff --git a/scripts/sdlc-delivery/assets/LeelawadeeUI.ttf b/scripts/sdlc-delivery/assets/LeelawadeeUI.ttf
new file mode 100644
index 0000000..ae5a126
Binary files /dev/null and b/scripts/sdlc-delivery/assets/LeelawadeeUI.ttf differ
diff --git a/scripts/sdlc-delivery/assets/document-control.html b/scripts/sdlc-delivery/assets/document-control.html
new file mode 100644
index 0000000..8f8a5f6
--- /dev/null
+++ b/scripts/sdlc-delivery/assets/document-control.html
@@ -0,0 +1,7 @@
+
+
+ | Document | {{documentType}} | Release | {{release}} |
+ | Project | {{projectName}} | Project code | {{projectCode}} |
+ | Project period | {{projectPeriod}} |
+
+
diff --git a/scripts/sdlc-delivery/assets/footer.html b/scripts/sdlc-delivery/assets/footer.html
new file mode 100644
index 0000000..080ca4c
--- /dev/null
+++ b/scripts/sdlc-delivery/assets/footer.html
@@ -0,0 +1,4 @@
+
+ ISO/IEC 29110-4-1:2018
+ {{projectCode}}
+
diff --git a/scripts/sdlc-delivery/assets/header.html b/scripts/sdlc-delivery/assets/header.html
new file mode 100644
index 0000000..46b96ed
--- /dev/null
+++ b/scripts/sdlc-delivery/assets/header.html
@@ -0,0 +1,8 @@
+
diff --git a/scripts/sdlc-delivery/assets/preview.html b/scripts/sdlc-delivery/assets/preview.html
new file mode 100644
index 0000000..caba88c
--- /dev/null
+++ b/scripts/sdlc-delivery/assets/preview.html
@@ -0,0 +1,37 @@
+
+
+
+
+ SDLC delivery theme preview
+
+
+
+
+
+
+
+
+ | Document | Software Project Plan | Release | 05/01/26 V1.0 Final |
+ | Project | BRN WMS | Project code | 200-WMS-26-001-00 |
+ | Project period | 05/01/26–24/08/26 |
+
+
+
+ Project overview
+ This preview demonstrates the shared A4 header, document-control table, headings, and standard data tables used by every generated delivery PDF.
+
+
+ | Phase | Period | Primary output |
+ | Planning | 12/01/26–18/02/26 | Project Plan |
+
+
+
+
+
diff --git a/scripts/sdlc-delivery/assets/sdlc-delivery.css b/scripts/sdlc-delivery/assets/sdlc-delivery.css
new file mode 100644
index 0000000..521062c
--- /dev/null
+++ b/scripts/sdlc-delivery/assets/sdlc-delivery.css
@@ -0,0 +1,130 @@
+@page {
+ size: A4;
+ margin: 18mm 16mm 28mm;
+}
+
+:root {
+ --brn-blue: #0789c9;
+ --brn-red: #bd0e2d;
+ --brn-ink: #1f2933;
+ --brn-muted: #52606d;
+ --brn-line: #aab7c4;
+ --brn-pale-blue: #eaf6fc;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ color: var(--brn-ink);
+ font-family: "BRN Thai", Arial, sans-serif;
+ font-size: 10.5pt;
+ line-height: 1.45;
+}
+
+body { margin: 0; }
+
+.delivery-document { width: 100%; }
+
+.document-header {
+ align-items: center;
+ border-bottom: 1.5pt solid var(--brn-blue);
+ display: flex;
+ gap: 9mm;
+ margin: 0 0 7mm;
+ min-height: 22mm;
+ padding: 0 0 3mm;
+}
+
+.document-header__logo {
+ flex: 0 0 auto;
+ height: 16mm;
+ object-fit: contain;
+ width: 16mm;
+}
+
+.document-header__company { flex: 1; min-width: 0; }
+
+.document-header__company-name {
+ font-size: 13pt;
+ font-weight: 700;
+ letter-spacing: .03em;
+ margin: 0;
+}
+
+.document-header__address {
+ color: var(--brn-muted);
+ font-size: 7.5pt;
+ line-height: 1.35;
+ margin: 1mm 0 0;
+}
+
+.document-header__title {
+ background: var(--brn-blue);
+ color: #fff;
+ font-size: 15pt;
+ font-weight: 500;
+ flex: 0 1 72mm;
+ overflow-wrap: anywhere;
+ min-width: 52mm;
+ padding: 4mm 6mm;
+ text-align: center;
+}
+
+.document-control {
+ border-collapse: collapse;
+ margin: 0 0 7mm;
+ page-break-inside: avoid;
+ width: 100%;
+}
+
+.document-control th,
+.document-control td {
+ border: .5pt solid var(--brn-line);
+ padding: 2.2mm 3mm;
+ text-align: left;
+ vertical-align: top;
+}
+
+.document-control th {
+ background: var(--brn-pale-blue);
+ font-weight: 700;
+ width: 26%;
+}
+
+h1, h2, h3 { break-after: avoid; color: var(--brn-ink); }
+h1 { font-size: 18pt; margin: 0 0 5mm; }
+h2 { border-left: 4pt solid var(--brn-blue); font-size: 14pt; margin: 9mm 0 4mm; padding-left: 3mm; }
+h3 { color: var(--brn-blue); font-size: 11.5pt; margin: 6mm 0 3mm; }
+
+p { margin: 0 0 3.5mm; }
+
+table:not(.document-control) {
+ border-collapse: collapse;
+ border: .75pt solid #718096;
+ font-size: 9pt;
+ margin: 0 0 5mm;
+ table-layout: fixed;
+ width: 100%;
+}
+
+table:not(.document-control) th,
+table:not(.document-control) td {
+ border: .5pt solid var(--brn-line);
+ overflow-wrap: anywhere;
+ padding: 2mm;
+ vertical-align: top;
+ word-break: normal;
+}
+
+table:not(.document-control) th { background: var(--brn-pale-blue); font-weight: 700; }
+thead { display: table-header-group; }
+tr { break-inside: avoid; }
+
+.approval-block { break-inside: avoid; margin-top: 9mm; }
+.approval-block__person { margin: 5mm 0; min-height: 21mm; }
+.approval-block__line { border-bottom: .5pt solid var(--brn-ink); display: inline-block; min-width: 70mm; }
+.approval-fields {
+ line-height: 1.62;
+ margin: 0 0 8mm;
+ padding: 2.5mm 0 4mm;
+}
diff --git a/scripts/sdlc-delivery/package-lock.json b/scripts/sdlc-delivery/package-lock.json
new file mode 100644
index 0000000..a6cea6f
--- /dev/null
+++ b/scripts/sdlc-delivery/package-lock.json
@@ -0,0 +1,54 @@
+{
+ "name": "brn-wms-sdlc-delivery",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "brn-wms-sdlc-delivery",
+ "dependencies": {
+ "playwright": "1.50.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.50.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz",
+ "integrity": "sha512-+GinGfGTrd2IfX1TA4N2gNmeIksSb+IAe589ZH+FlmpV3MYTx6+buChGIuDLQwrGNCw2lWibqV50fU510N7S+w==",
+ "dependencies": {
+ "playwright-core": "1.50.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.50.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.0.tgz",
+ "integrity": "sha512-CXkSSlr4JaZs2tZHI40DsZUN/NIwgaUPsyLuOAaIZp2CyF2sN5MM5NJsyB188lFSSozFxQ5fPT4qM+f0tH/6wQ==",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/scripts/sdlc-delivery/package.json b/scripts/sdlc-delivery/package.json
new file mode 100644
index 0000000..b426e48
--- /dev/null
+++ b/scripts/sdlc-delivery/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "brn-wms-sdlc-delivery",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "playwright": "1.50.0"
+ }
+}
diff --git a/scripts/sdlc-delivery/render-sdlc.mjs b/scripts/sdlc-delivery/render-sdlc.mjs
new file mode 100644
index 0000000..e1235eb
--- /dev/null
+++ b/scripts/sdlc-delivery/render-sdlc.mjs
@@ -0,0 +1,175 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { chromium } from 'playwright';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(here, '../..');
+const assets = path.join(here, 'assets');
+
+const escapeHtml = (value) => value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>');
+
+const renderInline = (value) => escapeHtml(value)
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/`([^`]+)`/g, '$1');
+
+const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
+
+function isTableLine(line) {
+ return /^\|.*\|\s*$/.test(line.trim());
+}
+
+function tableCells(line) {
+ return line.trim().slice(1, -1).split('|').map((cell) => cell.trim());
+}
+
+function isSeparator(cells) {
+ return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
+}
+
+function renderTable(lines, className = '') {
+ const rows = lines.map(tableCells);
+ const header = rows[0];
+ const data = isSeparator(rows[1] ?? []) ? rows.slice(2) : rows.slice(1);
+ const cells = (tag, row) => row.map((cell) => `<${tag}>${renderInline(cell)}${tag}>`).join('');
+ return `${cells('th', header)}
${data.map((row) => `${cells('td', row)}
`).join('')}
`;
+}
+
+function renderParagraph(lines) {
+ return lines.map((line, index) => {
+ const trimmed = line.trimEnd();
+ const hasHardBreak = /\s{2,}$/.test(line);
+ const isApprovalField = /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(trimmed);
+ const separator = hasHardBreak || isApprovalField ? '
' : (index < lines.length - 1 ? ' ' : '');
+ return `${renderInline(trimmed)}${separator}`;
+ }).join('');
+}
+
+function markdownToHtml(markdown) {
+ const lines = markdown.replaceAll('\r\n', '\n').split('\n');
+ const output = [];
+ let index = 0;
+
+ while (index < lines.length) {
+ const line = lines[index];
+ if (!line.trim()) { index += 1; continue; }
+
+ if (isTableLine(line)) {
+ const table = [];
+ while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
+ output.push(renderTable(table));
+ continue;
+ }
+
+ const heading = line.match(/^(#{1,3})\s+(.+)$/);
+ if (heading) {
+ const level = heading[1].length;
+ output.push(`${renderInline(heading[2])}`);
+ index += 1;
+ continue;
+ }
+
+ if (/^[-*]\s+/.test(line)) {
+ const items = [];
+ while (index < lines.length && /^[-*]\s+/.test(lines[index])) items.push(`${renderInline(lines[index++].replace(/^[-*]\s+/, ''))}`);
+ output.push(``);
+ continue;
+ }
+
+ const paragraph = [];
+ while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index])) paragraph.push(lines[index++]);
+ const approvalClass = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd())) ? ' class="approval-fields"' : '';
+ output.push(`${renderParagraph(paragraph)}
`);
+ }
+
+ return output.join('\n');
+}
+
+function extractDocument(markdown) {
+ const titleMatch = markdown.match(/^#\s+(.+)\n+/m);
+ const title = titleMatch?.[1] ?? 'BRN WMS';
+ const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0));
+ const lines = afterTitle.split('\n');
+ const metadata = {};
+ let bodyStart = 0;
+
+ for (let index = 0; index < lines.length; index += 1) {
+ if (!isTableLine(lines[index])) continue;
+
+ const table = [];
+ while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
+ const candidate = {};
+ const dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1;
+ for (const row of table.slice(dataStart).map(tableCells)) {
+ if (row.length >= 2) candidate[row[0]] = row[1];
+ }
+
+ if (candidate.Document && candidate.Project) {
+ Object.assign(metadata, candidate);
+ bodyStart = index;
+ break;
+ }
+
+ index -= 1;
+ }
+
+ return { title, metadata, body: lines.slice(bodyStart).join('\n') };
+}
+
+async function main() {
+ const [source, destination] = process.argv.slice(2);
+ if (!source || !destination) throw new Error('Usage: render-sdlc.mjs SOURCE.md DESTINATION.pdf');
+
+ const [markdown, css, headerTemplate, footerTemplate, logo, thaiFont] = await Promise.all([
+ fs.readFile(source, 'utf8'),
+ fs.readFile(path.join(assets, 'sdlc-delivery.css'), 'utf8'),
+ fs.readFile(path.join(assets, 'header.html'), 'utf8'),
+ fs.readFile(path.join(assets, 'footer.html'), 'utf8'),
+ fs.readFile(path.join(root, 'app/assets/images/logo.svg')),
+ fs.readFile(path.join(assets, 'LeelawadeeUI.ttf'))
+ ]);
+ const { title, metadata, body } = extractDocument(markdown);
+ const hasWideTable = markdown.split('\n').some((line) => isTableLine(line) && tableCells(line).length >= 8);
+ const isLandscape = metadata.Document === 'Work Schedule' || title === 'Work Schedule' || hasWideTable;
+ const values = {
+ logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
+ documentTitle: title,
+ documentType: metadata.Document ?? title,
+ release: metadata.Release ?? '',
+ projectName: metadata.Project ?? 'BRN WMS',
+ projectCode: metadata['Project code'] ?? '200-WMS-26-001-00',
+ projectPeriod: metadata['Project period'] ?? '05/01/26–24/08/26',
+ pageNumber: '',
+ totalPages: ''
+ };
+ const control = await fs.readFile(path.join(assets, 'document-control.html'), 'utf8');
+ const pdfFooter = applyTemplate(footerTemplate, values);
+ const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${thaiFont.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
+ const pageLayout = isLandscape
+ ? '@page { size: A4 landscape; margin: 18mm 16mm 28mm; }'
+ : '';
+ const html = `${applyTemplate(headerTemplate, values)}${applyTemplate(control, values)}${markdownToHtml(body)}`;
+
+ await fs.mkdir(path.dirname(destination), { recursive: true });
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage();
+ await page.setContent(html, { waitUntil: 'networkidle' });
+ await page.pdf({
+ path: destination,
+ format: 'A4',
+ printBackground: true,
+ preferCSSPageSize: true,
+ displayHeaderFooter: true,
+ headerTemplate: '',
+ footerTemplate: pdfFooter
+ });
+ } finally {
+ await browser.close();
+ }
+}
+
+main().catch((error) => { console.error(error); process.exit(1); });
diff --git a/scripts/sdlc-delivery/verify-content.mjs b/scripts/sdlc-delivery/verify-content.mjs
new file mode 100644
index 0000000..ae16976
--- /dev/null
+++ b/scripts/sdlc-delivery/verify-content.mjs
@@ -0,0 +1,91 @@
+import { execFile } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+
+function isTableLine(line) {
+ return /^\|.*\|\s*$/.test(line.trim());
+}
+
+function tableCells(line) {
+ return line.trim().slice(1, -1).split('|').map((cell) => cell.trim());
+}
+
+function isSeparator(cells) {
+ return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
+}
+
+function extractDocumentContent(markdown) {
+ const titleMatch = markdown.match(/^#\s+.+\n+/m);
+ const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0));
+ const lines = afterTitle.split('\n');
+
+ for (let index = 0; index < lines.length; index += 1) {
+ if (!isTableLine(lines[index])) continue;
+ const table = [];
+ while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
+ const dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1;
+ const fields = Object.fromEntries(table.slice(dataStart).map(tableCells).filter((row) => row.length >= 2));
+ if (fields.Document && fields.Project) return { body: lines.slice(index).join('\n'), fields };
+ index -= 1;
+ }
+
+ return { body: afterTitle, fields: {} };
+}
+
+function words(text) {
+ return new Set((text.normalize('NFC').toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []));
+}
+
+function compact(text) {
+ return text.normalize('NFC').toLocaleLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
+}
+
+async function markdownFiles(directory) {
+ const entries = await fs.readdir(directory, { withFileTypes: true });
+ const results = await Promise.all(entries.map(async (entry) => {
+ const target = path.join(directory, entry.name);
+ if (entry.isDirectory()) return markdownFiles(target);
+ return entry.isFile() && entry.name.endsWith('.md') ? [target] : [];
+ }));
+ return results.flat();
+}
+
+async function pdfText(pdf) {
+ const { stdout } = await execFileAsync('pdftotext', [pdf, '-'], { maxBuffer: 32 * 1024 * 1024 });
+ return stdout;
+}
+
+async function main() {
+ const [sourceRoot, deliveryRoot] = process.argv.slice(2);
+ if (!sourceRoot || !deliveryRoot) throw new Error('Usage: verify-content.mjs SOURCE_DIRECTORY DELIVERY_DIRECTORY');
+
+ const sources = (await markdownFiles(sourceRoot)).sort();
+ const failures = [];
+
+ for (const source of sources) {
+ const relative = path.relative(sourceRoot, source);
+ const pdf = path.join(deliveryRoot, relative.replace(/\.md$/, '.pdf'));
+ const [markdown, extracted] = await Promise.all([fs.readFile(source, 'utf8'), pdfText(pdf)]);
+ const { body, fields } = extractDocumentContent(markdown);
+ const controlText = ['Document', 'Project', 'Project code', 'Project period', 'Release']
+ .map((field) => fields[field] ?? '')
+ .join('\n');
+ const expected = words(`${body}\n${controlText}`);
+ const actual = compact(extracted);
+ const missing = [...expected].filter((word) => !actual.includes(word));
+ if (missing.length) failures.push(`${relative}: missing ${missing.slice(0, 12).join(', ')}${missing.length > 12 ? ', …' : ''}`);
+ }
+
+ if (failures.length) {
+ console.error(`Content coverage failed for ${failures.length}/${sources.length} PDFs:`);
+ failures.forEach((failure) => console.error(`- ${failure}`));
+ process.exit(1);
+ }
+
+ console.log(`Content coverage passed: ${sources.length}/${sources.length} PDFs.`);
+}
+
+main().catch((error) => { console.error(error); process.exit(1); });
diff --git a/scripts/verify-sdlc-delivery.sh b/scripts/verify-sdlc-delivery.sh
new file mode 100755
index 0000000..9c113a9
--- /dev/null
+++ b/scripts/verify-sdlc-delivery.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
+SOURCE_DIR="$ROOT_DIR/sdlc"
+TOOL_DIR="$SCRIPT_DIR/sdlc-delivery"
+PACKAGE_DIR="$(realpath -m "${1:-$ROOT_DIR/sdlc-delivery}")"
+
+[[ -d "$PACKAGE_DIR" ]] || { echo "Delivery package not found: $PACKAGE_DIR" >&2; exit 2; }
+
+expected="$(find "$SOURCE_DIR" -type f -name '*.md' | wc -l | tr -d ' ')"
+produced="$(find "$PACKAGE_DIR" -type f -name '*.pdf' | wc -l | tr -d ' ')"
+[[ "$expected" == "$produced" ]] || { echo "Expected $expected PDFs, found $produced." >&2; exit 1; }
+
+node "$TOOL_DIR/verify-content.mjs" "$SOURCE_DIR" "$PACKAGE_DIR"
+
+format_errors=0
+while IFS= read -r -d '' pdf; do
+ page_size="$(pdfinfo "$pdf" | awk -F': *' '/^Page size/ {print $2}')"
+ case "$page_size" in
+ '594.96 x 841.92 pts (A4)'|'841.92 x 594.96 pts (A4)') ;;
+ *) echo "Non-A4 PDF: $pdf ($page_size)" >&2; format_errors=$((format_errors + 1));;
+ esac
+done < <(find "$PACKAGE_DIR" -type f -name '*.pdf' -print0 | sort -z)
+
+[[ "$format_errors" == 0 ]] || exit 1
+echo "Format verification passed: $produced PDFs use A4 portrait or landscape."