Add PDF export#10
Open
eyeofshreyas wants to merge 10 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ekey): 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.isStaticgate (invisible content), CSS@keyframes-driven draw-ins inVisualDashboardthat always played their full timeline regardless ofisStatic, a font-loading race that could substitute the wrong monospace typeface, missing background color, and a-10000pxoffset trick that produced blank captures.Test plan
npx tsc --noEmit && npm run buildVisualDashboardcomponent's sparklines/line-chart/bars (previously blank/flat, now drawn)