---
name: a4-print-documents
description: Constraints for producing A4 HTML documents intended for PDF download via browser print. Use when creating or modifying any HTML document that will be printed to PDF, uses A4 page dimensions, or contains glassmorphism/backdrop-filter effects that must survive print. Triggers include "one-pager", "A4", "print to PDF", "download PDF", "market analysis", "executive document", or any HTML document with a print button.
---

# A4 Print-Ready HTML Documents

Constraints derived from two corrected reference documents: `biosymm/output/biosymm-one-pager.html` (single A4 page) and `gpu-derivatives/output/gpu-derivatives-brochure.html` (multi-page flowing brochure). Every rule exists because violating it caused a visible problem.

## Cover glassmorphism panels must span the full page width

When the cover uses a glassmorphism panel for the title block (eyebrow, wordmark, gold rule, tagline, contact strip), make it a horizontal **full-width band** anchored to the page edges — not a floating card with side margins. Full-width bands read as architectural marquees that match A4 proportions; floating cards look like UI tooltips superimposed on a photo.

```css
.cover-glass {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 88mm;                /* lock the height so content cannot overflow */
  padding: 12mm 22mm;          /* generous side padding gives the title breathing room */
  background: rgba(20, 20, 20, 0.30);
  backdrop-filter: blur(22px) saturate(135%);
  -webkit-backdrop-filter: blur(22px) saturate(135%);
  border-top: 1px solid rgba(255, 255, 255, 0.18);
  overflow: hidden;
  z-index: 3;
}
```

**Critical structural rule — the band must be a direct child of the cover element**, not nested inside a padded wrapper. If you nest it inside a wrapper that has `padding: 18mm 18mm 16mm` and `position: absolute; inset: 0`, the absolutely-positioned band's `bottom: 0` will not actually anchor to the page edge in every browser's print engine, and content overflows below the page. Place the title block as a sibling of `.cover-photo` and `.cover-veil`, directly inside `.cover`:

```html
<section class="page cover">
  <img class="cover-print-img" src="cover-print.png" alt="">
  <div class="cover-photo"></div>
  <div class="cover-veil"></div>
  <div class="cover-top">…logo + locale…</div>      <!-- absolute, top-anchored -->
  <div class="cover-glass">…title block…</div>      <!-- absolute, bottom-anchored -->
</section>
```

**Lock the band height** with an explicit `height` (e.g. `88mm`) and `overflow: hidden`. Don't rely on `height: auto` with `bottom: 0` — content overflow caused by font-loading races, wrapping changes, or hidden flex constraints will silently push text below the page edge in the screenshot capture, even if `overflow: hidden` is on the page itself. Locking the height plus tight content control is how you guarantee the band looks identical on screen and in print.

**Keep the band's content compact.** Inside the band: an eyebrow, the title (≤ 56pt), a gold rule (≤ 22mm), and the tagline (≤ 14pt with `max-width: 165mm`). Resist the urge to add a contact-strip footer inside the band — the logo at the top of the cover is sufficient branding, and key facts live on the next page. A busy band crowds the title; a clean band lets the wordmark do its job.

## Glassmorphism does not survive print

`backdrop-filter: blur()`, CSS glassmorphism, and layered transparency effects render as flat, transparent boxes in browser print/PDF output. This is a browser limitation, not a CSS error.

**Required pattern:** Every section using glassmorphism needs a pre-rendered PNG fallback that is hidden on screen and shown only in print.

```css
.cover-print-img { display: none; }

@media print {
  .cover > *:not(.cover-print-img) { display: none !important; }
  .cover-print-img {
    display: block !important;
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
}
```

```html
<div class="cover">
  <img class="cover-print-img" src="cover-print.png" alt="">
  <!-- live glassmorphism content follows -->
</div>
```

This pattern applies to every full-bleed glassmorphism section (cover pages, back pages). The GPU derivatives brochure uses it for both `.cover` and `.backpage`.

