When engineering teams export architecture decision records (ADRs), API runbooks, or security specifications from repository .md files into PDF deliverables for auditors or clients, naive converters routinely break the document structure. Turning Markdown documentation into a PDF reliably means solving five concrete incompatibilities between continuous Markdown text and fixed-geometry paged media (ISO A4 595.28 x 841.89 pt or US Letter 612.00 x 792.00 pt):

  1. YAML Frontmatter Setext Heading Corruption (--- ... ---): According to CommonMark Spec §4.3 (Setext headings), a line of dashes (---) immediately below a paragraph of text promotes that paragraph into an <h2> heading. When a parser like marked or remark reads a Markdown file with YAML frontmatter (id:, title:, status:, owner:), the opening --- becomes an <hr> and the closing --- turns the entire YAML metadata block into a giant <h2> heading above your <h1> title.
  2. Unwrapped <pre><code> Shrink-to-Fit Scaling (up to 66.7% scaling): A long curl command, webhook signature header, or JSON payload inside a fenced code block forces <pre> (white-space: pre) wider than the page margin box. Chromium’s print engine responds by scaling the entire page down until the widest line fits. The scale depends on how far the line overflows, but it bottoms out at Chrome’s maximum shrink of 66.7%—12 pt body text printed at 8 pt—and anything still too wide at that point is clipped.
  3. Orphaned Section Headings (break-after): Without explicit CSS Fragmentation Module Level 3 rules, an ## 4. Rollout & Verification Checklist heading lands orphaned at the bottom of Page 1 (y = 60.4 pt) while its checklist items start at the top of Page 2 (y = 778.9 pt).
  4. WinAnsi (Windows-1252) Font Encoding Exceptions: Pure-vector PDF compilers built on pdf-lib (StandardFonts.Helvetica, TimesRoman, Courier) use WinAnsiEncoding. A single Unicode arrow (→, 0x2192) or inequality symbol (≥, 0x2265) aborts compilation with Error: WinAnsi cannot encode "≥" (0x2265)—or, in converters that sanitize input to avoid that crash, is silently printed as ?.
  5. Inline Underscore Stripping in Naive Regex Parsers: Line-by-line Markdown regex strippers that evaluate _italic_ (/_(.*?)_/g) before inline `code` (/`([^`]+)`/g) strip pairs of underscores inside snake_case identifiers—corrupting `missing_signature_header` into missingsignatureheader.
flowchart TD
    A["Repository Markdown (.md)<br/>YAML Frontmatter + Fenced Code + GFM Tables"] --> B["Step 1: Pre-Flight Sanitization<br/>Strip Line-1 YAML Frontmatter (awk)<br/>& Normalize Non-WinAnsi Unicode (perl -CSD -Mutf8)"]
    B --> C{"Select PDF Compilation Engine"}
    C -->|"HTML AST + Paged Media Engine<br/>(marked + Chromium / Playwright)"| D["Apply @page { size: A4; margin: 18mm 16mm }<br/>pre { white-space: pre-wrap; overflow-wrap: anywhere }<br/>h1-h4 { break-after: avoid }"]
    D --> E["Multi-Page Styled PDF (~172 KB)<br/>Full GFM Tables + Clickable /URI Links<br/>+ Running Page X of Y Footers"]
    C -->|"Direct Vector Line Compiler<br/>(pdf-lib / engine.markdownToPdf)"| F["Embed StandardFonts (Helvetica / TimesRoman / Courier)<br/>Wrap Lines via font.widthOfTextAtSize()<br/>Stamp Page X of Y at y = margin / 2"]
    F --> G["Ultra-Compact Vector PDF (~4.5 KB)<br/>H1-H4 Hierarchy + Shaded Code Lines<br/>+ Expanded Inline Text (URL) Links"]

Step 1: Creating a Realistic Technical Markdown Test Document

To test every stage of the workflow and measure exact coordinates inside the generated PDF byte streams, save the following 48-line Architecture Decision Record as adr-014-webhook-signing.md. It includes a 7-line YAML frontmatter block, a blockquote with an external link ([RFC 2104](...)), Unicode symbols (→ and ≥), a --- horizontal rule on line 16, a 143-character JSON -d line inside a fenced bash block, a 4-row GFM table with snake_case error codes, and a task list:

---
id: ADR-014
title: Webhook Payload Signing & Replay Protection
status: Accepted
owner: Platform Security Team
updated: 2026-10-13
---

# ADR-014: Webhook Payload Signing & Replay Protection

## 1. Context & Problem Statement
Outbound webhook deliveries from the billing cluster traverse public networks before reaching merchant endpoints. Without cryptographic message authentication and strict timestamp validation, an attacker who intercepts a payload can modify invoice amounts or replay captured events.

> **Security Invariant:** Every outbound webhook request MUST include a timestamped [RFC 2104](https://datatracker.ietf.org/doc/html/rfc2104) HMAC-SHA256 signature header (`X-Webhook-Signature-256`) and reject clock skew ≥ 300 seconds.

---

## 2. Signature Construction Workflow
The signing worker constructs the canonical string by joining the Unix epoch timestamp and raw UTF-8 JSON body with a period (`.`):

1. Read the current UTC epoch seconds (`t`) and serialize the HTTP request body (`payload`).
2. Concatenate `t` → `.` → `payload` into the signing input buffer.
3. Compute the HMAC-SHA256 digest using the endpoint's active secret (`whsec_live_...`):
   * Primary secret signs `v1` in production traffic.
   * Rollover secret (if active during a 24-hour rotation window) appends a second `v1` tag.

```bash
curl -i -X POST https://merchant.example.com/webhooks/billing \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Timestamp: 1791913200" \
  -H "X-Webhook-Signature-256: t=1791913200,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" \
  -d '{"event_id":"evt_01J9Z8K4M2N6P8Q0R2S4T6V8W0","type":"invoice.paid","amount_cents":49000,"currency":"USD","webhook_version":"2026-10-01"}'
