Skip to content

feat(core): add "Export as image PPTX" download option#188

Merged
1weiho merged 2 commits into
mainfrom
claude/admiring-mccarthy-M4fr5
Jun 2, 2026
Merged

feat(core): add "Export as image PPTX" download option#188
1weiho merged 2 commits into
mainfrom
claude/admiring-mccarthy-M4fr5

Conversation

@1weiho

@1weiho 1weiho commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Adds a new export format that renders each slide to a PNG image via html-to-image and packages them into a PPTX file with one image per slide. This provides a quick way to share presentations in PowerPoint format while editable PPTX export is in development.

Changes:

  • New export module (export-pptx.ts): Renders all slides to images at 2x pixel ratio, waits for animations and fonts to settle, then assembles them into a valid PPTX structure using fflate for compression. Includes progress tracking through three phases (processing, generating, done).
  • UI integration: Added "Export as image PPTX" menu item in the slide export dropdown with progress toast feedback. Included a disabled "Export as PPTX" placeholder with tooltip explaining the editable format is coming soon.
  • Progress toast (pptx-progress-toast.tsx): Displays current page being rendered and overall progress bar, matching the existing PDF export UX.
  • Localization: Added strings for all supported languages (en, ja, zh-cn, zh-tw) covering the new menu items, error messages, and toast messages.
  • Dependencies: Added html-to-image for client-side rendering to PNG.

The implementation reuses existing infrastructure (SlidePageProvider, isFrameAnimationSettled, waitForFonts, waitForDataWaitfor) to ensure slides are fully rendered before capture, and generates minimal but valid PPTX XML with embedded images.

https://claude.ai/code/session_01BRPX7CueHhqoiB6Maya4jq

Summary by CodeRabbit

  • New Features
    • Added "Export as Image PPTX" to slide export — generates one image-per-slide PPTX and downloads it.
    • Real-time export progress toast with phase/status and progress bar.
    • "Export as PPTX" shown as "coming soon" (tooltip).
    • Localized UI and toast messages for English, Japanese, Chinese (Simplified & Traditional).

Render each slide to an image via html-to-image and stitch them into a
one-page-per-slide PPTX. Adds a coming-soon "Export as PPTX" entry whose
tooltip points users at the image export until the editable format lands.

https://claude.ai/code/session_01BRPX7CueHhqoiB6Maya4jq
@vercel

vercel Bot commented Jun 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
open-slide-demo Ready Ready Preview, Comment Jun 2, 2026 7:46am
open-slide-web Ready Ready Preview, Comment Jun 2, 2026 7:46am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 68787ba4-a4a5-4c7c-ac98-b63866f45076

📥 Commits

Reviewing files that changed from the base of the PR and between ff4aabb and 58a23be.

📒 Files selected for processing (1)
  • packages/core/src/app/lib/export-pptx.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/app/lib/export-pptx.ts

Walkthrough

This PR adds an "Export as image PPTX" feature that renders each slide to a PNG and packages them into a one-image-per-slide PPTX, including progress UI, client-side PPTX assembly/zip, and localized strings across four locales.

Changes

Image-based PPTX Export Feature

Layer / File(s) Summary
Release metadata and dependencies
.changeset/export-image-pptx.md, packages/core/package.json
Changeset marks a minor version bump; html-to-image added as a runtime dependency.
Locale type definitions
packages/core/src/locale/types.ts
Locale extended with PPTX export keys (exportAsImagePptx, exportAsPptx, comingSoon, pptxComingSoonTooltip, imagePptxExportFailed) and a pptxToast section (title, processing, generating, done).
Core PPTX export pipeline
packages/core/src/app/lib/export-pptx.ts
exportSlideAsImagePptx renders slides offscreen, waits for fonts/animations, freezes styles, captures PNGs via html-to-image, builds PPTX XML parts, zips with fflate, triggers download, reports progress, and cleans up. Includes helpers freezeForCapture, XML builders, sleep, nextPaint, and downloadBlob.
Progress toast UI component
packages/core/src/app/components/pptx-progress-toast.tsx
PptxProgressToast shows spinner, localized title, phase-dependent status text, and a rounded progress bar (formats page counters during processing).
Slide route export menu integration
packages/core/src/app/routes/slide.tsx
Adds icons and menu separator, inserts "export as image PPTX" item that shows a persistent progress toast, calls the export function with progress callback, handles errors with localized toast, and adds a tooltip-labeled "export as PPTX" coming-soon row.
Locale string implementations
packages/core/src/locale/{en,ja,zh-cn,zh-tw}.ts
PPTX export UI and toast strings implemented in English, Japanese, Simplified Chinese, and Traditional Chinese (export labels, coming-soon messaging, tooltips, failure messages, and toast text).

Sequence Diagram