### Capturing the print image

Each glassmorphism section needs its **own** screenshot — you cannot reuse a print image from a different document or a differently-sized section.

**Capture process** (using Playwright or equivalent browser automation):

1. Serve the HTML over HTTP (e.g. `python3 -m http.server`) — `file://` protocol is blocked by Playwright.
2. Set the viewport to match the element's rendered size. Measure the element with `boundingBox()` first, then resize the viewport to `Math.ceil(width) + 1` by `Math.ceil(height) + 1` to eliminate scrollbars.
3. Hide UI chrome (download buttons, navigation overlays) **and** set `document.body.style.overflow = 'hidden'` to suppress any body-level scrollbars.
4. Screenshot the specific element (e.g. `.cover`, `.backpage`) using `locator.screenshot()` — not a full-page or viewport screenshot.
5. Save the PNG alongside the HTML in the same output directory, named for the section it replaces (e.g. `cover-print.png`, `backpage-print.png`).
6. Restore hidden elements and overflow after capture.
7. Reference this image in the `<img>` element's `src`.
8. Visually verify the saved PNG by reading it — confirm no scrollbars, no UI chrome, correct dimensions.

## Page margins, page numbers, and running footers

Use `@page` margin boxes for page numbers and running footers — not `position: fixed` elements. This is the pattern used in the GPU derivatives brochure and is the most reliable cross-browser approach.

```css
@page {
  margin: 0.6in 0.7in 0.8in;
  @bottom-center {
    content: counter(page);
    font-family: 'Playfair Display', Georgia, serif;
    font-size: 9pt;
    color: #8A8A84;
  }
}

/* Cover page: zero margins, no page number */
@page :first {
  margin: 0;
  @bottom-center { content: none; }
}

/* Named page for full-bleed back page */
@page backpage-full {
  margin: 0;
  @bottom-center { content: none; }
}
```

Then in `@media print`, the cover needs NO `page:` property — `@page :first` handles it. Only the backpage needs the named page assignment:

```css
@media print {
  .cover {
    width: auto; margin: 0; box-shadow: none; /* reset screen-only properties */
    min-height: 0;
    height: 100vh;
    page-break-after: always;
    position: relative;
    overflow: hidden;
  }
  .backpage {
    width: auto; margin: 0; box-shadow: none; /* reset screen-only properties */
    min-height: 0;
    height: 100vh;
    page-break-before: always;
    position: relative;
    overflow: hidden;
    page: backpage-full;
  }
}
```

**Key details:**
- `@page :first` zeros the margins for the cover — no named page needed on the cover element. This is the pattern from the GPU derivatives reference and it works.
- Named pages are only needed for non-first full-bleed pages (backpage).
- `counter(page)` gives automatic page numbering with no JavaScript.
- No `position: fixed` hacks are needed for repeating headers/footers.
- If the screen CSS uses `width: 210mm; margin: 20px auto; box-shadow: ...` on the cover/backpage (for the card-on-grey-background look), the print CSS MUST explicitly reset these: `width: auto; margin: 0; box-shadow: none;`. Screen-only layout properties leak into print if not reset. Do NOT use `width: 100vw` or `margin: 0 !important` — just `width: auto; margin: 0`.
- The print image uses `position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;` — this is what the GPU derivatives reference uses and it works.

## A4 layout — continuous flow, not fixed-height boxes

Forcing each section into a fixed `height: 297mm` div creates large whitespace gaps when content doesn't fill a full page.

**Correct approach:**

- **Cover page:** `min-height: 100vh` on screen; `height: 100vh; page-break-after: always;` in print.
- **Content:** Flows naturally. The `@page` margins handle spacing. No fixed-height containers.
- Use `page-break-inside: avoid` on logical units (cards, tables, callouts, stat groups, timeline items, competitor cards, pullquotes).
- Use `page-break-before: always` on major section dividers (part breaks).
- Use `page-break-after: avoid` on headings and section numbers so they don't strand at the bottom of a page.
- Set `orphans: 3; widows: 3;` on paragraphs to prevent single-line stragglers.