```

## 3. Verification Failure Codes
Receiving gateways must evaluate the timestamp before running constant-time digest comparison:

| HTTP Status | Error Code | Trigger Condition | Retry Policy |
| :--- | :--- | :--- | :--- |
| 400 Bad Request | `missing_signature_header` | Missing `X-Webhook-Timestamp` or `X-Webhook-Signature-256` | Do not retry |
| 401 Unauthorized | `signature_mismatch` | Computed HMAC-SHA256 does not match any `v1` digest | Exponential backoff (max 5) |
| 408 Request Timeout | `timestamp_out_of_range` | Absolute clock drift `abs(now - t)` ≥ 300 seconds | Do not retry |
| 429 Too Many Requests | `rate_limit_exceeded` | Endpoint concurrency exceeds 150 req/sec | Respect `Retry-After` header |

## 4. Rollout & Verification Checklist
- [x] Deploy dual-key HMAC signing middleware to staging cluster
- [x] Verify constant-time comparison via `crypto.timingSafeEqual`
- [ ] Enforce 300-second replay window across all v2 merchant endpoints

Step 2: Pre-Processing YAML Frontmatter and Non-WinAnsi Unicode Safely

Before passing a repository .md file to a PDF compiler, you need to strip or extract the YAML frontmatter without deleting legitimate --- horizontal rules further down the document.

Why sed '/^---$/,/^---$/d' Destroys Markdown Documents

A commonly suggested one-liner for stripping YAML frontmatter is sed '/^---$/,/^---$/d'. On both macOS (BSD sed) and Linux (GNU sed), that range expression matches every pair of --- lines in the file. Because adr-014-webhook-signing.md has a --- horizontal rule on line 16 (and no closing --- after it), sed starts a second deletion range at line 16 and deletes Sections 2, 3, and 4 through the end of the file:

echo "sed '/^---$/,/^---$/d' line count:" $(sed '/^---$/,/^---$/d' adr-014-webhook-signing.md | wc -l)
echo "awk frontmatter-only strip line count:" $(awk 'NR==1 && /^---$/ { in_fm=1; next } in_fm && /^---$/ { in_fm=0; next } !in_fm { print }' adr-014-webhook-signing.md | wc -l)

Output:

sed '/^---$/,/^---$/d' line count: 8
awk frontmatter-only strip line count: 41

Using POSIX awk (NR==1 && /^---$/ { in_fm=1; next } ...) works identically on macOS BSD awk and Linux GNU gawk (mawk), stripping only the 7-line frontmatter block that begins on line 1 while preserving all 41 lines of the document body (including the --- divider on line 16). If you prefer to edit or strip YAML frontmatter visually before exporting, paste the file into YAML Frontmatter Editor.

Normalizing Non-WinAnsi Unicode (→, ≥, ≤) for Vector PDF Fonts

If you are targeting a pdf-lib vector converter that embeds standard PDF base-14 fonts (StandardFonts.Helvetica, TimesRoman, or Courier), characters outside WinAnsiEncoding (Windows-1252) will throw an exception. You can normalize Unicode arrows and mathematical inequalities to ASCII in the same pipeline using perl -CSD -Mutf8 (note that -Mutf8 is required alongside -CSD on both macOS and Linux so UTF-8 literals inside the -pe script string are decoded as characters rather than raw bytes):

awk 'NR==1 && /^---$/ { in_fm=1; next } in_fm && /^---$/ { in_fm=0; next } !in_fm { print }' adr-014-webhook-signing.md \
  | perl -CSD -Mutf8 -pe 's/→/->/g; s/≥/>=/g; s/≤/<=/g' > adr-014-clean.md

Utiliome’s @utiliome/pdf-engine (engine.markdownToPdf()) does not throw on the raw file, because it sanitizes text before drawing it: curly quotes, dashes, ellipses, and arrows (→ becomes ->) are mapped to safe equivalents, and any other character outside the Latin-1 range is replaced with ?. Compiling both files shows the difference:

node -e '
import("./packages/pdf-engine/src/index.js").then(async ({ getPDFEngine }) => {
  const fs = await import("node:fs");
  const engine = getPDFEngine();
  const opts = { pageSize: "A4", fontFamily: "Helvetica", margin: 44, fontSize: 11, includePageNumbers: true };

  const rawBytes = await engine.markdownToPdf(fs.readFileSync("adr-014-webhook-signing.md", "utf8"), opts);
  fs.writeFileSync("adr_pdflib_raw.pdf", rawBytes);

  const cleanBytes = await engine.markdownToPdf(fs.readFileSync("adr-014-clean.md", "utf8"), opts);
  fs.writeFileSync("adr_pdflib_a4.pdf", cleanBytes);
  console.log("Both files compiled without an encoding error");
});
'

Both calls succeed, but in adr_pdflib_raw.pdf the blockquote now reads reject clock skew ? 300 seconds—the ≥ was silently replaced with ?, which changes the meaning of a security requirement. The perl pre-pass keeps the intended >= instead.


Step 3: Building a Print-Hardened Markdown-to-PDF CLI Pipeline (marked + Chromium / Playwright)

When your documentation contains wide GFM tables, clickable RFC links, and syntax-fenced commands, compiling Markdown to HTML via marked and rendering to PDF with Headless Chromium or Playwright produces publication-grade typography—if you configure the print stylesheet and header/footer templates properly.

To see the exact difference between a naive conversion and a print-hardened workflow, save and run the following script (run_pipeline.mjs). It generates five PDFs from adr-014-webhook-signing.md:

  1. adr_naive.pdf: Raw marked.parse() output printed with default Headless Chrome flags.
  2. adr_no_break_after.pdf: Print-hardened CSS with YAML metadata extracted into a .doc-meta-bar header, except break-after: avoid is omitted on headings.
  3. adr_chrome_hardened.pdf: Full print-hardened CSS (@page { size: A4 portrait; margin: 18mm 16mm; }, pre { white-space: pre-wrap; overflow-wrap: anywhere; }, h1, h2, h3, h4 { break-after: avoid; }, and Helvetica, Arial, sans-serif font stack) exported with --no-pdf-header-footer.
  4. adr_playwright_numbered.pdf: The same print-hardened HTML exported via Playwright’s page.pdf() with displayHeaderFooter: true and native <span class="pageNumber"></span> of <span class="totalPages"></span> running footers.
  5. adr_pdflib_with_fm.pdf and adr_pdflib_a4.pdf: Direct vector compilation via @utiliome/pdf-engine (engine.markdownToPdf()) before and after stripping YAML frontmatter.

macOS vs. Linux Chromium Binary Paths: On macOS, pass "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome". On Linux (Ubuntu/Debian/Alpine), replace chromeBin with "google-chrome" or "chromium-browser".

// run_pipeline.mjs
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';

const require = createRequire(path.resolve('apps/web/package.json'));
const { marked } = require('marked');
const { PDFDocument } = require('pdf-lib');
const { chromium } = require('@playwright/test');
const { getPDFEngine } = await import(path.resolve('packages/pdf-engine/src/index.js'));

const rawMd = fs.readFileSync('adr-014-webhook-signing.md', 'utf8');

// 1. Naive marked HTML (no frontmatter stripping, no print CSS)
const naiveHtmlBody = marked.parse(rawMd);
const naiveHtml = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>adr-014-webhook-signing.md</title></head>
<body>${naiveHtmlBody}</body>
</html>`;
fs.writeFileSync('adr_naive.html', naiveHtml);

