# HTML + PDF render pipeline

Image-only A4 portrait PDF, rendered from a single HTML file via Playwright + img2pdf + qpdf. Constraints inlined in the parent `SKILL.md` Stage 5.

## File layout per artefact

```
html/<artefact>/
  index.html        # source of truth
  render-pdf.mjs    # Playwright pipeline
  <artefact>.pdf    # rendered output
  logo-light.png    # for dark cover/backpage
  logo-dark.png     # for light content pages (optional)
  README.md         # render + verify commands, page list
```

## HTML scaffold (key blocks)

```html
<!DOCTYPE html>
<html lang="en-GB">
<head>
  <meta charset="UTF-8">
  <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Inter:wght@400;500;600;700&family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
  <style>
    /* Tier 1 + Tier 2 design tokens from project's design-tokens.md */
    :root {
      --teal-900: #0E1418;
      --teal-950: #06080A;
      --gold-500: #A4884C;
      --gold-300: #C9B07A;
      --gold-700: #7A6235;
      --paper-0:  #FFFFFF;
      --paper-25: #FBFAF6;
      --paper-100: #ECF1F2;
      --slate-400: #8A8E96;
      --slate-600: #5C6470;
      --rule-fine: #E2D5BD;
      --serif: 'Cormorant Garamond', serif;
      --body-serif: 'Lora', Georgia, serif;
      --sans: 'Inter', sans-serif;
      --surface-page: var(--paper-25);
      --surface-deep: var(--teal-950);
      --text-primary: var(--teal-900);
      --text-on-deep: var(--paper-25);
      --text-accent: var(--gold-700);
      --text-accent-on-deep: var(--gold-300);
    }

    @page { size: A4 portrait; margin: 18mm 16mm 20mm; }
    @page :first { margin: 0; }
    @page backpage-full { margin: 0; }

    body {
      margin: 0;
      background: var(--surface-page);
      color: var(--text-primary);
      font-family: var(--body-serif);
      font-size: 11pt;
      line-height: 1.55;
      counter-reset: pagenum;
    }

    .page {
      counter-increment: pagenum;
      position: relative;
    }
    .page:not(.page--cover):not(.page--backpage)::after {
      content: counter(pagenum);
      position: absolute;
      bottom: 10mm;
      right: 16mm;
      font-family: var(--sans);
      font-size: 9pt;
      letter-spacing: 0.28em;
      color: var(--slate-400);
    }
    .page:not(.page--cover):not(.page--backpage)::before {
      content: "REAL AGENT NETWORK LIMITED · BUSINESS PLAN · v1.0";
      position: absolute;
      bottom: 10mm;
      left: 16mm;
      font-family: var(--sans);
      font-size: 7pt;
      letter-spacing: 0.24em;
      color: var(--slate-400);
    }

    @media screen {
      body { background: #FFFFFF; padding: 24px; }
      .page {
        width: 210mm; min-height: 297mm;
        margin: 20px auto; padding: 18mm 16mm 20mm;
        background: var(--surface-page);
        box-shadow: 0 4px 28px rgba(0,0,0,0.14);
      }
      .page--cover, .page--backpage { padding: 0; overflow: hidden; }
    }

    @media print {
      body { background: var(--surface-page); padding: 0; }
      .page { page-break-after: always; padding: 0; }
      .page--cover, .page--backpage {
        margin: 0; width: auto; box-shadow: none;
        height: 100vh; position: relative; overflow: hidden;
        -webkit-print-color-adjust: exact;
        print-color-adjust: exact;
      }
      .page--backpage { page-break-before: always; page: backpage-full; }
      .download-btn { display: none !important; }
    }

    .download-btn {
      position: fixed; top: 20px; right: 20px; z-index: 100;
      font-family: var(--sans); font-size: 9pt; letter-spacing: 0.28em;
      text-transform: uppercase; color: var(--paper-0);
      background: var(--teal-900); padding: 11px 18px 12px;
      border-radius: 9999px; text-decoration: none;
      box-shadow: 0 4px 16px rgba(14, 20, 24, 0.18);
    }
    .download-btn:hover { background: var(--gold-700); }
  </style>
</head>
<body>
  <a class="download-btn" href="<artefact>.pdf" download>Download PDF</a>

  <section class="page page--cover" data-screen-label="cover"> ... </section>
  <section class="page" data-screen-label="exec-summary"> ... </section>
  <!-- ... one <section class="page"> per A4 page ... -->
  <section class="page page--backpage" data-screen-label="backpage"> ... </section>
</body>
</html>
```