## No page may end with significant whitespace

Every A4 page must have its content fill the page intentionally — to the bottom margin or close to it. Large blocks of empty space at the bottom of a page are an editorial failure: they make a brochure feel underweight and signal that the layout was never finished. Print buyers read whitespace as "the agency couldn't be bothered."

**The rule:** if a page has visible empty space below the last content block that's larger than ~25mm (≈ 10% of A4 height), do one of three things — never ship it as-is.

1. **Resize the existing content to fill.** Make image grids stretch to the available height (`grid-auto-rows: 1fr` plus `flex: 1; min-height: 0` on the grid container). Make figures/charts `flex: 1; min-height: 0; object-fit: contain`. Pull text to a larger size if the column is narrow. The page's outer container should be `display: flex; flex-direction: column` so children can grow.

2. **Add more content of the same kind.** A photo gallery on a property brochure should fill — if you have 8 images and the grid stops at 60% of the page, add 3 more images and extend the grid to 5 rows. Reach back to the source media for unused assets before resorting to filler.

3. **Add a different kind of supporting content** — a sidebar with stats and copy beside a wide-aspect image, a closing callout band at the bottom of an otherwise text-heavy page, a distances/highlights block beside an EPC chart, a room schedule next to a floorplan. The added content must be substantively useful, not decorative noise.

**Diagnostic patterns** that produce whitespace and how to fix them:

| Pattern | Why it leaves space | Fix |
|---|---|---|
| `object-fit: contain` on a wide-aspect image inside a tall pane | Image fits to width, leaves vertical empty space above and below | Either pair the image with a meta-column sidebar (image + descriptive text in 2-col grid) or stack the image with a content block beneath |
| Grid with `grid-auto-rows: 36mm` (fixed) | Grid only takes its computed natural height, ignoring page height | Use `grid-auto-rows: 1fr` and put the grid in a `flex: 1` container so rows stretch to fill |
| Body content with no explicit flex | Content sizes to its natural height, leaving the bottom of the page empty | Make the page a flex column (`display: flex; flex-direction: column`) and give the last (or main) block `flex: 1; min-height: 0` |
| Two-column layout where one column is much shorter | Short column leaves whitespace below it | Either `align-items: stretch` (so the short column gets a meta-block at the bottom) or split the layout into a 3-band design where a third row covers the gap |

**Verify by reading the rendered PDF page-by-page.** `pdfinfo` reporting "10 pages, A4" is not verification. Open each page visually, look at the bottom 30%, and confirm content reaches it. The skill's emphasis on "Visually verify the saved PNG by reading it" applies equally to every PDF page, not just the cover.

## Dark backgrounds are stripped in print

Browsers strip background colours by default when printing.

**Required:** Add both properties to every dark element in `@media print`:

```css
@media print {
  .dark-element {
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
}
```

## Stripping glassmorphism from content areas