console.log('=== Naive HTML first 6 lines ===');
console.log(naiveHtmlBody.split('\n').slice(0, 8).join('\n'));

// macOS Chrome path (use "google-chrome" or "chromium-browser" on Linux)
const chromeBin = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
execFileSync(chromeBin, [
  '--headless',
  '--disable-gpu',
  '--print-to-pdf=adr_naive.pdf',
  `file://${path.resolve('adr_naive.html')}`
], { stdio: 'ignore' });

// 2. Extract YAML frontmatter into structured metadata + build print-hardened HTML
function extractFrontmatter(md) {
  const match = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
  if (!match) return { meta: {}, body: md };
  const meta = {};
  for (const line of match[1].split(/\r?\n/)) {
    const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.+)$/);
    if (kv) meta[kv[1].trim()] = kv[2].trim();
  }
  return { meta, body: md.slice(match[0].length) };
}

const { meta, body } = extractFrontmatter(rawMd);
const cleanHtmlBody = marked.parse(body);

const hardenedHtml = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>${meta.id ? `${meta.id}: ${meta.title}` : 'Technical Specification'}</title>
  <style>
    @page {
      size: A4 portrait;
      margin: 18mm 16mm 18mm 16mm;
    }
    * { box-sizing: border-box; }
    html, body {
      font-family: Helvetica, Arial, sans-serif;
      font-size: 10pt;
      line-height: 1.5;
      color: #0f172a;
      margin: 0;
      padding: 0;
      -webkit-print-color-adjust: exact;
      print-color-adjust: exact;
    }
    .doc-meta-bar {
      display: flex;
      flex-wrap: wrap;
      gap: 12px;
      padding: 8px 12px;
      margin-bottom: 14px;
      background: #f8fafc;
      border: 1px solid #cbd5e1;
      border-radius: 6px;
      font-size: 8.5pt;
      color: #334155;
    }
    .doc-meta-bar strong { color: #0f172a; }
    h1 { font-size: 18pt; margin: 0 0 10px; padding-bottom: 6px; border-bottom: 1.5px solid #cbd5e1; }
    h2 { font-size: 13pt; margin: 16px 0 6px; color: #1e293b; }
    h3 { font-size: 11pt; margin: 12px 0 4px; color: #334155; }
    h1, h2, h3, h4 {
      break-after: avoid;
      page-break-after: avoid;
    }
    p, ul, ol { margin: 0 0 8px; }
    blockquote {
      margin: 10px 0;
      padding: 8px 12px;
      border-left: 3.5px solid #2563eb;
      background: #eff6ff;
      color: #1e3a8a;
      border-radius: 0 6px 6px 0;
      break-inside: avoid;
      page-break-inside: avoid;
    }
    blockquote p { margin: 0; }
    pre {
      margin: 10px 0;
      padding: 10px 12px;
      background: #0f172a;
      color: #f8fafc;
      border-radius: 6px;
      font-family: "Courier New", Courier, monospace;
      font-size: 8.2pt;
      line-height: 1.45;
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      word-break: break-word;
      break-inside: avoid;
      page-break-inside: avoid;
    }
    code {
      font-family: "Courier New", Courier, monospace;
      font-size: 0.88em;
      background: #f1f5f9;
      padding: 1px 4px;
      border-radius: 3px;
    }
    pre code { background: transparent; padding: 0; color: inherit; font-size: inherit; }
    table {
      width: 100%;
      border-collapse: collapse;
      margin: 10px 0;
      font-size: 8.8pt;
      break-inside: avoid;
      page-break-inside: avoid;
    }
    th, td {
      border: 1px solid #cbd5e1;
      padding: 6px 8px;
      text-align: left;
      vertical-align: top;
      overflow-wrap: anywhere;
    }
    th { background: #f1f5f9; font-weight: 700; color: #0f172a; }
    tr { break-inside: avoid; page-break-inside: avoid; }
    hr { border: 0; border-top: 1px solid #cbd5e1; margin: 14px 0; }
    a { color: #2563eb; text-decoration: underline; }
    @media print {
      a[href]::after { content: none !important; }
    }
  </style>
</head>
<body>
  <div class="doc-meta-bar">
    <span><strong>ID:</strong> ${meta.id || 'N/A'}</span>
    <span><strong>Status:</strong> ${meta.status || 'Draft'}</span>
    <span><strong>Owner:</strong> ${meta.owner || 'Engineering'}</span>
    <span><strong>Updated:</strong> ${meta.updated || ''}</span>
  </div>
  ${cleanHtmlBody}
</body>
</html>`;

fs.writeFileSync('adr_hardened.html', hardenedHtml);
execFileSync(chromeBin, [
  '--headless',
  '--disable-gpu',
  '--no-pdf-header-footer',
  '--print-to-pdf=adr_chrome_hardened.pdf',
  `file://${path.resolve('adr_hardened.html')}`
], { stdio: 'ignore' });

// Variant without break-after: avoid to measure orphaned heading coordinates
const noBreakAfterHtml = hardenedHtml.replace('break-after: avoid;\n      page-break-after: avoid;', '/* no break-after */');
fs.writeFileSync('adr_no_break_after.html', noBreakAfterHtml);
execFileSync(chromeBin, [
  '--headless',
  '--disable-gpu',
  '--no-pdf-header-footer',
  '--print-to-pdf=adr_no_break_after.pdf',
  `file://${path.resolve('adr_no_break_after.html')}`
], { stdio: 'ignore' });

// 3. Playwright export with running Page X of Y footers
const browser = await chromium.launch({ executablePath: chromeBin });
const page = await browser.newPage();
await page.goto(`file://${path.resolve('adr_hardened.html')}`, { waitUntil: 'networkidle' });
await page.pdf({
  path: 'adr_playwright_numbered.pdf',
  format: 'A4',
  printBackground: true,
  displayHeaderFooter: true,
  margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' },
  headerTemplate: '<div></div>',
  footerTemplate: `
    <div style="width: 100%; font-family: Helvetica, Arial, sans-serif; font-size: 8pt; color: #64748b; padding: 0 16mm; display: flex; justify-content: space-between;">
      <span>${meta.id}: ${meta.title}</span>
      <span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
    </div>`
});
await browser.close();

// 4. pdf-lib vector output with unstripped YAML frontmatter vs stripped frontmatter
const engine = getPDFEngine();
const unicodeFixedRawFm = rawMd.replace(/→/g, '->').replace(/≥/g, '>=').replace(/≤/g, '<=');
const pdflibWithFmBytes = await engine.markdownToPdf(unicodeFixedRawFm, {
  pageSize: 'A4',
  fontFamily: 'Helvetica',
  margin: 44,
  fontSize: 11,
  includePageNumbers: true
});
fs.writeFileSync('adr_pdflib_with_fm.pdf', pdflibWithFmBytes);

Output:

=== Naive HTML first 6 lines ===
<hr>
<h2>id: ADR-014
title: Webhook Payload Signing &amp; Replay Protection
status: Accepted
owner: Platform Security Team
updated: 2026-10-13</h2>
<h1>ADR-014: Webhook Payload Signing &amp; Replay Protection</h1>
<h2>1. Context &amp; Problem Statement</h2>

Step 4: Auditing the Generated PDFs with pdfjs-dist

To verify exactly how each pipeline rendered font sizes, page breaks, YAML frontmatter, and footer timestamps, we inspected all six PDFs using pdfjs-dist:

// verify_all.mjs
import fs from 'node:fs';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';

const files = [
  'adr_naive.pdf',
  'adr_no_break_after.pdf',
  'adr_chrome_hardened.pdf',
  'adr_playwright_numbered.pdf',
  'adr_pdflib_with_fm.pdf',
  'adr_pdflib_a4.pdf'
];

function groupLinesByY(items) {
  const buckets = [];
  for (const it of items) {
    if (!it.str || !it.str.trim()) continue;
    const y = Math.round(it.transform[5] * 10) / 10;
    const x = Math.round(it.transform[4] * 10) / 10;
    const fontSize = Math.round(it.transform[0] * 100) / 100;
    let bucket = buckets.find(b => Math.abs(b.y - y) < 2.0);
    if (!bucket) {
      bucket = { y, fontSize, parts: [] };
      buckets.push(bucket);
    }
    bucket.parts.push({ x, str: it.str });
  }
  buckets.sort((a, b) => b.y - a.y);
  return buckets.map(b => {
    b.parts.sort((p1, p2) => p1.x - p2.x);
    return {
      y: b.y,
      fontSize: b.fontSize,
      text: b.parts.map(p => p.str).join(' ').replace(/\s+/g, ' ').trim()
    };
  });
}

for (const file of files) {
  const bytes = fs.readFileSync(file);
  const doc = await pdfjsLib.getDocument({ data: new Uint8Array(bytes), useSystemFonts: true }).promise;
  const p1 = await doc.getPage(1);
  const vp = p1.getViewport({ scale: 1.0 });
  const annots = await p1.getAnnotations();
  const links = annots.filter(a => a.subtype === 'Link').map(a => a.url);
  const tc1 = await p1.getTextContent();
  const lines1 = groupLinesByY(tc1.items);

  console.log(`=== ${file} (${bytes.length} B | ${doc.numPages} page(s) | ${vp.width.toFixed(2)}x${vp.height.toFixed(2)} pt | links=${links.length}) ===`);
  console.log(`  Page 1 top 2 lines   :`, lines1.slice(0, 2).map(l => `[y=${l.y}, ${l.fontSize}pt] ${l.text.slice(0, 72)}`));
  console.log(`  Page 1 bottom 2 lines:`, lines1.slice(-2).map(l => `[y=${l.y}, ${l.fontSize}pt] ${l.text.slice(0, 72)}`));
  if (doc.numPages > 1) {
    const p2 = await doc.getPage(2);
    const tc2 = await p2.getTextContent();
    const lines2 = groupLinesByY(tc2.items);
    console.log(`  Page 2 top 2 lines   :`, lines2.slice(0, 2).map(l => `[y=${l.y}, ${l.fontSize}pt] ${l.text.slice(0, 72)}`));
    console.log(`  Page 2 bottom 2 lines:`, lines2.slice(-2).map(l => `[y=${l.y}, ${l.fontSize}pt] ${l.text.slice(0, 72)}`));
  }
}

Output (Google Chrome 156 and Playwright 1.62 on macOS; Chromium byte sizes vary with the browser version and installed fonts):

=== adr_naive.pdf (126235 B | 1 page(s) | 612.00x792.00 pt | links=1) ===
  Page 1 top 2 lines   : [
  '[y=769.5, 7.99pt] 10/13/26, 5:51 PM adr-014-webhook-signing.md',
  '[y=738.3, 12pt] id: ADR-014 title: Webhook Payload Signing & Replay Protection status: A'
]
  Page 1 bottom 2 lines: [
  '[y=294.2, 8pt] Enforce 300-second replay window across all v2 merchant endpoints',
  '[y=17.3, 7.99pt] fi le:///tmp/adr_naive.html'
]
=== adr_no_break_after.pdf (182551 B | 2 page(s) | 594.96x841.92 pt | links=1) ===
  Page 1 top 2 lines   : [
  '[y=775.2, 8.5pt] ID: ADR-014 Status: Accepted Owner: Platform Security Team Updated: 2026',
  '[y=734.7, 18pt] ADR-014: Webhook Payload Signing & Replay Protection'
]
  Page 1 bottom 2 lines: [
  '[y=94.9, 8.8pt] Requests d header',
  '[y=60.4, 13pt] 4. Rollout & Veri fi cation Checklist'
]
  Page 2 top 2 lines   : [
  '[y=778.9, 10pt] Deploy dual-key HMAC signing middleware to staging cluster',
  '[y=762.4, 10pt] Verify constant-time comparison via crypto.timingSafeEqual'
]
  Page 2 bottom 2 lines: [
  '[y=762.4, 10pt] Verify constant-time comparison via crypto.timingSafeEqual',
  '[y=745.2, 10pt] Enforce 300-second replay window across all v2 merchant endpoints'
]
=== adr_chrome_hardened.pdf (182695 B | 2 page(s) | 594.96x841.92 pt | links=1) ===
  Page 1 top 2 lines   : [
  '[y=775.2, 8.5pt] ID: ADR-014 Status: Accepted Owner: Platform Security Team Updated: 2026',
  '[y=734.7, 18pt] ADR-014: Webhook Payload Signing & Replay Protection'
]
  Page 1 bottom 2 lines: [
  '[y=108.4, 8.8pt] 429 Too Many rate_limit_exceede Endpoint concurrency exceeds 150 req/sec',
  '[y=94.9, 8.8pt] Requests d header'
]
  Page 2 top 2 lines   : [
  '[y=777.4, 13pt] 4. Rollout & Veri fi cation Checklist',
  '[y=754.9, 10pt] Deploy dual-key HMAC signing middleware to staging cluster'
]
  Page 2 bottom 2 lines: [
  '[y=738.4, 10pt] Verify constant-time comparison via crypto.timingSafeEqual',
  '[y=721.2, 10pt] Enforce 300-second replay window across all v2 merchant endpoints'
]
=== adr_playwright_numbered.pdf (172540 B | 2 page(s) | 595.92x842.88 pt | links=1) ===
  Page 1 top 2 lines   : [
  '[y=775.4, 8.5pt] ID: ADR-014 Status: Accepted Owner: Platform Security Team Updated: 2026',
  '[y=734.9, 18pt] ADR-014: Webhook Payload Signing & Replay Protection'
]
  Page 1 bottom 2 lines: [
  '[y=95.1, 8.8pt] Requests d header',
  '[y=16.4, 7.99pt] ADR-014: Webhook Payload Signing & Replay Protection Page 1 of 2'
]
  Page 2 top 2 lines   : [
  '[y=777.6, 13pt] 4. Rollout & Veri fi cation Checklist',
  '[y=755.1, 10pt] Deploy dual-key HMAC signing middleware to staging cluster'
]
  Page 2 bottom 2 lines: [
  '[y=721.4, 10pt] Enforce 300-second replay window across all v2 merchant endpoints',
  '[y=16.4, 7.99pt] ADR-014: Webhook Payload Signing & Replay Protection Page 2 of 2'
]
=== adr_pdflib_with_fm.pdf (4686 B | 2 page(s) | 595.28x841.89 pt | links=0) ===
  Page 1 top 2 lines   : [
  '[y=777.9, 11pt] id: ADR-014',
  '[y=758.5, 11pt] title: Webhook Payload Signing & Replay Protection'
]
  Page 1 bottom 2 lines: [
  `[y=95.1, 9pt] currency":"USD","webhook_version":"2026-10-01"}'`,
  '[y=22, 9pt] Page 1 of 2'
]
  Page 2 top 2 lines   : [
  '[y=783.9, 15.4pt] 3. Verification Failure Codes',
  '[y=757.9, 11pt] Receiving gateways must evaluate the timestamp before running constant-t'
]
  Page 2 bottom 2 lines: [
  '[y=469.2, 11pt] • [ ] Enforce 300-second replay window across all v2 merchant endpoints',
  '[y=22, 9pt] Page 2 of 2'
]
=== adr_pdflib_a4.pdf (4492 B | 2 page(s) | 595.28x841.89 pt | links=0) ===
  Page 1 top 2 lines   : [
  '[y=771.1, 19.8pt] ADR-014: Webhook Payload Signing & Replay',
  '[y=745.4, 19.8pt] Protection'
]
  Page 1 bottom 2 lines: [
  '[y=124.1, 11pt] | :--- | :--- | :--- | :--- |',
  '[y=22, 9pt] Page 1 of 2'
]
  Page 2 top 2 lines   : [
  '[y=797.9, 11pt] | 400 Bad Request | missingsignatureheader | Missing X-Webhook-Timestamp',
  '[y=782.5, 11pt] X-Webhook-Signature-256 | Do not retry |'
]
  Page 2 bottom 2 lines: [
  '[y=576.2, 11pt] • [ ] Enforce 300-second replay window across all v2 merchant endpoints',
  '[y=22, 9pt] Page 2 of 2'
]

Key Findings from the Coordinate Audit

  1. 66.7% Shrink-to-Fit Scaling in adr_naive.pdf: Because the 143-character -d '{"event_id":...}' line inside <pre><code> cannot wrap under default white-space: pre, Chromium scaled the entire page down to 66.7%, its maximum shrink-to-fit factor. The 18 pt (24 px) <h2> YAML block rendered at 12.00 pt, the 12 pt (16 px) body text shrank to 8.00 pt ([y=294.2, 8pt]), and the <pre><code> text shrank by the same factor—while leaking file:///tmp/adr_naive.html at y = 17.3 pt.
  2. Orphaned Heading Elimination (adr_no_break_after.pdf vs. adr_chrome_hardened.pdf): Without break-after: avoid, the heading 4. Rollout & Verification Checklist sits isolated at the bottom of Page 1 ([y=60.4, 13pt]) while its first list item starts on Page 2 ([y=778.9, 10pt]). Adding break-after: avoid moves the heading cleanly to the top of Page 2 ([y=777.4, 13pt]).
  3. Font Stack and File Size (Helvetica, Arial vs. -apple-system): The hardened stylesheet deliberately names Helvetica, Arial, sans-serif and "Courier New", Courier, monospace instead of a system UI stack such as -apple-system, BlinkMacSystemFont. Chromium subsets and embeds whatever fonts it resolves, so the font stack directly affects file size; if size matters, export both variants on your machine and compare. Note that the text layer still splits some ligatures (Veri fi cation above), whichever stack you choose.

Step 5: What Utiliome’s Markdown to PDF Does With Your File

When you need to convert Markdown notes, runbooks, or specifications in your browser without setting up Node.js or Playwright, Markdown to PDF runs entirely in local browser memory with zero server uploads. The tool has two export buttons, and they behave very differently:

1. Direct Vector Compilation (Download PDF)

Clicking Download PDF compiles your Markdown with pdf-lib into a compact vector PDF directly in the browser (adr_pdflib_a4.pdf above is 4,492 bytes):

  • Page Geometry & Orientation: Supports A4 (595.28 x 841.89 pt), US Letter (612.0 x 792.0 pt), and US Legal (612.0 x 1008.0 pt) in portrait or landscape, with Small (24 pt), Medium (44 pt), or Spacious (64 pt) margins and base font sizes of 10 pt, 11 pt, 12 pt, or 13 pt.
  • Standard PDF Fonts: Body text uses Helvetica, Times Roman, or Courier (with their bold and italic variants), and fenced code always uses Courier. Because these base-14 fonts are built into every PDF viewer, nothing is embedded, which keeps the file small (38x smaller than Chromium’s 172,540 bytes for the same document).
  • Supported Block Syntax & Measured Word Wrapping:
    • Headings (# through ####): Scaled to 1.8x (H1), 1.4x (H2), 1.2x (H3), and 1.05x (H4) of the base font size. ##### H5 and ###### H6 lines are printed as regular paragraph text.
    • Fenced Code Blocks (```): Each code line is drawn in Courier at 2 pt below the base size (minimum 8 pt) on its own light slate shaded strip, with no border; long lines wrap character-by-character, and the block can continue across a page break.
    • Blockquotes (>) & Nested Lists (-, *, +, 1.): Blockquotes get a 3 pt blue vertical accent line, and lists support up to 4 indentation levels (14 pt per level, one level per 2 leading spaces).
    • Running Page X of Y Footers: With Page Numbers checked, a 9 pt Page X of Y label is centered at the bottom of every page.
  • Four Caveats of Download PDF:
    1. Pipe Tables Render as Raw Text Paragraphs: There is no table layout; each | ... | row, including the | :--- | separator, is printed as a plain text line.
    2. Inline Links Expand to Label (URL): [RFC 2104](https://...) becomes plain text RFC 2104 (https://...), with no clickable /Subtype /Link annotation.
    3. Underscores Inside Inline Code Can Disappear: Italic markup is removed before inline code is processed, so a line containing two underscores—such as `missing_signature_header`—loses both of them (missingsignatureheader, visible at the top of Page 2 in adr_pdflib_a4.pdf). Fenced code blocks are not affected.
    4. Unsupported Characters Are Replaced, Not Rejected: Arrows become ->, but symbols outside the Latin-1 range such as ≥ are silently printed as ?. Normalize them first, as shown in Step 2.

2. Browser Print (Print / Save as PDF)

The Print / Save as PDF button opens your browser’s print dialog for the current page. The tool does not ship a dedicated print layout for this mode, so do not rely on it as a print-styled export. The on-screen preview does render pipe tables as formatted HTML tables, task lists (- [x]) as checkboxes, ~~strikethrough~~, and clickable links, but it uses the same inline-formatting order as the PDF export, so `missing_signature_header` is mangled there too (the middle word is shown in italics without its underscores).

If your document depends on formatted tables, Unicode symbols, or snake_case identifiers, use the marked + Chromium / Playwright pipeline from Step 3, which gives you full control over the print stylesheet.


Comparison of All 6 Markdown-to-PDF Outputs

We verified the exact byte sizes of all six generated PDF files on disk using stat (stat -f "%N: %z bytes" on macOS BSD stat, or stat -c "%n: %s bytes" on Linux GNU coreutils):

# macOS (BSD stat)
stat -f "%N: %z bytes" adr_naive.pdf adr_no_break_after.pdf adr_chrome_hardened.pdf adr_playwright_numbered.pdf adr_pdflib_with_fm.pdf adr_pdflib_a4.pdf

# Linux (GNU coreutils)
# stat -c "%n: %s bytes" adr_naive.pdf adr_no_break_after.pdf adr_chrome_hardened.pdf adr_playwright_numbered.pdf adr_pdflib_with_fm.pdf adr_pdflib_a4.pdf

Output:

adr_naive.pdf: 126235 bytes
adr_no_break_after.pdf: 182551 bytes
adr_chrome_hardened.pdf: 182695 bytes
adr_playwright_numbered.pdf: 172540 bytes
adr_pdflib_with_fm.pdf: 4686 bytes
adr_pdflib_a4.pdf: 4492 bytes
Workflow / Output FilePage Size & CountFile SizeYAML Frontmatter Handled?Body / Code Font SizeGFM Table & /URI Link SupportRunning Page X of Y Footer
Chrome CLI Naive (adr_naive.pdf)US Letter (1 page)126,235 BFailed (Setext <h2> YAML leak)Shrunk to 8.0pt (66.7%, Chrome’s maximum shrink)Unbordered table, 1 /URI linkLeaked file:///... + 1/1
Chrome CLI No break-after (adr_no_break_after.pdf)A4 (2 pages)182,551 BYes (.doc-meta-bar badge)10.0pt / 8.2pt (100% scale)Bordered <table>, 1 /URI linkNone (--no-pdf-header-footer)
Chrome CLI Hardened (adr_chrome_hardened.pdf)A4 (2 pages)182,695 BYes (.doc-meta-bar badge)10.0pt / 8.2pt (100% scale)Bordered <table>, 1 /URI link, zero orphansNone (--no-pdf-header-footer)
Playwright Numbered (adr_playwright_numbered.pdf)A4 (2 pages)172,540 BYes (.doc-meta-bar badge)10.0pt / 8.2pt (100% scale)Bordered <table>, 1 /URI link, zero orphansYes (ADR-014 + Page 1 of 2)
pdf-lib Unstripped FM (adr_pdflib_with_fm.pdf)A4 (2 pages)4,686 BNo (prints raw YAML between two <hr> lines)11.0pt / 9.0pt (100% scale)Pipe text rows, inline Text (URL)Yes (Page 1 of 2 at y = 22 pt)
Markdown to PDF (adr_pdflib_a4.pdf)A4 (2 pages)4,492 BYes (stripped via awk)11.0pt / 9.0pt (100% scale)Pipe text rows, inline Text (URL)Yes (Page 1 of 2 at y = 22 pt)

5-Point Pre-Flight Checklist Before Publishing Documentation PDFs

Before distributing a PDF compiled from repository Markdown to external stakeholders, run through these five verification steps:

  1. Strip or Extract Leading YAML Frontmatter (---): Never feed raw frontmatter into a CommonMark parser or pdf-lib compiler. Strip it with POSIX awk (NR==1 && /^---$/ ...) or edit it in YAML Frontmatter Editor so your metadata does not render as a giant Setext <h2> heading on Page 1.
  2. Format and Audit Pipe Tables Before Export: If your specification relies on comparison tables, align your pipe columns first with Markdown Table Formatter and export through a Playwright/Chromium print stylesheet (table { border-collapse: collapse; break-inside: avoid; }) so tables render as bordered grids. The Download PDF button in Markdown to PDF prints table rows as raw pipe text.
  3. Generate an Explicit Table of Contents for Multi-Section Specs: Unlike continuous web documentation with sticky sidebars, multi-page PDFs require an explicit section outline at the top of Page 1. Generate a clean list of section headings with Markdown TOC Generator before exporting.
  4. Verify Page Dimensions (/MediaBox) Match Your Audience: European and ISO standards require A4 (595.28 x 841.89 pt), whereas North American binders require US Letter (612 x 792 pt). Drop your final PDF into PDF Page Size Inspector to verify the exact /MediaBox dimensions and orientation.
  5. Inspect and Clean Document /Info Metadata: Headless Chromium sets the PDF /Creator to Chromium and /Title to the <title> tag (or filename if omitted). Before sharing externally, verify or update the /Title, /Author, and /Subject fields using PDF Metadata Editor.