## render-pdf.mjs (canonical)

```javascript
#!/usr/bin/env node
import { chromium } from 'playwright';
import { execFileSync } from 'node:child_process';
import { mkdirSync, rmSync, existsSync } from 'node:fs';
import { dirname, basename, join, resolve, extname } from 'node:path';
import { pathToFileURL } from 'node:url';

const [, , htmlInput, pdfOutput] = process.argv;
if (!htmlInput || !pdfOutput) {
  console.error('Usage: render-pdf.mjs <input.html> <output.pdf>');
  process.exit(64);
}

const htmlPath = resolve(htmlInput);
const pdfPath  = resolve(pdfOutput);
const tmpDir = resolve(dirname(pdfPath), `.tmp-${basename(pdfPath, extname(pdfPath))}`);
rmSync(tmpDir, { recursive: true, force: true });
mkdirSync(tmpDir, { recursive: true });

const VIEWPORT_W = 794;        // A4 portrait at 96 dpi
const VIEWPORT_H = 1123;
const SCALE = 3.125;           // → 300 dpi PNGs
const A4_HEIGHT_PX_SLACK = VIEWPORT_H + 2;

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext({
    viewport: { width: VIEWPORT_W, height: VIEWPORT_H },
    deviceScaleFactor: SCALE,
  });
  const page = await context.newPage();

  await page.goto(pathToFileURL(htmlPath).toString(), { waitUntil: 'networkidle' });
  await page.emulateMedia({ media: 'print' });
  await page.evaluate(() => document.fonts ? document.fonts.ready : Promise.resolve());
  await page.evaluate(() => { document.body.style.padding = '0'; });

  const sections = await page.$$('section.page');
  if (sections.length === 0) {
    console.error('No <section class="page"> elements found.');
    process.exit(65);
  }

  const pngFiles = [];
  let overflow = false;

  for (let i = 0; i < sections.length; i++) {
    const section = sections[i];
    const label = await section.getAttribute('data-screen-label') || `page${i + 1}`;
    const scrollHeight = await section.evaluate(el => el.scrollHeight);
    if (scrollHeight > A4_HEIGHT_PX_SLACK) {
      console.error(`[OVERFLOW] page ${i + 1} (${label}): ${scrollHeight}px > A4 + 2px slack`);
      overflow = true;
    }
    const outPath = join(tmpDir, `page-${String(i + 1).padStart(2, '0')}-${label}.png`);
    await section.scrollIntoViewIfNeeded();
    await section.screenshot({ path: outPath, type: 'png' });
    pngFiles.push(outPath);
  }

  await browser.close();
  if (overflow) {
    console.error('Overflow detected. Aborting.');
    process.exit(4);
  }

  const preLinearize = join(tmpDir, 'pre-linearize.pdf');
  execFileSync('img2pdf', ['--pagesize', '210mmx297mm', '--output', preLinearize, ...pngFiles], { stdio: 'inherit' });
  execFileSync('qpdf', ['--linearize', '--object-streams=disable', preLinearize, pdfPath], { stdio: 'inherit' });

  console.log(`✓ PDF written: ${pdfPath}`);
})();
```

## Verification

After every render:

```sh
qpdf --check <artefact>.pdf                           # → "No syntax or stream encoding errors found"
pdfinfo  <artefact>.pdf | grep -E "Pages|Optimized"   # → confirms page count + linearization
```

Then **read every page visually**. `pdfinfo` reporting "25 pages, A4" is not verification.

## Dependencies

```sh
npm i -D playwright && npx playwright install chromium
brew install img2pdf qpdf
```

## Hard rules

- One `<section class="page">` per A4 page. Renderer aborts (exit 4) on overflow.
- `execFileSync` (never `execSync` with template strings).
- Cover and backpage are full-bleed dark teal (`--surface-deep`); skip page numbers and running footer via `:not(.page--cover):not(.page--backpage)` selectors on the page-counter pseudo-elements.
- Even page count for booklet binding (multiple of 4 ideal).
- Logo: copy the white-mono icon variant as `logo-light.png` for dark cover/backpage; copy the dark icon variant as `logo-dark.png` if used on light content pages.