sequenceDiagram
  participant SlideUI as Slide Route
  participant ExportFn as exportSlideAsImagePptx
  participant Render as React Offscreen Render
  participant HtmlToImage as html-to-image
  participant ZipBuild as PPTX Zip Builder
  participant Download as Browser Download
  
  SlideUI->>ExportFn: call with slide, slideId, onProgress
  ExportFn->>Render: mount offscreen React roots per page
  Render->>Render: settle animations & fonts
  ExportFn->>HtmlToImage: capture each page as PNG blob
  ExportFn->>ExportFn: onProgress(processing → generating)
  ExportFn->>ZipBuild: build XML parts & include image*.png
  ExportFn->>Download: create object URL, click anchor
  ExportFn->>ExportFn: onProgress(done) and cleanup
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I nibble code and chase the light,
Slides become pictures, crisp and bright,
A toast spins, counts pages one by one,
Zipped and bundled—download begun!
From offscreen roots to a PPTX flight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature added: a new 'Export as image PPTX' download option that converts slides to PNG images and packages them into PPTX format.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/export-image-pptx.md:
- Line 5: The changeset entry is too long and reads like release notes; replace
the current multi-clause sentence in .changeset/export-image-pptx.md with a
single short present-tense user-facing sentence that describes the change (e.g.,
"Add Export as image PPTX download option") and remove implementation details
and any "coming soon" rationale so it conforms to the one-line changeset rule.

In `@packages/core/src/app/lib/export-pptx.ts`:
- Around line 50-71: The current loop (creating hosts, pushing to frames and
reactRoots and rendering all pages) keeps the whole deck mounted and accumulates
PNGs, causing high peak memory; change export logic to process each page
sequentially: for each Page from pages createHost (host div), render with
createRoot and SlidePageProvider, wait for the page to finish
rendering/animations and capture the PNG, immediately unmount the root
(root.unmount()) and remove the host from DOM, then store only the captured
image buffer; do not keep frames or reactRoots arrays populated for every
page—reuse local variables for host/root per iteration so only one page is live
at a time (apply same change to the similar block referenced around lines
87-107).

In `@packages/core/src/app/routes/slide.tsx`:
- Around line 550-569: The tooltip trigger is an unfocusable div (inside
TooltipTrigger) so keyboard users can’t access the tooltip; replace that div
with a focusable, semantic element—preferably a disabled button—so it can
receive focus and expose the tooltip: change the child of TooltipTrigger to a
<button disabled aria-disabled="true" className="..." ...> (keeping the same
inner content: Presentation, {t.slide.exportAsPptx} and the coming-soon badge)
or alternatively add tabIndex={0} role="button" aria-disabled="true" to the
element if you must keep it non-button; ensure the TooltipTrigger,
TooltipContent and localized strings (t.slide.pptxComingSoonTooltip /
t.slide.comingSoon) remain unchanged.
- Around line 529-544: The export failure toast is immediately removed because
toast.error(..., { id: toastId }) is followed by toast.dismiss(toastId) in the
finally block; update the export flow in the exportSlideAsImagePptx call
handling so the shared toastId is only dismissed on success (move
toast.dismiss(toastId) into the success path after the export completes) or use
a separate id for the error toast so the failure message remains visible; keep
setExporting(false) in finally. Also make the TooltipTrigger focusable: the
current TooltipTrigger asChild wraps a non-focusable <div>, so change the
trigger element used by TooltipTrigger (or add tabIndex={0}) so keyboard users
can open the tooltip (refer to TooltipTrigger, asChild, and the element used as
the trigger).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 89f55784-5422-4343-bb69-5b9dcd428d85

📥 Commits

Reviewing files that changed from the base of the PR and between 9b8202e and ff4aabb.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (10)
  • .changeset/export-image-pptx.md
  • packages/core/package.json
  • packages/core/src/app/components/pptx-progress-toast.tsx
  • packages/core/src/app/lib/export-pptx.ts
  • packages/core/src/app/routes/slide.tsx
  • packages/core/src/locale/en.ts
  • packages/core/src/locale/ja.ts
  • packages/core/src/locale/types.ts
  • packages/core/src/locale/zh-cn.ts
  • packages/core/src/locale/zh-tw.ts

"@open-slide/core": minor
---

Add an "Export as image PPTX" download option that renders each slide to an image via html-to-image and stitches them into a one-page-per-slide PPTX, plus a coming-soon "Export as PPTX" entry for the editable format.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Shorten the changeset entry.

This reads like release notes. Changesets here should stay as one short user-facing sentence without implementation detail or “coming soon” rationale.

