| name | marp-slide-overflow |
| compatibility | Requires Node.js with marp-cli, mermaid-cli for pre-rendered diagrams, Puppeteer for overflow detection, and LibreOffice (soffice) for editable PPTX export. |
| description | Detect and fix silent content overflow in Marp slide decks before exporting to PPTX/PDF/PNG (anything taller than the 1280x720 viewBox is clipped with no warning). Also covers pre-rendering mermaid fences to SVG, a PNG-based visual verification workflow, a Puppeteer overflow detector, dense/compact CSS density tiers, a fillRatio decision table, and selectable-text PPTX export. USE FOR: Marp overflow, slide content clipped, content cut off in PPTX, slide overflow detection, Marp scrollHeight, dense/compact class, fillRatio, marp-cli overflow, Marp backgroundColor frontmatter, Marp mermaid not rendering, mermaid-cli mmdc, pre-render mermaid SVG, mermaid missing in PDF/PPTX, verify slide fits PNG, split slide vs shrink, editable PPTX, selectable text PPTX, marp pptx-editable, SOFFICE_PATH, LibreOffice PPTX, editable PPTX notes, speaker notes dropped, pptx-editable notes missing, copy pptx notes, python-pptx notes. DO NOT USE FOR: Reveal.js, Slidev, PowerPoint authoring, generic CSS layout, font rendering bugs. |
Marp Slide Overflow — Detect, Fix, Verify
When to Use
- A Marp deck exports cleanly to HTML preview but content is missing in PPTX/PDF/PNG.
- Tables, code blocks, or long paragraphs look truncated in the rendered output.
- You added content to a slide and aren't sure if it still fits.
- You need a CI gate that fails the build if any slide overflows.
- You want a single browser-based view that puts source markdown next to the rendered slide image so you can review the entire deck quickly.
The Root Cause: Silent Clipping
Marp wraps every slide in:
<svg data-marpit-svg viewBox="0 0 1280 720">
<foreignObject>
<section>
</section>
</foreignObject>
</svg>
The <section> has overflow: hidden applied by the Marpit theme. Anything taller than 720 px is silently clipped. There is no warning in the Marp CLI output, no red error in the VS Code preview, no hint in the PPTX. The bottom of your table just isn't there.
This bites hardest when:
- A code block, table, or markdown list grows over time and crosses the 720 px threshold.
- A custom CSS theme reduces line-height or font-size in some places but not others, making overflow inconsistent.
- The deck builds from a single source file into multiple sub-decks (1h / 2h / 4h pattern), so the same slide may overflow in one variant but not another.
The Detection Strategy
The only reliable way to detect overflow is to render the deck and measure each section's scrollHeight against the viewBox height. Line-count or character-count heuristics miss tables, code blocks with long lines, and CSS-driven layouts.
Marp source.md
│
▼
marp --html → rendered.html (one file with N <svg><section> per slide)
│
▼
Headless Chromium (Puppeteer) loads rendered.html
│
▼
For each <section>:
contentHeight = section.scrollHeight ← includes clipped overflow
frameHeight = svg.viewBox.height ← the visible frame (720)
overflowY = max(0, contentHeight - frameHeight)
fillRatio = contentHeight / frameHeight
scrollHeight reports the full content height including the clipped portion, which is exactly the diagnostic we need.
Gotcha: Marp does not render ```mermaid fences
Marp CLI has no built-in mermaid support. A ```mermaid fenced code block is emitted into the rendered HTML/PDF/PPTX as a literal <pre><code class="language-mermaid">…</code></pre> block — never a diagram, and with no warning. Client-side mermaid.js plugins only work in --html output; they leave a static <pre> in --pdf/--pptx. The reliable fix is to pre-render every ```mermaid fence to an SVG on disk during deck assembly (via mermaid-cli / mmdc) and replace the fence with a  image reference, then constrain image height in CSS so the SVG fits the 720 px viewBox.
Full recipe — mmdc pre-render script, mermaid syntax gotchas ({}/()/[] in labels, <br/>, backticks), regression detection, and diagram-sizing CSS: references/mermaid-prerender.md.
Gotcha — section img { display: block } pushes inline emoji onto their own line
The diagram-sizing rule above (section img { … display: block; … }) is a broad selector: it matches every <img> Marp emits, not just your mermaid SVGs. If the theme renders emoji as images (Twemoji — the default on many Marp themes turns ☕, 🐢, 🌐 into <img class="emoji">), display: block forces each emoji onto its own line, so a bullet like … at 2 a.m. ☕ wraps the coffee cup to a new line and a one-line contact row (🌐 site 🐙 github 🐦 x) collapses into a vertical stack.
Fix: scope a counter-rule for the emoji class, on the affected slide (<style scoped>) or globally:
img.emoji { display: inline; height: 1em; width: 1em; vertical-align: -0.12em; margin: 0 0.15em 0 0; max-height: none; }
max-height: none is required — the diagram rule's max-height otherwise still applies and distorts the glyph. This only surfaces in the PNG/PPTX render, not always in the live preview, so catch it in Recipe 0.
Recipe 0: PNG-based visual verification (mandatory before claiming "fixed")
Text-heuristic overflow checks (counting <li> elements, total character length, raw scrollHeight) miss the cases that matter most: oversized images, tables with wrapped cells, code blocks with long lines. The only reliable signal that a slide actually fits is a rendered PNG of that slide:
- Render every slide to PNG (
marp --images png --image-scale 1) — one 1280×720 PNG per slide.
- Flag at-risk slides programmatically (tables, images/SVGs, code blocks > ~6 lines, lists > ~7 bullets).
- For each at-risk PNG check three invariants: title visible at top, footer page number visible at bottom-right, no half-cut rows at the bottom edge.
- Fix (
dense/compact class, content trim, or split), re-render, re-check until clean. Hand the PNGs to a fresh subagent with an adversarial prompt for a final pass.
Full recipe — render/at-risk-detection scripts, the three-invariant checklist, the adversarial subagent QA prompt, and the "smoke alarm vs. gate" anti-patterns (heuristics and the HTML preview both lie): references/png-verification.md.
Recipe 1: Minimal Overflow Detector (Node + Puppeteer)
The only reliable programmatic overflow check renders the deck to HTML and measures each <section>'s scrollHeight against the viewBox height in headless Chromium (scrollHeight includes the clipped overflow). A ~60-line overflow-check.mjs Puppeteer script emits per-slide overflowY / overflowX / fillRatio and exits non-zero when any slide overflows — wire it into the build as a gate.
Full recipe — the complete overflow-check.mjs detector, its package.json, and the render → measure → cleanup build wiring: references/overflow-detector.md.
Recipe 2: Two-Tier CSS Density Pattern
When a slide overflows, the first instinct is to split it. Resist that — slide count usually has semantic meaning (e.g. agenda timing). Instead, define two density tiers in the deck's frontmatter and tag overflowing slides with the appropriate class:
section.dense {
font-size: 20px;
}
section.dense h1 { font-size: 1.4em; margin-bottom: 0.2em; }
section.dense h3 { font-size: 1.0em; margin-top: 0.2em; margin-bottom: 0.1em; }
section.dense pre { padding: 8px; font-size: 0.85em; }
section.dense blockquote { margin-top: 0.3em; margin-bottom: 0.3em; }
section.compact {
font-size: 18px;
}
section.compact h1 { font-size: 1.3em; margin-bottom: 0.15em; padding-bottom: 0.1em; }
section.compact h2 { font-size: 1.15em; }
section.compact h3 { font-size: 0.95em; margin-top: 0.15em; margin-bottom: 0.1em; }
section.compact p { margin-top: 0.4em; margin-bottom: 0.4em; }
section.compact pre { padding: 6px; font-size: 0.8em; }
section.compact table { font-size: 0.65em; }
section.compact th, section.compact td { padding: 3px 6px; }
section.compact blockquote { margin: 0.25em 0; padding: 0.4em 0.6em; }
section.compact ul, section.compact ol { margin-top: 0.25em; margin-bottom: 0.25em; }
section.compact li { margin-top: 0.1em; }
Apply to a slide via Marp's per-slide directive:
---
<!-- _class: compact -->
# Slide That Used to Overflow
| col | col | col |
|-----|-----|-----|
| ... | ... | ... |
Empirical capacity (measured against the same content):
- Default → 100 %
dense → ~120 %
compact → ~133 %
So compact gives ~13 % more capacity than dense and ~33 % more than the default theme.
Recipe 3: fillRatio Decision Table
Read the fillRatio column from the detector and pick the smallest fix that works:
| fillRatio | What it means | Recommended fix |
|---|
| ≤ 1.00 | Fits with room to spare | Nothing |
| 1.00–1.05 | Tiny overflow (rounding) | <!-- _class: dense --> |
| 1.05–1.20 | Moderate overflow | <!-- _class: dense --> |
| 1.20–1.30 | Heavy overflow | <!-- _class: compact --> |
| 1.30–1.40 | Severe overflow | compact + minor content trim |
| > 1.40 | Way over | Trim first (drop a section, condense bullets), then compact; split only if content is genuinely two ideas |
Rule of thumb: anything below 5 px (overflowY < 5) is rendering rounding noise — leave it alone, the PPTX will look fine.
Recipe 4: Side-by-Side Review Report
The detector tells you which slide overflows, but not what is being clipped. To review every slide visually without flipping through the binary PPTX:
- Export the deck to PNGs once:
npx @marp-team/marp-cli@latest deck.md --images png --allow-local-files -o png-out/slide. Marp produces slide.001, slide.002, etc. (no extension — rename with Get-ChildItem | Where-Object { $_.Extension -ne '.png' } | Rename-Item -NewName { $_.FullName + '.png' }).
- Re-parse the source markdown into per-slide blocks (see "Phantom Section" gotcha below).
- Generate one HTML file per deck variant with two columns per slide:
<pre> of the source markdown, <img> of the rendered PNG, plus an OVERFLOW / fits badge from the detector results.
- Add a sticky toolbar at the top with one-click links to the overflowing slides.
Open the HTML in any browser and scroll. Overflowing slides get a red left-border and a striped clip-marker bar across the bottom of the PNG, making them obvious at a glance. This is far faster than opening the PPTX, especially for decks with > 50 slides.
Recipe 4b: Selectable-Text PPTX (--pptx-editable)
Marp's default --pptx export rasterises one image per slide — pixel-perfect but the
text is not selectable, searchable, or editable in PowerPoint. marp-cli's experimental
--pptx-editable flag instead emits real text shapes by shelling out to LibreOffice
(soffice).
# Image PPTX (default): every slide is a bitmap, ~MBs, text NOT selectable
npx @marp-team/marp-cli@latest deck.md --allow-local-files -o deck.pptx
# Editable PPTX: real text shapes, ~KBs, selectable/searchable
$env:SOFFICE_PATH = 'C:\Program Files\LibreOffice\program\soffice.exe' # if not on PATH
npx @marp-team/marp-cli@latest deck.md --pptx --pptx-editable --allow-local-files -o deck.editable.pptx
Key facts:
- Requires LibreOffice. marp shells out to
soffice; it honours the SOFFICE_PATH
env var, otherwise it must be on PATH. No LibreOffice = the flag silently does nothing
useful or errors.
- Ship both. Keep the image PPTX when pixel fidelity matters; ship the editable PPTX
when the audience needs to copy text, search, or re-style. They are different artefacts,
not a replacement.
- Never mutate the canonical deck. The two LibreOffice fixes below inject editable-only
CSS. Feed the editable export its own assembled copy of the markdown (e.g.
dist/deck.editable.assembled.md) and leave the canonical deck untouched, so the image
PPTX and HTML preview keep their original bold tables and webfont code styling.
- Size tell. The editable PPTX is typically ~40x smaller than the image PPTX (text
shapes vs. embedded bitmaps) — a quick sanity check that the editable path actually ran.
- Silent exit 1 when the target is open. If the destination
.pptx is open in
PowerPoint, marp exits with code 1 and no error text — the LibreOffice file lock
fails the write. Close the deck (or write to a fresh filename) before re-running.
Fix LibreOffice rendering bugs (editable path only)
LibreOffice's multi-slide HTML→PPTX conversion is not simply lower fidelity — it has
four concrete, fixable corruption bugs. The first three are fixed by injecting CSS into the
editable-only assembled markdown (do not apply these to the canonical deck); the fourth
(dropped speaker notes) cannot be fixed in CSS and is repaired by a post-export graft,
covered last.
Bug 1 — digit glyphs dropped from bold numeric table cells. LibreOffice silently drops
digits from bold numeric cells during the HTML→PPTX pass: Haiku 4.5 renders as
Haiku ., $1.618455 as $ .. The fix is to render table text non-bold so the cells
use a glyph set LibreOffice keeps intact:
table th, table strong, table b { font-weight: normal !important; }
Rejected workaround — wider substitute font. Forcing a wider substitute font onto the
table does not fix it: it clips leading digits from dense cells instead
(101,747 → 0 ,747). Non-bold is the only reliable fix; do not chase the font swap.
Bug 2 — inline code falls back to an ugly font. The deck's monospace webfont cannot
be embedded by LibreOffice, so inline code and code blocks render in an arbitrary fallback.
Pin them to fonts LibreOffice ships with:
code, pre, pre code {
font-family: "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace !important;
}
Bug 3 — inline code sits below the body baseline. Themes (including Marp's default)
shrink inline code to ~0.9em and add padding/background. LibreOffice renders that smaller
inline run dropped below the surrounding text's baseline instead of centring it, so
Get-LabAzureAvailableSku floats low against the sentence around it. Normalise inline code
to the body text's metrics — full size, baseline alignment, no padding — and leave code
blocks (pre code) alone:
:not(pre) > code {
font-size: 1em !important;
vertical-align: baseline !important;
line-height: inherit !important;
padding: 0 !important;
}
Confirm via the <a:t> run inspection below: the inline-code run's sz (font size, in
hundredths of a point) should now equal the adjacent body run's sz, with no baseline
offset attribute — equal size on a shared baseline is what makes them line up.
Combine all three rules into a single <style> block in the editable-only assembled markdown,
then verify the result with the <a:t> run inspection below — confirm the numeric cells
survived (search the extracted runs for the digits that were being dropped) and the inline
code size matches the body.
Bug 4 — speaker notes dropped. Marp's native --pptx export writes each slide's
HTML-comment speaker notes (<!-- ... -->) as PowerPoint notes. The --pptx-editable
export round-trips through LibreOffice, which emits a PPTX with no ppt/notesSlides/
parts and no notes master — every slide's notes are gone, silently, with no warning.
Unlike Bugs 1–3 this is not fixable with editable-only CSS: the notes never reach the
slide body, so there is no markup to restyle. Repair it with a post-export graft instead.
Detect — a PPTX is a ZIP; native decks contain ppt/notesSlides/notesSlideN.xml parts,
the editable deck contains none:
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead((Resolve-Path 'deck.editable.pptx').Path)
($zip.Entries.FullName -like 'ppt/notesSlides/notesSlide*.xml').Count # 0 => notes dropped
$zip.Dispose()
Fix — graft notes from a native render. Render a throwaway native PPTX from the
same editable assembled markdown (guarantees identical slide count and order), then copy
its notes into the editable deck slide-by-slide with python-pptx, which recreates the
notes slides, notes master, relationships, and [Content_Types].xml overrides
automatically:
$assembled = 'dist/deck.editable.assembled.md'
# editable deck (built above) has no notes; render a throwaway NATIVE deck from the SAME
# assembled markdown so slide order matches 1:1, then graft its notes onto the editable deck.
npx @marp-team/marp-cli@latest $assembled --pptx --allow-local-files -o dist/deck.notes.pptx
python build/Copy-PptxNotes.py dist/deck.notes.pptx dist/deck.editable.pptx
Copy-PptxNotes.py loads both decks, zips src.slides with dst.slides, and assigns the
notes text for every source slide that has non-empty notes (accessing notes_slide on the
destination creates the notes slide, notes master, and relationships on demand):
"""Copy speaker notes from a native Marp PPTX into the editable (LibreOffice) PPTX.
Usage: python Copy-PptxNotes.py <src-native.pptx> <dst-editable.pptx>
The --pptx-editable export drops all notes; the native --pptx export keeps them. Both decks
must be rendered from the SAME assembled markdown so slide order matches 1:1.
"""
import sys
try:
from pptx import Presentation
except ImportError:
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", "python-pptx"])
from pptx import Presentation
def main(src_path, dst_path):
src = Presentation(src_path)
dst = Presentation(dst_path)
if len(src.slides) != len(dst.slides):
print(f"WARNING: slide count differs (src={len(src.slides)}, "
f"dst={len(dst.slides)}); notes may be misaligned", file=sys.stderr)
grafted = 0
for s, d in zip(src.slides, dst.slides):
if not s.has_notes_slide:
continue
text = s.notes_slide.notes_text_frame.text
if text.strip():
d.notes_slide.notes_text_frame.text = text
grafted += 1
dst.save(dst_path)
print(f"Grafted notes onto {grafted} slide(s)")
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("Usage: python Copy-PptxNotes.py <src-native.pptx> <dst-editable.pptx>")
main(sys.argv[1], sys.argv[2])
Dependencies: Python 3 + python-pptx. The script auto-installs python-pptx on first
run, consistent with the build already needing internet access for npx / marp-cli.
Verify — count the editable slides that now carry non-empty notes:
python -c "from pptx import Presentation; p=Presentation('deck.editable.pptx'); print(sum(1 for s in p.slides if s.has_notes_slide and s.notes_slide.notes_text_frame.text.strip()),'slides with notes')"
Proven end-to-end in raandree/PSConfProxmoxSession
— build.ps1 region "3b — Restore speaker notes in the editable PPTX" plus
build/Copy-PptxNotes.py: 41/41 slides carry notes after the graft, while the canonical
image deck and HTML preview stay untouched. The editable deck remains a second artefact;
this fix just makes it notes-complete (ship both / never mutate the canonical deck).
Verify the text really is selectable (not just a relabeled image PPTX)
A PPTX is a ZIP; slide text lives in <a:t> runs inside ppt/slides/slideN.xml. Zero
<a:t> runs means you got an image deck.
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead((Resolve-Path 'deck.editable.pptx').Path)
$slide1 = ($zip.Entries | Where-Object FullName -like 'ppt/slides/slide*.xml' | Sort-Object FullName)[0]
$sr = New-Object IO.StreamReader($slide1.Open()); $xml = $sr.ReadToEnd(); $sr.Close(); $zip.Dispose()
([regex]::Matches($xml, '<a:t>(.*?)</a:t>')).Count # > 0 => selectable text
Visually diff editable vs. image (reuse the PNG workflow)
- Reference PNGs from marp:
npx @marp-team/marp-cli@latest deck.md --images png --allow-local-files -o ref/slide.png.
- Render the editable PPTX to PDF, then to PNG:
soffice --headless --convert-to pdf --outdir ed deck.editable.pptx, then pdftoppm -png -r 96 ed/deck.editable.pdf ed/slide (pdftoppm ships with poppler / MiKTeX).
- Pair
ref/slide.NNN.png against ed/slide-NN.png in a two-column HTML report (Recipe 4) and scroll. Expect prose to match and code blocks to differ.
Provisioning LibreOffice on a locked/non-admin box: if winget install TheDocumentFoundation.LibreOffice fails with MSI error 1618 ("another installation
is already in progress") and you cannot elevate, download the LibreOffice MSI and extract
it portably with lessmsi x <msi> C:\LO_portable\ — no Windows Installer engine, no
admin. Point SOFFICE_PATH at C:\LO_portable\SourceDir\LibreOffice\program\soffice.exe.
First headless run self-initialises; isolate its profile with
-env:UserInstallation=file:///C:/LO_portable/profile.
Critical Gotcha: The Phantom Leading Section
When a Marp build script writes a slide separator (---) immediately after the closing --- of the YAML frontmatter, Marp emits an empty leading <section> in the rendered HTML. Any tool that maps source-markdown slide indices to rendered slide numbers must include that phantom — otherwise everything is off-by-one for the rest of the deck.
Detection
In your rendered HTML:
$h = Get-Content rendered.html -Raw
[regex]::Matches($h, '<section\b').Count # rendered section count
In your source markdown, count --- separators (excluding the two frontmatter delimiters) and add 1 for the first slide. If the rendered count is exactly one higher than that, you've got a phantom.
Fix in source-markdown slicer
When parsing the source for a side-by-side report, always add an empty slide entry on the very first separator after the frontmatter, even if the buffer is empty:
# Wrong (off-by-one):
if ($cur.Count -gt 0) { $slides.Add(($cur -join "`n")) }
# Right (matches Marp pagination):
$slides.Add(($cur -join "`n").Trim("`n")) # always add, even if empty
This was a real bug found in production tooling — slides 1..N had source/rendered swapped by one position, which made debugging "why does slide 23 show slide 22's content" extremely confusing until the section count and separator count were compared.
Critical Gotcha: Frontmatter backgroundColor Wins Over Class CSS
Marp's YAML frontmatter backgroundColor: (and color:) directive is not translated to a CSS rule — it is injected as an inline style="..." attribute on every <section> element. Inline styles beat any class-based selector in the style: block, regardless of specificity. This silently breaks two common patterns:
-
Section dividers with a coloured background
---
marp: true
backgroundColor: "#ffffff"
color: "#1e293b"
style: |
section.section-divider {
background: linear-gradient(135deg, #0c4a6e, #0369a1);
color: #ffffff;
}
section.section-divider h1 { color: #ffffff; }
section.section-divider h2 { color: #bae6fd; }
---
The gradient is never rendered. The section still has the white inline background. The h1 stays #ffffff → white-on-white, invisible headline. The h2 stays #bae6fd → light cyan on white, fails WCAG contrast (~1.4:1, looks like a faded watermark).
-
<!-- _class: lead --> slides expecting a tinted background
Same root cause — the section.lead { background: ... } rule is overridden by the inline style.
How to detect it
Render the deck once with marp-cli --html and grep the output:
$h = Get-Content rendered.html -Raw
# Inline style attribute on a section (not data-style):
$rx = [regex]'(?<![-\w])style="([^"]*)"'
$rx.Matches($h) | Select-Object -First 3 | ForEach-Object { $_.Groups[1].Value }
# You will see: ...background-color:#ffffff;background-image:none;color:#1e293b
The background-image:none part is the smoking gun — it actively erases any background: linear-gradient(...) set by a class rule.
Visual symptom in the side-by-side review report: the section-divider PNG looks identical to a regular content slide, with the heading text either missing or barely legible.
Fix options
Option A — Tune class-based text colours to the inline background (recommended). Accept that frontmatter wins, treat dividers as same-background-as-body, and use dark text on the white background:
section.section-divider {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: #1e293b;
}
section.section-divider h1 { color: #0c4a6e; border-bottom: none; }
section.section-divider h2 { color: #0369a1; }
This preserves the visual rhythm (centred, larger heading, no border) without fighting the engine.
Option B — Drop the frontmatter directive and set both palettes in the style: block. Move backgroundColor/color out of YAML and into a base section { ... } rule, where class-based rules can override on equal footing:
---
marp: true
style: |
section { background-color: #ffffff; color: #1e293b; }
section.section-divider {
background: linear-gradient(135deg, #0c4a6e, #0369a1);
color: #ffffff;
}
---
Now both rules are class-based and the divider gets its gradient. Trade-off: Marp's per-slide <!-- _backgroundColor: ... --> comment directive stops working (it relies on the YAML form), so use this only when you do not need per-slide background overrides.
Option C — Per-slide <!-- _backgroundColor: ... --> comment. Apply a one-off inline override on each divider slide. Verbose for many dividers but the only path that gives you a different background per slide while keeping the global default.
The lesson
Never rely on section.<class> { background: ... } to override a YAML backgroundColor:. Either match the text palette to the inline background (Option A) or move backgrounds entirely into the style: block (Option B). Always check at least one section-divider / lead slide in the side-by-side review report after any palette change — a low-contrast headline is the canary that frontmatter is silently winning.
Recommended Workflow
Edit source.md
│
▼
Build version files (if multi-version deck)
│
▼
overflow-check (gate) ──────▶ any overflow? ──── yes ──▶ Apply fix:
│ • dense / compact directive
│ • content trim
│ • last resort: split
│ no
▼
Side-by-side review report (visual sanity check before publishing)
│
▼
Export PPTX / PDF / PNG
│
▼
Commit
Wire overflow-check into your build script so the build exits non-zero on any overflow. This makes it impossible to commit a deck with silently-clipped content.
Recipe 5: Speaker-Note Coverage — Gotchas and a Pester Guard
Marp speaker notes are HTML comments inside a slide; they render in presenter mode and export as PPTX slide notes, but are invisible in the rendered slide. Auditing "does every slide have notes?" has three traps: (A) --- inside a code fence creates phantom slides — the auditor must be code-fence-aware and mirror the build's slide-splitter; (B) Marp directives (version:, _class:, _paginate:, _color:, _backgroundColor:, fit, _split_) are HTML comments too — filter by prefix blocklist plus inner-text length > 40 chars; (C) section-divider slides typically carry a per-module appendix note, so assert them separately.
Full recipe — the four gotchas in depth (including a premature --> leaking the rest of a note onto the slide), a drop-in Pester 5 guard (Get-MarpSlide + Test-SlideHasNote in BeforeAll), the notes-title-map.psd1 title-drift pattern for multi-file decks, and the <!-- _split_ --> marker explanation: references/speaker-note-guard.md.
Critical Gotcha: @import Globs Inside Comments Re-Trigger the Assembler
Multi-file decks usually have a build step that inlines partials by matching @import "…" lines (Marpit's @import syntax, or a custom assembler in build.ps1). That matcher is almost always a plain text/regex scan — it does not know it is inside an HTML comment, a code fence, or a presenter note. So a line like this, written as a harmless reminder…
<!--
Build note: the @import "sections/*.md" lines above are resolved by build.ps1.
-->
…gets matched by the assembler, which expands the glob and re-imports every section file. The deck silently doubles (e.g. 49 → 82 slides, 2 → 4 mermaid diagrams, with a burst of "new" overflows that are really duplicated slides).
Diagnosis: the slide-count and mermaid-count both jump after an edit that only touched a comment. Confirm by A/B build — git stash the change, rebuild, compare counts.
Rules:
- Never write a literal
@import "…" (especially a glob like *.md) inside any comment, note, or code fence in a deck the assembler will scan. Paraphrase it ("the section partials are inlined by the build") instead.
- If you must show the syntax, break the token so it can't match — e.g.
@import with a zero-width space, or describe it without quotes.
- Make the assembler ignore commented/fenced regions if you control it, but treat that as defence-in-depth, not the primary fix.
Operational Gotchas
- Puppeteer first-run cost:
npm install pulls ~150 MB of Chromium. Make your wrapper script bootstrap deps automatically (Test-Path node_modules → run npm install if missing) so users don't have to remember.
- Web fonts: Wait for
document.fonts.ready before measuring. If you measure too early, custom fonts haven't loaded and the section reports the wrong height.
- Multi-version decks: Run the check against every generated variant, not just the source. The same slide may fit in the 4h variant (which uses a
dense class on the previous slide that affects layout) but overflow in the 1h variant where surrounding context is different.
- PPTX export uses Chromium too: Marp CLI renders PPTX via headless Chromium, so what Puppeteer measures is exactly what ends up on the slide. There is no measurement-vs-export drift.
- 5 px tolerance: Treat
overflowY < 5 as rendering rounding noise. Trying to chase those last few pixels usually produces fragile content.
Anti-Patterns
| Anti-pattern | Why it fails |
|---|
| Counting markdown lines or characters as a heuristic | Tables, fenced code, and CSS layouts blow this up immediately |
| Eyeballing the VS Code Marp preview | The preview reflows freely; PPTX clips at exactly 720 px |
Inserting a global smaller font-size on section | Loses visual rhythm; default 24 px is correct for most slides — only the dense ones need the override |
| Splitting every overflowing slide | Inflates slide count, breaks agenda timing, hides the real problem (slide is doing too many jobs at once) |
Removing the overflow: hidden from the theme | Content escapes the slide bounds in the PPTX, looks broken |
| Off-by-one slide indexing without accounting for the phantom section | Reviewer compares the wrong source against the wrong rendered slide and trusts the wrong fix |
Setting section.<class> { background: ... } while frontmatter declares backgroundColor: | Frontmatter is injected as an inline style attribute and beats any class rule; the class background is dead code, and any white-on-coloured text colour set alongside it becomes invisible on the actual (white) background |
Reference Implementation
A complete reference implementation (Puppeteer detector, side-by-side report generator, density CSS, PowerShell wrapper) lives in AgenticOperatingModel/content/pptx:
overflow-check.mjs — Node + Puppeteer detector (Recipe 1)
Test-SlideOverflow.ps1 — PowerShell wrapper (orchestrates render → check → report)
New-SlideReviewReport.ps1 — Side-by-side HTML report generator (Recipe 4)
Build-MarpVersions.ps1 — Build script with -CheckOverflow and -Report switches
Copy-PptxNotes.py — python-pptx notes graft that restores speaker notes dropped by the --pptx-editable LibreOffice round-trip (Recipe 4b, Bug 4)
- The
compact and dense CSS variants are in content/slides/marp-presentation.md frontmatter (Recipe 2)