In multi-page documents, strip `backdrop-filter` from content sections (where it won't render correctly anyway) while preserving it on cover/backpage elements that are replaced with print images:

```css
@media print {
  /* Strip glass from content */
  .content *, .content *::before, .content *::after {
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
    border-radius: 0 !important;
    box-shadow: none !important;
  }
}
```

## Document structure pattern

Multi-page documents follow this structure:

1. **Cover** — full-bleed, glassmorphism, with print image fallback. Uses `@page :first { margin: 0; }` — NO named page on the element. `page-break-after: always`.
2. **Table of contents** (optional) — `page-break-after: always` to start content on a fresh page.
3. **Content sections** — flowing, with part dividers using `page-break-before: always`.
4. **Back page** — full-bleed CTA/contact page. **Not** an inline footer div — a separate top-level element outside `.body-wrap` with its own named page, print image fallback, and `page-break-before: always`.

**The back page is mandatory for multi-page documents.** An inline CTA footer (a `div` inside flowing content) ends up floating mid-page with whitespace below it. The back page pattern guarantees a clean final page.

```css
@page backpage-full {
  margin: 0;
  @bottom-center { content: none; }
}

@media print {
  .backpage {
    page: backpage-full;
    min-height: 0; height: 100vh;
    page-break-before: always;
    position: relative; overflow: hidden;
  }
  .backpage > *:not(.backpage-print-img) { display: none !important; }
  .backpage-print-img {
    display: block !important;
    position: absolute;
    inset: 0;
    width: 100%; height: 100%;
    object-fit: cover;
  }
}
```

```html
<!-- Outside .body-wrap, after it closes -->
<div class="backpage">
  <img class="backpage-print-img" src="backpage-print.png" alt="" style="display:none;">
  <!-- live backpage content (bg, text, contact info) -->
</div>
```

The backpage print image is captured via the same Playwright process as the cover — element-level screenshot with UI chrome hidden.

## Single-page documents

For single A4 documents (like the BioSymm one-pager), use fixed dimensions:

```css
.page {
  width: 210mm;
  min-height: 297mm;
  margin: 0 auto;
  overflow: hidden;
}

@page { size: A4; margin: 0; }

@media print {
  .page {
    width: 210mm;
    height: 297mm;
    page-break-after: always;
  }
}
```

## Image resolution floor

Brochures from this pipeline are **print-first deliverables**. The print master is the canonical artefact; the digital PDF that reviewers email/embed is generated **from the print master** by downsampling at the PDF stage (see **PDF compression** below). Source images therefore target the print floor — there is no separate "digital-grade source" pixel target. If a screen-only deliverable is ever needed, it is derived from the print PDF, never from a smaller source-side WebP.

The longest edge of every brochure photo, **measured before any optimisation**, must clear the print floor for its slot:

| Slot type | Source-px floor | Cap |
|---|---|---|
| Full-bleed (cover, full-page hero, back-page background) | **2800 px** | 4000 px |
| Half-page (split-spread heros, opener portrait) | **2200 px** | 4000 px |
| Quarter-page and smaller (gallery, strip cells) | **1600 px** | 2400 px |
| Floor plan / map | **2000 px** | 4000 px |

The cap is firm — beyond 4000 px the file size grows without visible benefit. The floor is firm too: below it the photo softens noticeably even on screen at 100% zoom, and worse in print.

**1600 px source-ceiling caveat.** Many UK listing-CDN exports cap at a 1600 px ceiling (this is a common Loop-CRM tenant default; some portal exports cap similarly). That is below the full-bleed and half-page floors. When the source photos cap below the floor for an assigned slot and no higher-resolution substitute exists, raise this with the user rather than silently shipping a sub-floor cover — a `DONE_WITH_CONCERNS` is the right exit.

**Measure before optimising:**

```bash
sips -g pixelWidth -g pixelHeight <src>          # macOS native
identify -format "%wx%h\n" <src>                  # ImageMagick
```

**If a source photo is below the floor for its assigned slot:**

1. Check whether a higher-resolution version of the same photo exists elsewhere in the property's source directory (`<property_dir>/images/`, `floorplans/`, `epc/`). DSLR raws and `DJI_*` drone photos sometimes arrive at 6000–8000 px before someone downscales.
2. If none exists, **reassign the slot** — promote a higher-resolution photo of the same room to the full-bleed slot, demote the low-resolution one to a quarter-page cell or drop it.
3. If the cover, full-bleed, or back-page hero cannot meet the floor and there is no substitute, **emit a `DONE_WITH_CONCERNS`** entry naming the slot, the actual longest edge, and the reason no substitute was used. Do not silently ship a sub-floor cover.

**Do not upscale.** `cwebp -resize 3200 0 <1600px-src>` and `sips -Z 3200 <1600px-src>` both produce a 3200 px file with no extra detail — Lanczos-upscaled blur encoded into a larger payload. The brochure ends up larger without being sharper. The floor is a constraint on the source pixel count, not the output dimension.

**Re-encoding.** Optimise from the highest-quality available source (the original JPEG/PNG, never a previously-optimised WebP). Lossy → lossy round-trips stack compression artifacts; that's how a 1600 px webp at q82 ends up looking soft even though its pixel count is fine.

```bash
# Print-master encoder defaults (these are the only source-side widths used):
cwebp -q 90 -m 6 -mt -resize 2800 0 <src.jpg> -o <dst.webp>   # full-bleed / hero
cwebp -q 88 -m 6 -mt -resize 2200 0 <src.jpg> -o <dst.webp>   # half-page / story
cwebp -q 84 -m 6 -mt -resize 1600 0 <src.jpg> -o <dst.webp>   # gallery / strip thumb
cwebp -q 92 -m 6 -mt -resize 2000 0 <src.jpg> -o <plan.webp>  # floor plan / map
```

The web and print PDFs are independent renders at different DPIs (see **PDF deliverables** below) — they share no compression chain, so each can be regenerated without affecting the other. The source WebP is never re-encoded for screen use.

## PDF deliverables — image-only, two grades

The brochure ships **two PDFs**, both built from per-page snapshots of the rendered HTML. Each PDF page is one full-bleed PNG image — no embedded fonts, no soft masks beyond what's baked into the snapshot, no Ghostscript anywhere in the pipeline. This is deliberate: every PDF viewer (macOS Preview, WhatsApp, Outlook attachments, mobile email previewers, web viewers) renders image-only PDFs the same way they render scanned documents — fast, predictable, never spinning.

| File | Resolution | Snapshot size | Typical PDF size (16-page folio) |
|---|---|---|---|
| `<property_slug>-brochure-web.pdf` | 192 dpi | 2246×1588 px per page | **25–45 MB** |
| `<property_slug>-brochure-print.pdf` | 300 dpi | 3509×2481 px per page | **65–100 MB** |

Both files are linearized — page 1 sits at the front of the byte stream so streaming viewers (WhatsApp, mobile webmail) display the cover before the rest arrives.

### Why image-only — and what was wrong with the old Ghostscript pipeline

The previous pipeline ran `Chromium → page.pdf() → Ghostscript pdfwrite` and produced "real PDFs" with selectable text. In practice it broke in three distinct ways that all manifested as the same user-visible symptom — slow renders, spinners, occasional crashes — across Preview, WhatsApp, and Mail:

1. **Ghostscript downgrades the web fonts to Type 3.** Each glyph is rewritten as a tiny PostScript program with the font name stripped to `[none]`. `pdffonts` reveals dozens of `Type 3 / [none]` entries. Mobile and messenger PDF viewers either render Type 3 glyphs slowly (re-interpreting PostScript per paint) or skip them entirely.
2. **Ghostscript's linearization writes a malformed hint table.** `pdfinfo` reports `Optimized: yes`, but `qpdf --check` fails with `ERROR: read_bits: too many bits requested. qpdf: errors detected`. WhatsApp's streaming viewer parses the same hint table the same way and falls back to download-everything-then-render — or errors out. Adding `-dFastWebView=true` to the gs invocation does not fix this; it just produces a "linearized but corrupt" PDF.
3. **Embedded ICC profiles per image and soft masks for transparency** add colour-management and alpha-compositing overhead on every paint, even in non-streaming viewers like Preview.

None of these survive a switch to image-only. The only thing we lose is selectable text — and Ghostscript's Type 3 conversion already destroyed practical text selection (the font name is `[none]`; copy-paste yields gibberish). So the trade-off in real terms is zero.

### Render pipeline

For each grade, render every `<section class="page">` element to a PNG via Playwright at the target deviceScaleFactor:

| Grade | viewport (CSS px) | deviceScaleFactor | Resulting PNG dimensions |
|---|---|---|---|
| Web (192 dpi) | 1123 × 794 (landscape) / 794 × 1123 (portrait) | 2 | 2246×1588 |
| Print master (300 dpi) | same as above | 3.125 | 3509×2481 |

Reference Playwright script (`render-snapshots.js`):

```javascript
const { chromium } = require('playwright');
const path = require('path');

const HTML = process.argv[2];        // path to brochure.html
const OUT_DIR = process.argv[3];     // dir to write PNGs into
const SCALE = parseFloat(process.argv[4] || '3.125');  // 2 for web, 3.125 for print

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext({
    viewport: { width: 1123, height: 794 },          // 794 × 1123 if portrait
    deviceScaleFactor: SCALE,
  });
  const page = await context.newPage();
  await page.goto(`file://${HTML}`, { waitUntil: 'networkidle' });
  await page.evaluate(() =>
    Promise.all(Array.from(document.images, img =>
      img.complete ? null : new Promise(r => { img.onload = img.onerror = r; }))));

  const pages = await page.$$('section.page');
  for (let i = 0; i < pages.length; i++) {
    const label = await pages[i].getAttribute('data-screen-label');
    const filename = i === 0
      ? 'cover-print.png'
      : /back/i.test(label || '') ? 'backpage-print.png' : `page${i+1}-print.png`;
    await pages[i].scrollIntoViewIfNeeded();
    await pages[i].screenshot({ path: path.join(OUT_DIR, filename), type: 'png' });
  }
  await browser.close();
})();
```

Then bind PNGs into a PDF and linearize. img2pdf preserves the embedded image bytes verbatim — no re-encode, no Type 3 downgrade, no quality loss. qpdf is the canonical PDF linearizer; its hint table is correct and `qpdf --check`-clean.

```bash
# Build print master from 300 dpi PNGs (lossless embed)
img2pdf --pagesize 297mmx210mm \
        --title "<Property Name> · <address line 1>" \
        --author "<Agent Name>" \
        --output /tmp/pre-linearize.pdf \
        cover-print.png page2-print.png … page15-print.png backpage-print.png