As per coding guidelines, ".changeset/*.md: Changeset descriptions must be short and direct: one line, present-tense, describing what changed from a user's perspective. No paragraphs, no rationale, no 'this PR…'."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/export-image-pptx.md at line 5, The changeset entry is too long
and reads like release notes; replace the current multi-clause sentence in
.changeset/export-image-pptx.md with a single short present-tense user-facing
sentence that describes the change (e.g., "Add Export as image PPTX download
option") and remove implementation details and any "coming soon" rationale so it
conforms to the one-line changeset rule.

Comment on lines +50 to +71
const reactRoots: Root[] = [];
const frames: HTMLElement[] = [];
for (let i = 0; i < pages.length; i++) {
const Page = pages[i];
if (!Page) continue;
const host = document.createElement('div');
host.setAttribute('data-osd-canvas', '');
host.style.width = `${SLIDE_W}px`;
host.style.height = `${SLIDE_H}px`;
host.style.overflow = 'hidden';
host.style.background = '#fff';
if (designVars) {
for (const [k, v] of Object.entries(designVars)) host.style.setProperty(k, v);
}
container.appendChild(host);
frames.push(host);
const r = createRoot(host);
r.render(
createElement(SlidePageProvider, { index: i, total: pages.length }, createElement(Page)),
);
reactRoots.push(r);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Process slides incrementally to cap peak memory.

This keeps the full deck mounted while also accumulating every PNG in memory before zipping. On larger decks, the combination of live React trees, running animations, and image buffers is likely to freeze or OOM the browser. Render/capture/unmount one page at a time instead of holding the whole deck live.

Also applies to: 87-107

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/app/lib/export-pptx.ts` around lines 50 - 71, The current
loop (creating hosts, pushing to frames and reactRoots and rendering all pages)
keeps the whole deck mounted and accumulates PNGs, causing high peak memory;
change export logic to process each page sequentially: for each Page from pages
createHost (host div), render with createRoot and SlidePageProvider, wait for
the page to finish rendering/animations and capture the PNG, immediately unmount
the root (root.unmount()) and remove the host from DOM, then store only the
captured image buffer; do not keep frames or reactRoots arrays populated for
every page—reuse local variables for host/root per iteration so only one page is
live at a time (apply same change to the similar block referenced around lines
87-107).

Comment on lines +529 to +544
await exportSlideAsImagePptx(slide, slideId, (p) => {
toast.custom(() => <PptxProgressToast progress={p} />, {
id: toastId,
duration: Infinity,
});
});
} catch (err) {
console.error('[open-slide] image pptx export failed', err);
toast.error(t.slide.imagePptxExportFailed, {
id: toastId,
duration: 4000,
});
} finally {
setExporting(false);
toast.dismiss(toastId);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Sonner v2.x, if toast.error(..., { id })updates an existing toast andtoast.dismiss(id)is called immediately afterward infinally, does that remove the updated error toast right away?

💡 Result:

Yes. If you call toast.error(..., { id }) which updates an existing toast, and then you immediately call toast.dismiss(id) in a finally block, that same toast (including the updated error content) will be removed immediately from the active toast list; it won’t remain visible. In Sonner’s implementation, an existing toast update is handled by merging the new toast data into the existing toast with the same id [1]. Separately, dismissToast marks the toast as “deleting” and schedules its DOM removal on a short exit animation delay (default exitTime 400ms in the shown Toast.ts implementation), and it also deletes it from the internal toast map right away [2]. So even though the element may linger briefly for the dismiss animation, the updated toast is considered dismissed immediately after toast.dismiss(id) is called. Therefore, in the scenario you described (“update then immediately dismiss in finally”), the updated error toast will be dismissed right away (disappear from the toaster state immediately, with a short exit animation before it’s physically removed).

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/core/src/app/routes/slide.tsx"
# show the snippet around the PPTX export error/finally block
nl -ba "$FILE" | sed -n '500,580p'

Repository: 1weiho/open-slide

Length of output: 104


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/core/src/app/routes/slide.tsx"
# show the snippet around the PPTX export error/finally block and nearby tooltip row
cat -n "$FILE" | sed -n '500,590p'

Repository: 1weiho/open-slide

Length of output: 5142


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="packages/core/src/app/routes/slide.tsx"

# show top imports and where TooltipTrigger is imported from
rg -n "TooltipTrigger|`@radix-ui/react-tooltip`|TooltipProvider|TooltipContent" "$FILE" -n

# show a bit of the import section for context
sed -n '1,120p' "$FILE'

Repository: 1weiho/open-slide

Length of output: 869


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the tooltip component file that is aliased as "`@/components/ui/tooltip`"
# Use fd to locate likely matches in src/components.
fd -a -t f "tooltip.*\.(ts|tsx)" packages/core/src/components || true
fd -a -t f "tooltip.*\.(ts|tsx)" packages/core/src || true

# Also directly list common location if it exists
if [ -f "packages/core/src/components/ui/tooltip.tsx" ]; then
  echo "FOUND: packages/core/src/components/ui/tooltip.tsx"
  sed -n '1,220p' packages/core/src/components/ui/tooltip.tsx
fi
if [ -f "packages/core/src/components/ui/tooltip.ts" ]; then
  echo "FOUND: packages/core/src/components/ui/tooltip.ts"
  sed -n '1,220p' packages/core/src/components/ui/tooltip.ts
fi

Repository: 1weiho/open-slide

Length of output: 262


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="packages/core/src/app/components/ui/tooltip.tsx"
wc -l "$FILE"
sed -n '1,220p' "$FILE"
sed -n '220,420p' "$FILE"

Repository: 1weiho/open-slide

Length of output: 2049


Fix PPTX export error toast dismissal + improve tooltip keyboard accessibility

  • In packages/core/src/app/routes/slide.tsx, the error toast is updated with toast.error(..., { id: toastId }) but then immediately removed in finally by toast.dismiss(toastId), so the failure message won’t stay visible—only dismiss on success (or use a different id for the error).
Suggested fix
+                        let failed = false;
                         try {
                           await exportSlideAsImagePptx(slide, slideId, (p) => {
                             toast.custom(() => <PptxProgressToast progress={p} />, {
                               id: toastId,
                               duration: Infinity,
                             });
                           });
                         } catch (err) {
+                          failed = true;
                           console.error('[open-slide] image pptx export failed', err);
                           toast.error(t.slide.imagePptxExportFailed, {
                             id: toastId,
                             duration: 4000,
                           });
                         } finally {
                           setExporting(false);
-                          toast.dismiss(toastId);
+                          if (!failed) toast.dismiss(toastId);
                         }
- The “coming soon” `TooltipTrigger asChild` wraps a non-focusable `
`, so keyboard users can’t open the tooltip; make the trigger focusable (e.g., add `tabIndex={0}` or use a focusable element).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/app/routes/slide.tsx` around lines 529 - 544, The export
failure toast is immediately removed because toast.error(..., { id: toastId })
is followed by toast.dismiss(toastId) in the finally block; update the export
flow in the exportSlideAsImagePptx call handling so the shared toastId is only
dismissed on success (move toast.dismiss(toastId) into the success path after
the export completes) or use a separate id for the error toast so the failure
message remains visible; keep setExporting(false) in finally. Also make the
TooltipTrigger focusable: the current TooltipTrigger asChild wraps a
non-focusable <div>, so change the trigger element used by TooltipTrigger (or
add tabIndex={0}) so keyboard users can open the tooltip (refer to
TooltipTrigger, asChild, and the element used as the trigger).

Comment on lines +550 to +569
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
aria-disabled
className="relative flex cursor-help items-center justify-between gap-2 rounded-[5px] px-2 py-1.5 text-[12.5px] opacity-45 select-none [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:opacity-80"
>
<span className="flex items-center gap-2">
<Presentation />
{t.slide.exportAsPptx}
</span>
<span className="rounded-[3px] bg-muted px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.04em] text-muted-foreground">
{t.slide.comingSoon}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-[240px] leading-relaxed">
{t.slide.pptxComingSoonTooltip}
</TooltipContent>
</Tooltip>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make the “coming soon” row keyboard-reachable.

The tooltip trigger is a plain div, so keyboard users cannot focus it and never get the explanatory tooltip. Use a disabled button/menu item with aria-disabled, or make the trigger focusable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/app/routes/slide.tsx` around lines 550 - 569, The tooltip
trigger is an unfocusable div (inside TooltipTrigger) so keyboard users can’t
access the tooltip; replace that div with a focusable, semantic
element—preferably a disabled button—so it can receive focus and expose the
tooltip: change the child of TooltipTrigger to a <button disabled
aria-disabled="true" className="..." ...> (keeping the same inner content:
Presentation, {t.slide.exportAsPptx} and the coming-soon badge) or alternatively
add tabIndex={0} role="button" aria-disabled="true" to the element if you must
keep it non-button; ensure the TooltipTrigger, TooltipContent and localized
strings (t.slide.pptxComingSoonTooltip / t.slide.comingSoon) remain unchanged.

@1weiho 1weiho changed the title Add "Export as image PPTX" download option feat: add "Export as image PPTX" download option Jun 1, 2026
@1weiho 1weiho changed the title feat: add "Export as image PPTX" download option feat(core): add "Export as image PPTX" download option Jun 1, 2026
html-to-image clones each frame and replays its intro keyframes from the
hidden 0% frame, dropping animated content from the snapshot. Fast-forward
animations to their end frame in the live DOM and pin the settled
opacity/transform/filter/clip-path inline (with animation disabled) so the
clone rasterises the final, visible state. Pseudo-elements are handled by a
scoped capture stylesheet.

https://claude.ai/code/session_01BRPX7CueHhqoiB6Maya4jq
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