Skip to content

Add PDF export#10

Open
eyeofshreyas wants to merge 10 commits into
stackblitz:mainfrom
eyeofshreyas:feature/pdf-pptx-export
Open

Add PDF export#10
eyeofshreyas wants to merge 10 commits into
stackblitz:mainfrom
eyeofshreyas:feature/pdf-pptx-export

Conversation

@eyeofshreyas

Copy link
Copy Markdown

Summary

  • Adds a PDF export (E key): every slide is rasterized off-screen at 1920x1080 with builds fully revealed and animations settled, then assembled into one PDF with jsPDF. PPTX export was explored and dropped in favor of focusing on PDF.
  • Fixes several content-loss bugs found while hardening the capture path: components that skipped the isStatic gate (invisible content), CSS @keyframes-driven draw-ins in VisualDashboard that always played their full timeline regardless of isStatic, a font-loading race that could substitute the wrong monospace typeface, missing background color, and a -10000px offset trick that produced blank captures.
  • Switches capture to JPEG (was PNG) - a 26-slide deck dropped from 200MB+ to a fraction of that.
  • Adds per-slide error containment so one broken image identifies which slide failed instead of aborting the export with a generic error.
  • Documents the export shortcut in the README, which previously only listed it in the keyboard-shortcut table with no explanation of what it actually produces.

Test plan

  • npx tsc --noEmit && npm run build
  • Manually exported the demo deck and visually verified every slide, including click-builds (export fully revealed) and the VisualDashboard component's sparklines/line-chart/bars (previously blank/flat, now drawn)

Rasterizes each slide off-screen (isStatic context, same as thumbnails)
via html-to-image, then assembles a .pdf (jsPDF) or .pptx (pptxgenjs)
from the captured images. Both builder libs are dynamically imported
so they only load when export is actually triggered.
html-to-image clones inline styles into an SVG foreignObject, so a node
positioned with left:-10000px rendered 10000px outside the captured
canvas — every exported slide came back blank. Swap to a zero-size
clipped wrapper instead, which hides the capture host from the viewer
without moving the captured node itself off-screen.

Also drop the film-grain overlay during capture: foreignObject
rasterization doesn't honor mix-blend-mode, so the 3.5%-opacity grain
was rendering as full-strength noise over every exported slide.

Verified all 26 demo slides capture non-blank content and both the
PDF and PPTX build paths complete without error.
The offscreen capture host has no ancestor supplying the dark theme
background (that comes from body's CSS in the live app), so captured
slides were ~24% transparent. jsPDF/pptxgenjs then composited that
onto their default white page, turning the theme white and washing
out every semi-transparent gradient/overlay color designed against
the dark background.

Resolve --bg to a literal color and pass it as html-to-image's
backgroundColor option (a CSS var wouldn't resolve inside the
isolated SVG it rasterizes through). Verified via alpha-channel
inspection (now 100% opaque, was ~24% alpha=0) and visual check of
Cover, Bento, Globe, and Pricing slides.
Once the capture background is opaque, the PNG's alpha channel is
just dead weight — and jsPDF embeds PNG's RGBA data essentially
uncompressed, so the 26-slide demo deck came out at 215MB. JPEG at
quality 0.92 has no alpha channel to carry, compresses properly, and
brings the same deck down to ~1.2MB (PDF) / ~1.35MB (PPTX), verified
by building both outputs in-browser and checking byte size.
Slides with <Build> reveals are now captured as one layered image per
click step instead of a single flattened image. Build count comes
from walking the slide's element tree (no mounting needed, so it
can't race with useEffect timing during offscreen capture), and
Build.tsx now respects the real clicks count under isStatic so each
step can be rendered.

pptxgenjs has no animation API at all, so click-to-reveal is wired up
by hand-authoring PowerPoint's raw timing XML (p:timing/p:bldLst)
into the generated .pptx after the fact, via jszip (already a
transitive dep of pptxgenjs, now explicit). Each layer is tagged with
altText so its shape id — assigned internally by pptxgenjs — can be
looked up reliably instead of assumed. The base (unrevealed) layer is
never added to the build list, so even a broken timing block degrades
to 'that one slide shows its pre-click state' rather than losing
content.

PDF has no animation concept and keeps using only the final,
fully-built layer per slide.

Verified: single-build and 3-build synthetic slides produce
well-formed, schema-correct XML with correctly sequenced shape ids
and click targets; the real 26-slide demo deck exports cleanly with
exactly the one slide that has a <Build> getting timing XML. Real
PowerPoint validation wasn't possible in this environment (no
PowerPoint install, and LibreOffice's CLI didn't produce output here)
— structural/schema correctness is confirmed, but worth a real open
in PowerPoint before relying on it.
PPTX added real complexity (hand-authored PowerPoint animation XML,
unvalidatable in this environment) for a format that isn't the
priority. Removes pptxgenjs/jszip, the click-build layering in
export.tsx (back to one flattened, fully-built image per slide via
exportPdf), the export popover/menu in Deck.tsx (now a single button,
E triggers it directly), and the Build.tsx clicks-under-isStatic
change that only existed to support per-layer PPTX capture.

PDF was never actually losing build content — it always rendered the
final, fully-revealed state — but the simplification removes any
doubt: back to a single capture per slide, no layer indexing at all.
…ports

Found the real cause of the "missing data" report: Tabs, Comparison,
Table, and Timeline all play real Framer Motion entrance transitions
(0.25-0.9s, some with per-row/per-item stagger delays up to ~1s) that
were gated only on prefers-reduced-motion, never on the app's own
isStatic flag -- unlike every other animated component (Reveal, Build,
Charts, CountUp, StatGrid, Pricing, Steps, Bento, Team, Agenda,
Contrast), which all correctly derive animate = !isStatic && !reduce.

Export's offscreen capture renders with isStatic:true and only waits
two animation frames (~33ms) before snapshotting -- nowhere near
enough for these components' transitions to finish, so any slide
using Tabs/Comparison/Table/Timeline was captured mid-fade or fully
transparent, i.e. missing content in the PDF.

Fixed each to match the established isStatic-aware pattern used
everywhere else in the codebase (Tabs skips its AnimatePresence panel
transition entirely under isStatic, matching Reveal/Build;
Comparison/Table/Timeline gate initial/duration on isStatic || reduce,
matching Charts' DonutChart). Verified by rendering each of the four
previously-broken demo slides through the real export pipeline and
inspecting the output -- all content is now fully visible with no
fade-in artifacts.
…ror-containment fixes

VisualDashboard's sparklines, area-chart line, and weekly bars use CSS
@Keyframes (not Framer Motion or a transition), so they always played their
full draw-in timeline on mount regardless of isStatic and were captured
blank/flat. Neutralize them under .export-capture, mirroring the existing
prefers-reduced-motion overrides.

Also force-load Inter/JetBrains Mono weights before waiting on
document.fonts.ready, since fonts.ready only waits for faces already
requested by on-screen content, not ones a later slide in the export queue
is first to use. And wrap per-slide capture in try/catch so a failure names
the slide instead of aborting the whole export with a generic error.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant