Skip to content

Carousel PDF export, per-page PNGs, and a display-independent export size - #5

Open
pallaoro wants to merge 3 commits into
mainfrom
feat/carousel-pdf-export
Open

Carousel PDF export, per-page PNGs, and a display-independent export size#5
pallaoro wants to merge 3 commits into
mainfrom
feat/carousel-pdf-export

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 3, 2026

Copy link
Copy Markdown
Member

Why

A swipeable carousel on LinkedIn is a document post. Per LinkedIn's own help page, that means PDF/DOC/DOCX/PPT/PPTX, up to 300 pages and 100 MB. The short-lived native multi-image carousel LinkedIn shipped in 2022 was removed in June 2023, with existing carousel posts deleted that December. (Carousel Ads is a separate paid ads product, still live, and unrelated.)

OpenDesign already had multi-page designs and a page-per-slide canvas. It had no way to get them out: Export emitted only the active page, as a PNG always named design.png. You could build a 10-slide carousel in this editor and then be stuck.

What changed

Export is a menu:

Item Output
Carousel PDF every page, in canvas order, one file
This page PNG at 2x
All pages one PNG per page at 2x

Files are named after the design (q3-recap.pdf), not design.png. The PDF is assembled client-side with jsPDF, so page content never leaves the machine and the template keeps working when self-hosted.

Export resolution is now display-independent. Each canvas is backed at the display's device pixel ratio, and fabric folds that ratio into the dimension toDataURL scales — so a fixed multiplier: 2 produced a 4320px file on a retina laptop and 2160px on a 1x monitor, from the same design. Dividing the multiplier by the canvas zoom cancels the ratio back out and pins every export to exactly 2x the design's own size.

Also: adds the 1080x1350 (4:5) LinkedIn Carousel canvas size.

Why client-side, given the house pattern is the PDF service

open-slides, open-books and open-personal-training all render PDFs through services.clawnify.com/pdf/render. That is the right call for those: their content is HTML, so an HTML→PDF renderer produces vector text directly.

This app's content is a fabric canvas, not HTML. Routing it through the service would mean wrapping N base64 PNGs in HTML and POSTing multi-MB bodies to a remote browser — same raster output as doing it locally, plus a round trip, plus metered quota, plus a 501 when self-hosted. It would also reintroduce a fidelity class this repo has already been bitten by: open-slides/src/server/reveal.ts:122-125 records reveal.js re-laying-out at page.pdf() time and overflowing Cloudflare's renderer, which forced a deterministic fixed-block workaround. Rasterizing the canvas locally cannot have that bug.

A vector PDF via canvas.toSVG() → svg2pdf was considered and rejected: font embedding for 10 Google Fonts is the hard part, and LinkedIn rasterizes document posts for display anyway, so the vector win is lost at the destination.

Cost

main this branch
main JS chunk 376.20 kB (gzip 112.19) 381.52 kB (gzip 113.84)
lazy jsPDF chunk 390.52 kB (gzip 128.63), fetched only on PDF export

jsPDF's optional extras (canvg, html2canvas, dompurify) back doc.svg() and doc.html(). They are referenced only at jspdf.es.js:21291-21345 and :13482-13750 respectively; addImage is at :9328 and addPage at :3628, and neither touches them. They're skipped at install (canvg pulls a native node-canvas build that would make pnpm install need cairo) and aliased to a stub so the bundler can still resolve the specifiers.

The export no longer deselects your object mid-export — fabric sets skipControlsDrawing while rendering, so handles never reach the file anyway.

Verification

tsc --noEmit introduces no new errors and vite build passes. The export path was exercised headlessly against real jsPDF with real PNG bytes:

PASS  dpr=1: 1080x1350 exports at 2160x2700 — multiplier=2
PASS  dpr=2: 1080x1350 exports at 2160x2700 — multiplier=1
PASS  dpr=3: 1080x1350 exports at 2160x2700 — multiplier=0.666…
PASS  carousel 1080x1350 x6: 6 PDF page(s)
PASS  carousel 1080x1350 x6: aspect preserved (MediaBox ratio 0.8000)
PASS  landscape 1200x627 x3: page is wider than tall
PASS  slugify("Q3 Recap!") === "q3-recap"  /  "   " falls back to "design"

The landscape case guards a real trap: jsPDF silently swaps the page box to match orientation (jspdf.es.js:14213-14225), so a landscape design comes out portrait unless the orientation agrees with the format array.

Not verified: the toolbar menu has not been clicked in a browser — it is typechecked and builds, but no human or agent has driven the UI.

Known ceiling

"All pages" fires one download per page, staggered 250ms. That is fine for the 5-20 slides a social carousel runs to. It is not the binding limit: the editor mounts every page's canvas simultaneously at DPR-squared backing size, so a very long design exhausts memory in the editor long before export becomes the problem. Both are pre-existing to this PR.

…export size

A swipeable LinkedIn carousel can only be published as a multi-page document
post, i.e. a PDF. The editor already had multi-page designs, but Export only
ever emitted the active page — so a finished carousel had no way out.

Export is now a menu:
  - Carousel PDF — every page, in canvas order, one file
  - This page   — PNG
  - All pages   — one PNG per page

Exports are named after the design instead of always "design.png", and the
PDF is assembled in the browser, so no page content leaves the machine.

Also fixes the export resolution. Each canvas is backed at the display's
device pixel ratio, and that ratio is already folded into the dimensions
toDataURL scales, so a fixed 2x multiplier produced a 4320px file on a retina
laptop and 2160px on a 1x monitor from the same design. Dividing the
multiplier by the canvas zoom cancels the ratio out and pins every export to
2x the design's own size.

Adds the 1080x1350 (4:5) carousel canvas size, and skips jsPDF's optional
extras — canvg pulls in a native node-canvas build that would make
`pnpm install` require cairo.
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.

2 participants