qpdf --linearize --object-streams=disable \
     /tmp/pre-linearize.pdf \
     <property_slug>-brochure-print.pdf

rm /tmp/pre-linearize.pdf
```

Repeat with the 192 dpi PNGs from the web render pass to produce `<property_slug>-brochure-web.pdf`.

`--object-streams=disable` is required — qpdf's default packs cross-references into object streams, which the PDF 1.4 linearization profile we want for maximum viewer compatibility cannot use. With that flag the output is a clean PDF 1.3.

### Verification — three commands, all must pass

```bash
qpdf --check <output>.pdf      # → "No syntax or stream encoding errors found"
pdfinfo  <output>.pdf | grep Optimized   # → "Optimized: yes"
pdffonts <output>.pdf | wc -l   # → 2 (the header rows; no font entries)
```

If any fails, do not ship the file. The most common failure mode is a stale snapshot PNG getting bound — re-render and rebuild.

### When file size is a concern

The "lossless PNG embedded" outputs above are intentional for print quality. If a property's web PDF runs into a transport limit (some webmail caps at 25 MB), substitute JPEG-encoded snapshots: `magick page-print.png -quality 88 -strip -interlace none -colorspace sRGB page-print.jpg`, then bind the `.jpg` files instead of `.png`. Expect ~30–40% size reduction with no visible quality loss at 192 dpi. The print master should always remain lossless PNG-embedded.

## Even-page count for duplex printing

Brochures must have an **even** page count. Duplex printing pairs page N with page N+1 on the front and back of one sheet; an odd count leaves the back of the final sheet blank, which reads as a production error to the print buyer. Saddle-stitch booklet binding is even stricter — the page count must be a **multiple of 4** (each folded sheet contributes 4 pages).

**The canonical folio is 16 pages.** It satisfies both constraints (16 is even; 16 ÷ 4 = 4 folded sheets) and is the structure the property-brochure SKILL ships in `references/template.html`. Do not add or remove pages without re-checking the parity.

If a brand legitimately needs an 8-, 12-, or 20-page variant, the count must still be even (and ideally a multiple of 4). **Consult the user before changing the page count** — an odd-page variant is never acceptable, and a different even count changes the chapter structure enough that it deserves a deliberate decision rather than a silent drift.

If a layout change pushes content onto a new page or merges two pages, re-count after the change and add or remove a page (typically a full-bleed lifestyle/area photograph break) to restore the even count. Don't ship a brochure that rounds to 11 or 13.

## Image overlay text — legibility floor

Any text rendered on top of a photograph (image captions, kicker labels, page numbers placed over heroes, prices on a cover hero, garden-page chapter headings overlaid on aerials) must remain legible regardless of what region of the photo it lands on. Light gold-cream italic text on a "mostly-dark" photo is **not** acceptable — light pavement, sky, snow, or whitewashed walls in the underlying photo will swallow the text and the brochure ships with an invisible caption.

**Required pattern — pick one:**

1. **Translucent dark scrim panel** (preferred for editorial captions). The text sits inside a small opaque-ish panel at the corner of the photo. Alpha must be **≥ 0.45** on a dark colour (e.g. `rgba(20,20,20,0.55)` or `rgba(40,40,40,0.62)`). Add `padding: 10–14px 14–18px` and a subtle `border-radius: 2px` for an editorial feel.
2. **Strong text-shadow** (acceptable for short overlay headings, e.g. chapter titles on a full-bleed aerial). Blur radius **≥ 4 px**, alpha **≥ 0.6** on the shadow colour: `text-shadow: 0 1px 6px rgba(0,0,0,0.7)`.
3. **Both**, if the underlying photo's caption region varies a lot in luminance across the brochure's photos (e.g. an editorial line that overlays the bottom-right of every photo, where some are sky and some are stone).

```css
/* Pattern 1 — editorial caption with scrim panel */
.photo-caption {
  position: absolute; bottom: 10mm; left: 8mm; right: 8mm;
  background: rgba(20,20,20,0.62);
  color: rgba(253,253,253,0.96);
  padding: 12px 16px 14px;
  border-radius: 2px;
  text-shadow: 0 1px 6px rgba(0,0,0,0.7);
}

/* Pattern 2 — overlay heading with text-shadow only (gradient already provides scrim) */
.aerial-overlay {
  color: var(--paper-25);
  text-shadow: 0 1px 6px rgba(0,0,0,0.7);
}
```

**Forbidden patterns** for text-on-photo:

- Light-coloured text (gold-cream, white, paper-tone) on a photo with **no scrim panel** and **no text-shadow** — the cover-edge and the photo-bottom may both be light in the underlying image.
- Text relying on a `linear-gradient` overlay that fades from `transparent` to a dark colour at one edge of the photo only — works for that edge, fails when the caption is positioned elsewhere or when the photo doesn't darken at that edge.
- `mix-blend-mode: difference` or similar tricks that depend on the photo's content. Print engines render these inconsistently; some flatten them and the text disappears.

**Verification.** Read each rendered print snapshot that has an overlay caption and confirm the caption is legible at 1× zoom. Specifically check captions positioned over: light pavement, white walls, snow/sky, sun-bleached lawn. If a caption sits over an unscrimmed light region, the rule above isn't applied.

## Document background for multi-page screen viewing

Multi-page documents use a neutral body background (e.g. `body { background: var(--background); }`) on screen. In `@media print`, reset to `body { background: white; }`.
