diff --git a/templates/slides/app/components/editor/slide-object-interactions.test.ts b/templates/slides/app/components/editor/slide-object-interactions.test.ts
index 88e4c09dd3..d540b3f468 100644
--- a/templates/slides/app/components/editor/slide-object-interactions.test.ts
+++ b/templates/slides/app/components/editor/slide-object-interactions.test.ts
@@ -19,7 +19,9 @@ import {
freezeSlideElementForFreeform,
getSlideSelectionIdentity,
getSlideSelectionMode,
+ findPersistedImageObject,
getSlideTextBoxDefaultColor,
+ isDeletableFlowImage,
removeSlideObjectAndLayoutSpacer,
resolveSlideObjectContainingBlock,
resizeSlideObject,
@@ -738,3 +740,92 @@ describe("slide object interactions", () => {
expect(pasted.style.top).toBe("");
});
});
+
+describe("isDeletableFlowImage", () => {
+ it("accepts a plain image in flow layout", () => {
+ const img = document.createElement("img");
+ expect(isDeletableFlowImage(img)).toBe(true);
+ });
+
+ it("accepts an image placeholder box", () => {
+ const placeholder = document.createElement("div");
+ placeholder.className = "fmd-img-placeholder";
+ expect(isDeletableFlowImage(placeholder)).toBe(true);
+ });
+
+ it("refuses ordinary flow containers so Delete cannot collapse a layout", () => {
+ const card = document.createElement("div");
+ card.className = "fmd-card";
+ card.innerHTML = "

Zamioculcas
";
+ expect(isDeletableFlowImage(card)).toBe(false);
+ });
+
+ it("refuses text blocks", () => {
+ const heading = document.createElement("h1");
+ heading.textContent = "Low LIGHT";
+ expect(isDeletableFlowImage(heading)).toBe(false);
+ });
+});
+
+describe("findPersistedImageObject", () => {
+ function importedSlide(): { root: HTMLElement; img: HTMLElement } {
+ const root = document.createElement("div");
+ root.className = "fmd-slide";
+ root.innerHTML =
+ '
' +
+ '

' +
+ "
";
+ const img = root.querySelector("img") as HTMLElement;
+ return { root, img };
+ }
+
+ it("returns the wrapper that carries the persisted object id", () => {
+ const { root, img } = importedSlide();
+ const owner = findPersistedImageObject(img, root);
+ expect(owner?.getAttribute("data-slide-object-id")).toBe("pdf-img-1-0");
+ });
+
+ it("resolves an empty placeholder to the same wrapper", () => {
+ const root = document.createElement("div");
+ root.innerHTML =
+ '
";
+ const placeholder = root.querySelector(
+ ".fmd-img-placeholder",
+ ) as HTMLElement;
+ expect(
+ findPersistedImageObject(placeholder, root)?.getAttribute(
+ "data-slide-object-id",
+ ),
+ ).toBe("pdf-img-2-0");
+ });
+
+ it("returns null for an ordinary flow image so only the image is removed", () => {
+ const root = document.createElement("div");
+ root.innerHTML = '

Label
';
+ const img = root.querySelector("img") as HTMLElement;
+ expect(findPersistedImageObject(img, root)).toBeNull();
+ });
+
+ it("does not escape past the slide root", () => {
+ const outer = document.createElement("div");
+ outer.className = "fmd-pptx-image";
+ outer.setAttribute("data-slide-object-id", "outside");
+ const root = document.createElement("div");
+ outer.appendChild(root);
+ const img = document.createElement("img");
+ root.appendChild(img);
+ expect(findPersistedImageObject(img, root)).toBeNull();
+ });
+
+ it("ignores a positioned container that is not an image wrapper", () => {
+ const root = document.createElement("div");
+ root.innerHTML =
+ '

';
+ const img = root.querySelector("img") as HTMLElement;
+ expect(findPersistedImageObject(img, root)).toBeNull();
+ });
+});
diff --git a/templates/slides/app/components/editor/slide-object-interactions.ts b/templates/slides/app/components/editor/slide-object-interactions.ts
index 8c9a91437d..7e6380929c 100644
--- a/templates/slides/app/components/editor/slide-object-interactions.ts
+++ b/templates/slides/app/components/editor/slide-object-interactions.ts
@@ -521,6 +521,51 @@ export function removeSlideObjectAndLayoutSpacer(element: HTMLElement): void {
element.remove();
}
+/**
+ * Whether Delete should remove `element` even though it is not a freeform
+ * canvas object.
+ *
+ * Flow-layout nodes are deliberately excluded from object operations, because
+ * deleting an arbitrary one collapses the layout around it. An image is the
+ * exception: it is a leaf, users select it directly, and removing it leaves
+ * the surrounding grid or card intact. Without this, selecting a picture in a
+ * card grid and pressing Delete silently did nothing.
+ */
+export function isDeletableFlowImage(element: HTMLElement): boolean {
+ return (
+ element.tagName === "IMG" ||
+ element.classList.contains("fmd-img-placeholder")
+ );
+}
+
+/**
+ * The persisted image object that owns `element`, if any.
+ *
+ * PPTX/PDF import wraps each picture in an absolutely positioned
+ * `.fmd-pptx-image` div carrying the durable `data-slide-object-id`, with the
+ * `
![]()
` (or an empty placeholder) inside it. Deleting the inner node alone
+ * leaves that wrapper behind as an invisible object that still occupies its
+ * slot and still round-trips through save. Matching on the image wrapper
+ * specifically — rather than any positioned ancestor — keeps this from
+ * swallowing a whole card or column that merely contains a picture.
+ */
+export function findPersistedImageObject(
+ element: HTMLElement,
+ root: HTMLElement,
+): HTMLElement | null {
+ let current: HTMLElement | null = element;
+ while (current && current !== root && root.contains(current)) {
+ const isImageWrapper =
+ current.classList.contains("fmd-pptx-image") ||
+ current.getAttribute("data-pptx-element-kind") === "image";
+ if (isImageWrapper && current.getAttribute("data-slide-object-id")) {
+ return current;
+ }
+ current = current.parentElement;
+ }
+ return null;
+}
+
/** Convert a viewport click into the unscaled fmd-slide coordinate system. */
export function clientPointToSlideCoordinates(
clientX: number,
diff --git a/templates/slides/app/lib/export-pdf-client.ts b/templates/slides/app/lib/export-pdf-client.ts
index 7efc21ed67..42a682cfb9 100644
--- a/templates/slides/app/lib/export-pdf-client.ts
+++ b/templates/slides/app/lib/export-pdf-client.ts
@@ -10,9 +10,16 @@
* largest rendered element so a thumbnail's transform: scale(0.25)
* doesn't shrink the captured pixels.
*/
+import { appBasePath } from "@agent-native/core/client/api-path";
+
import { type AspectRatio, getAspectRatioDims } from "./aspect-ratios";
import { importExportModule } from "./dynamic-import";
+/** Same-origin URL that re-serves a remote image, bypassing its missing CORS. */
+export function imageProxyUrl(src: string): string {
+ return `${appBasePath()}/api/image-proxy?url=${encodeURIComponent(src)}`;
+}
+
/**
* Cross-origin
![]()
elements without an explicit `crossOrigin="anonymous"`
* attribute taint the canvas when rasterized via
, producing
@@ -21,8 +28,15 @@ import { importExportModule } from "./dynamic-import";
* setting the attribute and re-assigning the same src. This is the root
* cause of the "blank images in exported PDF" bug Rochkind reported.
*/
-export async function preloadImagesWithCors(root: HTMLElement): Promise {
+export async function preloadImagesWithCors(
+ root: HTMLElement,
+): Promise<() => void> {
const imgs = Array.from(root.querySelectorAll("img"));
+ // The slide DOM is the live editor canvas. Anything rewritten here would
+ // otherwise be picked up by the next save and persisted into the deck, so
+ // every mutation is recorded and undone once the capture is done.
+ const restores: Array<() => void> = [];
+
await Promise.all(
imgs.map(async (img) => {
const src = img.currentSrc || img.src;
@@ -35,30 +49,59 @@ export async function preloadImagesWithCors(root: HTMLElement): Promise {
isCrossOrigin = false;
}
if (!isCrossOrigin) return;
+
+ const originalCrossOrigin = img.getAttribute("crossorigin");
+ const originalSrc = img.getAttribute("src");
+ const restore = () => {
+ if (originalCrossOrigin === null) img.removeAttribute("crossorigin");
+ else img.setAttribute("crossorigin", originalCrossOrigin);
+ if (originalSrc === null) img.removeAttribute("src");
+ else img.setAttribute("src", originalSrc);
+ };
+
if (img.crossOrigin === "anonymous") {
// Already CORS-enabled; just make sure it's decoded.
try {
await img.decode();
+ return;
} catch {
- /* ignore */
+ // coercion-ok: a failed direct load is the signal to try the proxy,
+ // and the proxy attempt below reports its own failure.
+ }
+ } else {
+ img.crossOrigin = "anonymous";
+ // Re-set src to retrigger the load with the new CORS attribute.
+ img.src = src;
+ restores.push(restore);
+ try {
+ await img.decode();
+ return;
+ } catch {
+ // coercion-ok: same as above — this is the CORS probe, not the
+ // final outcome.
}
- return;
}
+
+ // The host does not send Access-Control-Allow-Origin, and no client-side
+ // flag can override that. Re-serve the image from our own origin so the
+ // canvas stays clean instead of rasterizing a blank rect.
+ if (!restores.includes(restore)) restores.push(restore);
img.crossOrigin = "anonymous";
- // Re-set src to retrigger the load with the new CORS attribute.
- img.src = src;
+ img.src = imageProxyUrl(src);
try {
await img.decode();
} catch (err) {
- // Server didn't return Access-Control-Allow-Origin. The screenshot
- // will be blank for this image — log so the user can swap the host.
console.warn(
- `[export-pdf] CORS-tainted image likely caused blank render: ${src}`,
+ `[export-pdf] image could not be loaded for export: ${src}`,
err,
);
}
}),
);
+
+ return () => {
+ for (const restore of restores) restore();
+ };
}
export function findSlideExportSource(
@@ -132,23 +175,39 @@ export async function exportDeckAsPdf(
// Force CORS-enabled re-fetch on every cross-origin
before
// capture — otherwise the canvas tainting check inside modern-screenshot
// produces a blank rect for the image.
- await preloadImagesWithCors(source);
-
- const dataUrl = await domToJpeg(source, {
- width: dims.width,
- height: dims.height,
- scale: 2, // 2x for crisp text
- backgroundColor: "#000000",
- quality: 0.92,
- // Pair with the in-DOM CORS preload above. modern-screenshot's
- // internal image fetcher needs no-cache so re-issued requests don't
- // get served the original tainted (no-CORS) response from the HTTP
- // cache, and an anonymous-CORS request mode so the response itself
- // is usable on a clean canvas.
- fetch: {
- requestInit: { cache: "no-cache", mode: "cors", credentials: "omit" },
- },
- });
+ const restoreImages = await preloadImagesWithCors(source);
+
+ let dataUrl: string;
+ try {
+ dataUrl = await domToJpeg(source, {
+ width: dims.width,
+ height: dims.height,
+ scale: 2, // 2x for crisp text
+ // guard:allow-raw-color — a PDF page has no theme to follow.
+ backgroundColor: "#000000",
+ quality: 0.92,
+ // Pair with the in-DOM CORS preload above. modern-screenshot's
+ // internal image fetcher needs no-cache so re-issued requests don't
+ // get served the original tainted (no-CORS) response from the HTTP
+ // cache, and an anonymous-CORS request mode so the response itself
+ // is usable on a clean canvas.
+ //
+ // `same-origin` rather than `omit`: images the preload rewrote to
+ // /api/image-proxy are same-origin and that route needs the session
+ // cookie, so omitting credentials would 401 exactly the images this
+ // is meant to rescue. Cross-origin requests still go out anonymously,
+ // which is what CORS mode requires.
+ fetch: {
+ requestInit: {
+ cache: "no-cache",
+ mode: "cors",
+ credentials: "same-origin",
+ },
+ },
+ });
+ } finally {
+ restoreImages();
+ }
if (i > 0) pdf.addPage([dims.width, dims.height], orientation);
pdf.addImage(dataUrl, "JPEG", 0, 0, dims.width, dims.height);
diff --git a/templates/slides/app/lib/import-uploaded-deck.test.ts b/templates/slides/app/lib/import-uploaded-deck.test.ts
index 387e9974e8..89aacec7cc 100644
--- a/templates/slides/app/lib/import-uploaded-deck.test.ts
+++ b/templates/slides/app/lib/import-uploaded-deck.test.ts
@@ -6,7 +6,10 @@ vi.mock("@agent-native/core/client/hooks", () => ({
callAction: (...args: unknown[]) => mockCallAction(...args),
}));
-import { importUploadedDeckIntoDeck } from "./import-uploaded-deck";
+import {
+ IMPORT_ACTION_TIMEOUT_MS,
+ importUploadedDeckIntoDeck,
+} from "./import-uploaded-deck";
const pptxFile = {
path: "/uploads/source.pptx",
@@ -43,10 +46,14 @@ describe("importUploadedDeckIntoDeck", () => {
slideCount: 8,
file: pptxFile,
});
- expect(mockCallAction).toHaveBeenCalledWith("import-pptx", {
- filePath: pptxFile.path,
- deckId: "deck-1",
- });
+ expect(mockCallAction).toHaveBeenCalledWith(
+ "import-pptx",
+ {
+ filePath: pptxFile.path,
+ deckId: "deck-1",
+ },
+ { timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
+ );
});
it("uses source-faithful page import for PDFs", async () => {
@@ -58,12 +65,18 @@ describe("importUploadedDeckIntoDeck", () => {
await importUploadedDeckIntoDeck([pdfFile], "deck-1");
- expect(mockCallAction).toHaveBeenCalledWith("import-file", {
- filePath: pdfFile.path,
- format: "pdf",
- deckId: "deck-1",
- importIntoDeck: true,
- });
+ expect(mockCallAction).toHaveBeenCalledWith(
+ "import-file",
+ {
+ filePath: pdfFile.path,
+ format: "pdf",
+ deckId: "deck-1",
+ importIntoDeck: true,
+ },
+ // A large PDF routinely outruns the 60s default, and this is the path
+ // the create-from-upload flow uses before generation starts.
+ { timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
+ );
});
it("refuses ambiguous multi-deck uploads", async () => {
diff --git a/templates/slides/app/lib/import-uploaded-deck.ts b/templates/slides/app/lib/import-uploaded-deck.ts
index 427a79dcdc..8d8466daca 100644
--- a/templates/slides/app/lib/import-uploaded-deck.ts
+++ b/templates/slides/app/lib/import-uploaded-deck.ts
@@ -9,6 +9,16 @@ export type ImportedSourceDeck = {
imagesSkipped: number;
};
+/**
+ * PDF/PPTX import renders every page (image extraction, per-page fidelity
+ * parsing) and can run well past the client's default 60s action timeout on
+ * large or image-heavy files. A timeout here only aborts the client's wait —
+ * the server keeps importing and the deck still ends up with slides — so the
+ * default made the editor silently fail on a deck that had, or was about to
+ * have, real content.
+ */
+export const IMPORT_ACTION_TIMEOUT_MS = 5 * 60 * 1000;
+
function sourceFormat(file: UploadedFile): "pdf" | "pptx" | null {
const name = file.originalName.toLowerCase();
if (name.endsWith(".pptx")) return "pptx";
@@ -57,6 +67,7 @@ export async function importUploadedDeckIntoDeck(
deckId,
importIntoDeck: true,
},
+ { timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
),
);
if (result.imported !== true || result.deckId !== deckId) {
diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx
index eb1846efce..8b1ad7db1e 100644
--- a/templates/slides/app/pages/DeckEditor.tsx
+++ b/templates/slides/app/pages/DeckEditor.tsx
@@ -345,6 +345,18 @@ export default function DeckEditor() {
if (!generating) setAddSlideGenerating(false);
}, [generating]);
+ // Below `md` the rail is a drawer behind a full-viewport dimming scrim; at
+ // `md` and up it's docked with no scrim. `sidebarOpen` is seeded from the
+ // width at mount only, so a window that starts wide and is then narrowed
+ // (or an editor opened in a resizable preview pane) keeps `sidebarOpen`
+ // true while the scrim stops being `md:hidden` — dimming the whole editor
+ // with no way to dismiss it.
+ useEffect(() => {
+ const onResize = () => setSidebarOpen(window.innerWidth >= 768);
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ }, []);
+
const previousSlideIdsRef = useRef([]);
useEffect(() => {
const currentSlideIds = deck?.slides.map((slide) => slide.id) ?? [];
diff --git a/templates/slides/app/pages/Index.generation-flow.test.ts b/templates/slides/app/pages/Index.generation-flow.test.ts
index 5f55371e06..9a16ab3661 100644
--- a/templates/slides/app/pages/Index.generation-flow.test.ts
+++ b/templates/slides/app/pages/Index.generation-flow.test.ts
@@ -72,7 +72,9 @@ describe("new deck generation flow", () => {
source.indexOf("const handleReferenceSkip"),
);
- expect(referenceImportFlow).toContain('callAction("import-pptx"');
+ // Whitespace-tolerant: passing the extended import timeout wraps the call
+ // across lines, and this asserts the call exists, not how it is formatted.
+ expect(referenceImportFlow).toMatch(/callAction\(\s*"import-pptx"/);
expect(referenceImportFlow).toContain("importedReference = {");
expect(referenceImportFlow).toContain('source: "pptx"');
expect(referenceImportFlow).toContain("setPendingDeck((current) =>");
@@ -86,7 +88,7 @@ describe("new deck generation flow", () => {
source.indexOf("const handleReferenceSkip"),
);
- expect(referenceImportFlow).toContain('callAction("import-file"');
+ expect(referenceImportFlow).toMatch(/callAction\(\s*"import-file"/);
expect(referenceImportFlow).toContain('format: "pdf"');
expect(referenceImportFlow).toContain("importIntoDeck: true");
expect(referenceImportFlow).toContain(
diff --git a/templates/slides/app/pages/Index.tsx b/templates/slides/app/pages/Index.tsx
index 83a6589e5c..20898a9ded 100644
--- a/templates/slides/app/pages/Index.tsx
+++ b/templates/slides/app/pages/Index.tsx
@@ -59,6 +59,7 @@ import { createDeckAgentMessage } from "@/lib/agent-visible-message";
import { savePromptToComposerDraft } from "@/lib/composer-draft";
import { sortDecksByRecency } from "@/lib/deck-sorting";
import {
+ IMPORT_ACTION_TIMEOUT_MS,
importUploadedDeckIntoDeck,
type ImportedSourceDeck,
} from "@/lib/import-uploaded-deck";
@@ -813,9 +814,11 @@ export default function Index() {
let importedReference: ImportedReference | null = null;
let generationFiles = uploaded;
if (pptxReference) {
- const imported = (await callAction("import-pptx", {
- filePath: pptxReference.path,
- })) as {
+ const imported = (await callAction(
+ "import-pptx",
+ { filePath: pptxReference.path },
+ { timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
+ )) as {
id?: unknown;
imported?: unknown;
slideCount?: unknown;
@@ -854,12 +857,16 @@ export default function Index() {
);
}
try {
- const imported = (await callAction("import-file", {
- filePath: pdfReference.path,
- format: "pdf",
- deckId: referenceDeck.id,
- importIntoDeck: true,
- })) as {
+ const imported = (await callAction(
+ "import-file",
+ {
+ filePath: pdfReference.path,
+ format: "pdf",
+ deckId: referenceDeck.id,
+ importIntoDeck: true,
+ },
+ { timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
+ )) as {
imported?: unknown;
deckId?: unknown;
pageCount?: unknown;
diff --git a/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md b/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md
new file mode 100644
index 0000000000..c145b242f4
--- /dev/null
+++ b/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-08-08
+---
+
+Google Slides export now tells you why a deck fell back to a .pptx download instead of reporting success.
diff --git a/templates/slides/changelog/2026-08-09-delete-key-now-removes-a-selected-image.md b/templates/slides/changelog/2026-08-09-delete-key-now-removes-a-selected-image.md
new file mode 100644
index 0000000000..31f57d1768
--- /dev/null
+++ b/templates/slides/changelog/2026-08-09-delete-key-now-removes-a-selected-image.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-08-09
+---
+
+Fixed the Delete key doing nothing after selecting an image that sits inside a slide's layout, such as a picture in a card grid. Only images that had been moved freely on the canvas could be deleted before.
diff --git a/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md b/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md
new file mode 100644
index 0000000000..0f5c2d1323
--- /dev/null
+++ b/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-08-09
+---
+
+Fixed direct edits to a PDF/PPTX-imported deck's slide text sometimes failing to save with a generic "Internal server error" — the source-preservation guard meant to stop an agent from silently dropping the original artwork or copy was also blocking ordinary human edits, which have no way to opt out of it.
diff --git a/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md b/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md
new file mode 100644
index 0000000000..acd1c01065
--- /dev/null
+++ b/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-08-09
+---
+
+Fixed PDF and custom-size PowerPoint imports rendering with distorted, mispositioned images and text on non-16:9 pages.
diff --git a/templates/slides/changelog/2026-08-09-images-from-sites-without-cors-no-longer-export-blank.md b/templates/slides/changelog/2026-08-09-images-from-sites-without-cors-no-longer-export-blank.md
new file mode 100644
index 0000000000..b187b88f3b
--- /dev/null
+++ b/templates/slides/changelog/2026-08-09-images-from-sites-without-cors-no-longer-export-blank.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-08-09
+---
+
+Fixed images vanishing from exported PDFs and PowerPoint files when they came from a site that blocks direct browser access, such as a blog or a stock photo host. Those images are now fetched through the app and appear in the export.
diff --git a/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md b/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md
new file mode 100644
index 0000000000..5f4409318f
--- /dev/null
+++ b/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-08-09
+---
+
+Fixed imported PDF text losing the space between words when a line changes color or weight mid-sentence, which ran headings like "7 Air purifying house plants" together into "7 Airpurifying".
diff --git a/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md b/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md
new file mode 100644
index 0000000000..8edda8a7fb
--- /dev/null
+++ b/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-08-09
+---
+
+Fixed imported PDF/PowerPoint slide text rendering larger than its original box (often overlapping neighboring text) whenever the source page's physical size didn't match the deck canvas's assumed size — font sizes now scale by the same factor as element positions instead of a fixed point-to-pixel conversion.
diff --git a/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md b/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md
new file mode 100644
index 0000000000..65b684c51b
--- /dev/null
+++ b/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-08-09
+---
+
+Fixed decks with many slides loading slowly and rendering incorrectly in the editor. Every slide thumbnail measured its full layout on mount, so a long deck forced hundreds of page reflows at once; off-screen thumbnails now wait until they scroll into view, and the browser skips painting them entirely until then. The hover buttons on each thumbnail also no longer blur what is behind them, which was making the rail flicker and leaving dark patches over the editor.
diff --git a/templates/slides/changelog/2026-08-09-revise-highlighted-slide-text-with-ai.md b/templates/slides/changelog/2026-08-09-revise-highlighted-slide-text-with-ai.md
new file mode 100644
index 0000000000..3b09ce1cc1
--- /dev/null
+++ b/templates/slides/changelog/2026-08-09-revise-highlighted-slide-text-with-ai.md
@@ -0,0 +1,6 @@
+---
+type: added
+date: 2026-08-09
+---
+
+Highlight text on a slide and the formatting bar now offers "Revise with AI". Describe the change you want — shorter, punchier, on-brand — and the agent rewrites just that text without disturbing the rest of the slide.
diff --git a/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md b/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md
new file mode 100644
index 0000000000..a4be982cca
--- /dev/null
+++ b/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md
@@ -0,0 +1,6 @@
+---
+type: fixed
+date: 2026-08-09
+---
+
+Fixed the deck editor staying covered by the mobile slide-rail dimming overlay after the window was narrowed, which washed the whole editor dark with no way to dismiss it.
diff --git a/templates/slides/server/handlers/import/html-converter.test.ts b/templates/slides/server/handlers/import/html-converter.test.ts
new file mode 100644
index 0000000000..48ddcc9572
--- /dev/null
+++ b/templates/slides/server/handlers/import/html-converter.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "vitest";
+
+import { convertToSlideHtml } from "./html-converter.js";
+import type { ParsedElement, ParsedSlide } from "./pptx-parser.js";
+
+/**
+ * Real numbers from a portrait PDF page (10287000 x 12852400 EMU, ratio
+ * 0.8) that reproduced the reported bug: a square background photo
+ * rendered squashed into the top ~50% of the slide, and the title text sat
+ * in the middle of the canvas instead of near the bottom.
+ */
+function portraitSlide(): ParsedSlide {
+ const widthEmu = 10287000;
+ const heightEmu = 12852400;
+ const image: ParsedElement = {
+ id: "img-1",
+ kind: "image",
+ x: -1294848,
+ y: -89725,
+ width: 12948475,
+ height: 12948475,
+ };
+ return {
+ texts: [],
+ images: [],
+ elements: [image],
+ widthEmu,
+ heightEmu,
+ };
+}
+
+/**
+ * Real numbers for a standard 13.33in x 7.5in widescreen PPTX slide
+ * (12192000 x 6858000 EMU, exactly 16:9) — the common case, not an edge
+ * case. `toSlidePxX`/`toSlidePxY` scale this down to the 960x540 reference
+ * box; font sizes must scale by the same factor instead of a fixed pt->px
+ * conversion, or every run renders larger than its box expects.
+ */
+function widescreenTextSlide(fontSizePt: number): ParsedSlide {
+ const widthEmu = 12192000;
+ const heightEmu = 6858000;
+ const text: ParsedElement = {
+ id: "text-1",
+ kind: "text",
+ x: 0,
+ y: 0,
+ width: widthEmu,
+ height: heightEmu,
+ paragraphs: [{ runs: [{ content: "Hi", fontSize: fontSizePt }] }],
+ };
+ return {
+ texts: [],
+ images: [],
+ elements: [text],
+ widthEmu,
+ heightEmu,
+ };
+}
+
+function styleAttr(html: string, dataAttr: string): string {
+ const marker = `data-pptx-element-kind="${dataAttr}"`;
+ const start = html.indexOf(marker);
+ const styleStart = html.indexOf('style="', start) + 'style="'.length;
+ const styleEnd = html.indexOf('"', styleStart);
+ return html.slice(styleStart, styleEnd);
+}
+
+function pxValue(style: string, prop: string): number {
+ const match = style.match(new RegExp(`${prop}:\\s*([\\d.]+)px`));
+ if (!match) throw new Error(`missing ${prop} in ${style}`);
+ return Number(match[1]);
+}
+
+describe("convertToSlideHtml fidelity positioning", () => {
+ it("scales a portrait/non-16:9 slide's elements against its own aspect ratio, not a fixed 16:9 box", () => {
+ const html = convertToSlideHtml(portraitSlide());
+ const imageStyle = styleAttr(html, "image");
+
+ const width = pxValue(imageStyle, "width");
+ const height = pxValue(imageStyle, "height");
+
+ // The source image is square in EMU (width === height): isotropic
+ // scaling must keep it square in the rendered px box too.
+ expect(width).toBeCloseTo(height, -1);
+
+ // The nearest aspect-ratio preset for a 0.8 ratio slide is "4:5"
+ // (864x1080) — the image should span (near) the full 1080px canvas
+ // height, not the old fixed 540px reference that squashed it in half.
+ expect(height).toBeGreaterThan(1000);
+ });
+});
+
+describe("convertToSlideHtml fidelity text sizing", () => {
+ it("scales run font size by the same EMU-relative factor as element positions", () => {
+ const html = convertToSlideHtml(widescreenTextSlide(24));
+ const match = html.match(/font-size:([\d.]+)px/);
+ if (!match) throw new Error("missing font-size in rendered run");
+ // 24pt -> 304800 EMU -> * (960 / 12192000) = 24px, not the fixed
+ // 24 * 96/72 = 32px a source-size-blind pt->px conversion would give.
+ expect(Number(match[1])).toBeCloseTo(24, 0);
+ });
+});
diff --git a/templates/slides/server/handlers/import/html-converter.ts b/templates/slides/server/handlers/import/html-converter.ts
index 4f844899c6..f54b81dfba 100644
--- a/templates/slides/server/handlers/import/html-converter.ts
+++ b/templates/slides/server/handlers/import/html-converter.ts
@@ -1,3 +1,5 @@
+import { ASPECT_RATIOS } from "@shared/aspect-ratios";
+
import type {
ParsedElement,
ParsedParagraph,
@@ -165,10 +167,37 @@ export function convertToSlideHtml(
const DEFAULT_SLIDE_WIDTH_EMU = 9144000;
const DEFAULT_SLIDE_HEIGHT_EMU = 5143500;
-const CSS_PX_PER_POINT = 96 / 72;
const DEFAULT_PPTX_BACKGROUND = "#000000"; // guard:allow-raw-color - preserve PPTX black when no background is declared
const DEFAULT_PPTX_FOREGROUND = "#ffffff"; // guard:allow-raw-color - preserve PPTX white when no run color is declared
+/**
+ * The absolute px box `toSlidePxX`/`toSlidePxY` scale positions and sizes
+ * against. It must match the aspect-ratio preset the deck actually renders
+ * into (`ASPECT_RATIOS`, chosen by the import actions' own
+ * `nearestAspectRatio`) rather than a fixed 16:9 box: a PDF page or a custom
+ * PPTX slide size is routinely portrait or square, and scaling its elements
+ * against a 960x540 reference while the deck itself renders in an 864x1080
+ * (or other) box stretches every element by the ratio between the two
+ * boxes, most visibly squashing everything into the top fraction of a
+ * taller-than-540 canvas.
+ */
+function referenceBoxForSlide(
+ widthEmu: number,
+ heightEmu: number,
+): { width: number; height: number } {
+ const target = widthEmu / heightEmu;
+ let best: { width: number; height: number } = ASPECT_RATIOS["16:9"];
+ let bestDiff = Infinity;
+ for (const preset of Object.values(ASPECT_RATIOS)) {
+ const diff = Math.abs(preset.width / preset.height - target);
+ if (diff < bestDiff) {
+ bestDiff = diff;
+ best = preset;
+ }
+ }
+ return { width: best.width, height: best.height };
+}
+
function buildFidelitySlide(
slide: ParsedSlide,
imageUrls: string | Record | undefined,
@@ -176,9 +205,10 @@ function buildFidelitySlide(
): string {
const widthEmu = slide.widthEmu || DEFAULT_SLIDE_WIDTH_EMU;
const heightEmu = slide.heightEmu || DEFAULT_SLIDE_HEIGHT_EMU;
+ const refBox = referenceBoxForSlide(widthEmu, heightEmu);
const background = slide.backgroundColor ?? DEFAULT_PPTX_BACKGROUND;
const gridStyle = slide.backgroundGrid
- ? `background-image:linear-gradient(to right, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu))}px, transparent ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu))}px),linear-gradient(to bottom, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu))}px, transparent ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu))}px);background-size:${toSlidePxX(slide.backgroundGrid.stepXEmu, widthEmu)}px ${toSlidePxY(slide.backgroundGrid.stepYEmu, heightEmu)}px;background-position:${toSlidePxX(slide.backgroundGrid.offsetXEmu, widthEmu)}px ${toSlidePxY(slide.backgroundGrid.offsetYEmu, heightEmu)}px;background-repeat:repeat;`
+ ? `background-image:linear-gradient(to right, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu, refBox.width))}px, transparent ${Math.max(0.5, toSlidePxX(slide.backgroundGrid.lineWidthEmu, widthEmu, refBox.width))}px),linear-gradient(to bottom, ${esc(slide.backgroundGrid.color)} 0 ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu, refBox.height))}px, transparent ${Math.max(0.5, toSlidePxY(slide.backgroundGrid.lineWidthEmu, heightEmu, refBox.height))}px);background-size:${toSlidePxX(slide.backgroundGrid.stepXEmu, widthEmu, refBox.width)}px ${toSlidePxY(slide.backgroundGrid.stepYEmu, heightEmu, refBox.height)}px;background-position:${toSlidePxX(slide.backgroundGrid.offsetXEmu, widthEmu, refBox.width)}px ${toSlidePxY(slide.backgroundGrid.offsetYEmu, heightEmu, refBox.height)}px;background-repeat:repeat;`
: "";
const elements = slide.elements ?? [];
const html = elements
@@ -188,6 +218,7 @@ function buildFidelitySlide(
index,
widthEmu,
heightEmu,
+ refBox,
imageUrls,
themeFont,
),
@@ -203,10 +234,11 @@ function buildFidelityElement(
index: number,
widthEmu: number,
heightEmu: number,
+ refBox: { width: number; height: number },
imageUrls: string | Record | undefined,
themeFont: string | undefined,
): string {
- const position = `position: absolute; left: ${toSlidePxX(element.x, widthEmu)}px; top: ${toSlidePxY(element.y, heightEmu)}px; width: ${toSlidePxX(element.width, widthEmu)}px; height: ${toSlidePxY(element.height, heightEmu)}px; z-index: ${index}; box-sizing: border-box;`;
+ const position = `position: absolute; left: ${toSlidePxX(element.x, widthEmu, refBox.width)}px; top: ${toSlidePxY(element.y, heightEmu, refBox.height)}px; width: ${toSlidePxX(element.width, widthEmu, refBox.width)}px; height: ${toSlidePxY(element.height, heightEmu, refBox.height)}px; z-index: ${index}; box-sizing: border-box;`;
const rotation = element.rotation
? ` transform: rotate(${element.rotation}deg); transform-origin: center center;`
: "";
@@ -218,12 +250,18 @@ function buildFidelityElement(
return `${url ? `
})
` : `
Imported image: ${esc(element.image?.name ?? "image")}
`}
`;
}
- const decoration = shapeDecoration(element, widthEmu);
+ const decoration = shapeDecoration(element, widthEmu, refBox.width);
if (element.kind === "shape") {
return ``;
}
- const textStyle = textBoxStyle(element, widthEmu, heightEmu, themeFont);
+ const textStyle = textBoxStyle(
+ element,
+ widthEmu,
+ heightEmu,
+ refBox,
+ themeFont,
+ );
const defaultFontWeight = element.placeholderType === "title" ? 700 : 400;
const paragraphs = (element.paragraphs ?? [])
.map((paragraph, paragraphIndex) =>
@@ -231,6 +269,7 @@ function buildFidelityElement(
paragraph,
paragraphIndex,
widthEmu,
+ refBox.width,
themeFont,
defaultFontWeight,
),
@@ -239,12 +278,41 @@ function buildFidelityElement(
return `${paragraphs}
`;
}
-function toSlidePxX(valueEmu: number, slideWidthEmu: number): number {
- return Math.round((valueEmu / slideWidthEmu) * 960 * 1000) / 1000;
+function toSlidePxX(
+ valueEmu: number,
+ slideWidthEmu: number,
+ refWidthPx: number,
+): number {
+ return Math.round((valueEmu / slideWidthEmu) * refWidthPx * 1000) / 1000;
}
-function toSlidePxY(valueEmu: number, slideHeightEmu: number): number {
- return Math.round((valueEmu / slideHeightEmu) * 540 * 1000) / 1000;
+function toSlidePxY(
+ valueEmu: number,
+ slideHeightEmu: number,
+ refHeightPx: number,
+): number {
+ return Math.round((valueEmu / slideHeightEmu) * refHeightPx * 1000) / 1000;
+}
+
+const EMU_PER_POINT = 12700;
+
+/**
+ * A run's font size (and paragraph spacing) is stored in points, a physical
+ * unit independent of the source slide's own canvas size — unlike
+ * position/size EMUs, a fixed `pt * 96/72` conversion doesn't know how far
+ * `toSlidePxX`/`toSlidePxY` scaled that canvas down (or up) to fit the
+ * deck's aspect-ratio box. Converting the point value to EMU first and
+ * running it through the same `toSlidePxX` scale keeps text sized
+ * proportionally to its box on every source slide size, not just the one
+ * physical size (10in wide) that happens to make the fixed conversion agree
+ * with the 16:9 preset's box.
+ */
+function ptToSlidePx(
+ valuePt: number,
+ widthEmu: number,
+ refWidthPx: number,
+): number {
+ return toSlidePxX(valuePt * EMU_PER_POINT, widthEmu, refWidthPx);
}
function imageUrlForElement(
@@ -263,10 +331,14 @@ function imageRenderStyle(element: ParsedElement): string {
return `display:block;position:absolute;left:${(-crop.left / visibleWidth) * 100}%;top:${(-crop.top / visibleHeight) * 100}%;width:${(1 / visibleWidth) * 100}%;height:${(1 / visibleHeight) * 100}%;object-fit:fill;`;
}
-function shapeDecoration(element: ParsedElement, widthEmu: number): string {
+function shapeDecoration(
+ element: ParsedElement,
+ widthEmu: number,
+ refWidthPx: number,
+): string {
const fill = element.fill ? `background: ${esc(element.fill)};` : "";
const line = element.lineColor
- ? `border: ${Math.max(1, toSlidePxX(element.lineWidth ?? 12700, widthEmu))}px solid ${esc(element.lineColor)};`
+ ? `border: ${Math.max(1, toSlidePxX(element.lineWidth ?? 12700, widthEmu, refWidthPx))}px solid ${esc(element.lineColor)};`
: "";
const radius = element.shapeType === "roundRect" ? "border-radius: 6px;" : "";
return `${fill}${line}${radius}`;
@@ -276,13 +348,16 @@ function textBoxStyle(
element: ParsedElement,
widthEmu: number,
heightEmu: number,
+ refBox: { width: number; height: number },
themeFont: string | undefined,
): string {
const padding = element.padding;
- const left = padding ? toSlidePxX(padding.left, widthEmu) : 0;
- const right = padding ? toSlidePxX(padding.right, widthEmu) : 0;
- const top = padding ? toSlidePxY(padding.top, heightEmu) : 0;
- const bottom = padding ? toSlidePxY(padding.bottom, heightEmu) : 0;
+ const left = padding ? toSlidePxX(padding.left, widthEmu, refBox.width) : 0;
+ const right = padding ? toSlidePxX(padding.right, widthEmu, refBox.width) : 0;
+ const top = padding ? toSlidePxY(padding.top, heightEmu, refBox.height) : 0;
+ const bottom = padding
+ ? toSlidePxY(padding.bottom, heightEmu, refBox.height)
+ : 0;
const align = element.paragraphs?.[0]?.alignment ?? "left";
const vertical =
element.verticalAlign === "middle"
@@ -297,37 +372,55 @@ function buildFidelityParagraph(
paragraph: ParsedParagraph,
paragraphIndex: number,
widthEmu: number,
+ refWidthPx: number,
themeFont: string | undefined,
defaultFontWeight: number,
): string {
const firstRun = paragraph.runs[0];
- const fontSize = (firstRun?.fontSize ?? 18) * CSS_PX_PER_POINT;
+ const fontSize = ptToSlidePx(firstRun?.fontSize ?? 18, widthEmu, refWidthPx);
const lineHeight = paragraph.lineSpacing ?? 1.2;
+ const bulletFontSize = ptToSlidePx(
+ paragraph.bulletSize ?? firstRun?.fontSize ?? 18,
+ widthEmu,
+ refWidthPx,
+ );
const bullet = paragraph.bulletChar
- ? `${esc(paragraph.bulletChar)}`
+ ? `${esc(paragraph.bulletChar)}`
: "";
const marginLeft = paragraph.marginLeftEmu
- ? toSlidePxX(paragraph.marginLeftEmu, widthEmu)
+ ? toSlidePxX(paragraph.marginLeftEmu, widthEmu, refWidthPx)
: 0;
const indent = paragraph.indentEmu
- ? toSlidePxX(paragraph.indentEmu, widthEmu)
+ ? toSlidePxX(paragraph.indentEmu, widthEmu, refWidthPx)
: 0;
const spacingBefore = paragraph.spaceBeforePt ?? 0;
const spacingAfter = paragraph.spaceAfterPt ?? 0;
const bulletMargin = paragraph.bulletChar ? `margin-left:${indent}px;` : "";
+ const marginBefore = ptToSlidePx(spacingBefore, widthEmu, refWidthPx);
+ const marginAfter = ptToSlidePx(spacingAfter, widthEmu, refWidthPx);
const text = paragraph.runs
- .map((run) => formatFidelityRun(run, themeFont, defaultFontWeight))
+ .map((run) =>
+ formatFidelityRun(
+ run,
+ widthEmu,
+ refWidthPx,
+ themeFont,
+ defaultFontWeight,
+ ),
+ )
.join("");
- return `${bullet.replace("display:inline-block;", `display:inline-block;${bulletMargin}`)}${text}
`;
+ return `${bullet.replace("display:inline-block;", `display:inline-block;${bulletMargin}`)}${text}
`;
}
function formatFidelityRun(
run: ParsedTextRun,
+ widthEmu: number,
+ refWidthPx: number,
themeFont: string | undefined,
defaultFontWeight = 400,
): string {
const styles = [
- `font-size:${(run.fontSize ?? 18) * CSS_PX_PER_POINT}px`,
+ `font-size:${ptToSlidePx(run.fontSize ?? 18, widthEmu, refWidthPx)}px`,
`font-family:${cssFontFamily(run.fontFamily ?? themeFont)}`,
`color:${esc(run.color ?? DEFAULT_PPTX_FOREGROUND)}`,
`font-weight:${run.bold ? 700 : fontWeightForFamily(run.fontFamily, defaultFontWeight)}`,
diff --git a/templates/slides/server/handlers/import/pdf-fidelity-parser.spec.ts b/templates/slides/server/handlers/import/pdf-fidelity-parser.spec.ts
index 1a9dbb20a1..082eb758a2 100644
--- a/templates/slides/server/handlers/import/pdf-fidelity-parser.spec.ts
+++ b/templates/slides/server/handlers/import/pdf-fidelity-parser.spec.ts
@@ -193,6 +193,40 @@ describe("mergeLineRuns", () => {
]);
expect(runs).toHaveLength(2);
});
+
+ it("keeps the word gap across a style change so the words don't jam together", () => {
+ const runs = mergeLineRuns([
+ box({
+ text: "7 Air",
+ left: 0,
+ right: 60,
+ fontSize: 40,
+ color: "#ffffff",
+ }),
+ box({
+ text: "purifying",
+ left: 80,
+ right: 200,
+ fontSize: 40,
+ color: "#18b6f6",
+ }),
+ ]);
+ expect(runs.map((r) => r.text).join("")).toBe("7 Air purifying");
+ });
+
+ it("does not double a space that either side already carries", () => {
+ const runs = mergeLineRuns([
+ box({ text: "Nike NYC: ", left: 0, right: 60, fontSize: 40 }),
+ box({
+ text: "Event Details",
+ left: 80,
+ right: 200,
+ fontSize: 40,
+ color: "#18b6f6",
+ }),
+ ]);
+ expect(runs.map((r) => r.text).join("")).toBe("Nike NYC: Event Details");
+ });
});
describe("groupIntoStyledLines", () => {
diff --git a/templates/slides/server/handlers/import/pdf-fidelity-parser.ts b/templates/slides/server/handlers/import/pdf-fidelity-parser.ts
index 4c8c6b7714..cba0457630 100644
--- a/templates/slides/server/handlers/import/pdf-fidelity-parser.ts
+++ b/templates/slides/server/handlers/import/pdf-fidelity-parser.ts
@@ -380,14 +380,23 @@ export function mergeLineRuns(items: TextRunBox[]): TextRunBox[] {
prev.italic === item.italic &&
prev.underline === item.underline &&
prev.href === item.href;
+ const needsSpace =
+ prev !== undefined && item.left - prev.right > item.fontSize * 0.25;
if (prev && sameStyle) {
- const needsSpace = item.left - prev.right > item.fontSize * 0.25;
prev.text += (needsSpace ? " " : "") + item.text;
prev.right = Math.max(prev.right, item.right);
prev.top = Math.min(prev.top, item.top);
prev.bottom = Math.max(prev.bottom, item.bottom);
} else {
- runs.push({ ...item });
+ // A word-sized gap has to survive a style change too. Only the
+ // same-style branch used to re-add it, so a heading whose colour
+ // changed mid-line ("7 Air " + "purifying") lost the space at the
+ // boundary and rendered as one jammed-together word.
+ const separator =
+ needsSpace && !/\s$/.test(prev?.text ?? "") && !/^\s/.test(item.text)
+ ? " "
+ : "";
+ runs.push({ ...item, text: separator + item.text });
}
}
return runs;
diff --git a/templates/slides/server/lib/fetch-remote-image.test.ts b/templates/slides/server/lib/fetch-remote-image.test.ts
new file mode 100644
index 0000000000..81dd54a8b3
--- /dev/null
+++ b/templates/slides/server/lib/fetch-remote-image.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+
+import { fetchRemoteImage, publicOnlyLookup } from "./fetch-remote-image.js";
+
+function lookupResult(
+ hostname: string,
+ options: Record = {},
+): Promise<{ err: NodeJS.ErrnoException | null; address: unknown }> {
+ return new Promise((resolve) => {
+ (
+ publicOnlyLookup as unknown as (
+ host: string,
+ opts: Record,
+ cb: (err: NodeJS.ErrnoException | null, address: unknown) => void,
+ ) => void
+ )(hostname, options, (err, address) => resolve({ err, address }));
+ });
+}
+
+describe("publicOnlyLookup", () => {
+ it("refuses a hostname that resolves to loopback", async () => {
+ // The socket asks for the address at connect time, so this is the check
+ // a DNS-rebinding host would otherwise slip past. `localhost` resolves
+ // from the hosts file, so no network is needed.
+ const { err } = await lookupResult("localhost");
+ expect(err?.code).toBe("EBLOCKED");
+ });
+
+ it("refuses loopback even when all addresses are requested", async () => {
+ const { err } = await lookupResult("localhost", { all: true });
+ expect(err?.code).toBe("EBLOCKED");
+ });
+});
+
+describe("fetchRemoteImage", () => {
+ it("refuses a loopback literal before opening a socket", async () => {
+ const result = await fetchRemoteImage("http://127.0.0.1:9/secret.png");
+ expect(result).toEqual({ ok: false, reason: "unsupported-url" });
+ });
+
+ it("refuses the cloud metadata address", async () => {
+ const result = await fetchRemoteImage(
+ "http://169.254.169.254/latest/meta-data/",
+ );
+ expect(result).toEqual({ ok: false, reason: "unsupported-url" });
+ });
+
+ it("refuses a non-http scheme", async () => {
+ const result = await fetchRemoteImage("file:///etc/passwd");
+ expect(result).toEqual({ ok: false, reason: "unsupported-url" });
+ });
+
+ it("refuses a hostname whose only addresses are private", async () => {
+ // Reaches the socket layer, where publicOnlyLookup rejects it.
+ const result = await fetchRemoteImage("http://localhost.localdomain/a.png");
+ expect(result.ok).toBe(false);
+ });
+});
diff --git a/templates/slides/server/lib/fetch-remote-image.ts b/templates/slides/server/lib/fetch-remote-image.ts
new file mode 100644
index 0000000000..968f5f56a1
--- /dev/null
+++ b/templates/slides/server/lib/fetch-remote-image.ts
@@ -0,0 +1,192 @@
+import { lookup as dnsLookup, type LookupAddress } from "node:dns";
+import http from "node:http";
+import https from "node:https";
+import type { LookupFunction } from "node:net";
+
+import {
+ isPrivateAddress,
+ MAX_PROXIED_IMAGE_BYTES,
+ MAX_PROXY_REDIRECTS,
+ parseProxyableImageUrl,
+} from "./image-proxy-url.js";
+
+export type RemoteImageFailure =
+ | "unsupported-url"
+ | "blocked-address"
+ | "fetch-failed"
+ | "too-many-redirects"
+ | "not-an-image"
+ | "too-large";
+
+export type RemoteImageResult =
+ | { ok: true; contentType: string; body: Buffer }
+ | { ok: false; reason: RemoteImageFailure };
+
+const REQUEST_TIMEOUT_MS = 15_000;
+
+/**
+ * A `lookup` implementation that only ever hands the socket an address we have
+ * classified as public.
+ *
+ * Validating the hostname separately and then calling `fetch` leaves a gap: the
+ * two resolutions are independent, so a DNS-rebinding host can answer with a
+ * public address for the check and a loopback or metadata address for the
+ * actual connection. Because Node passes this straight to `net.connect`, the
+ * address that is checked here is the address that gets dialled.
+ */
+export const publicOnlyLookup: LookupFunction = (
+ hostname,
+ options,
+ callback,
+) => {
+ const wantsAll =
+ typeof options === "object" && options !== null && options.all === true;
+ const hints = typeof options === "object" && options !== null ? options : {};
+
+ dnsLookup(hostname, { ...hints, all: true }, (err, addresses) => {
+ if (err) {
+ (callback as (e: NodeJS.ErrnoException) => void)(err);
+ return;
+ }
+ const safe = (addresses as LookupAddress[]).filter(
+ (entry) => !isPrivateAddress(entry.address),
+ );
+ if (safe.length === 0) {
+ const blocked: NodeJS.ErrnoException = new Error(
+ `Refusing to connect to a non-public address for ${hostname}`,
+ );
+ blocked.code = "EBLOCKED";
+ (callback as (e: NodeJS.ErrnoException) => void)(blocked);
+ return;
+ }
+ if (wantsAll) {
+ (callback as unknown as (e: null, a: LookupAddress[]) => void)(
+ null,
+ safe,
+ );
+ return;
+ }
+ callback(null, safe[0].address, safe[0].family);
+ });
+};
+
+interface HopResult {
+ status: number;
+ headers: http.IncomingHttpHeaders;
+ read: () => Promise;
+ discard: () => void;
+}
+
+function requestHop(target: URL): Promise {
+ return new Promise((resolve, reject) => {
+ const transport = target.protocol === "https:" ? https : http;
+ const request = transport.request(
+ target,
+ {
+ method: "GET",
+ headers: { Accept: "image/*" },
+ lookup: publicOnlyLookup,
+ },
+ (response) => {
+ resolve({
+ status: response.statusCode ?? 0,
+ headers: response.headers,
+ discard: () => response.destroy(),
+ read: () =>
+ new Promise((resolveBody, rejectBody) => {
+ const chunks: Buffer[] = [];
+ let total = 0;
+ response.on("data", (chunk: Buffer) => {
+ total += chunk.length;
+ // Stop at the cap instead of buffering first and measuring
+ // after: a chunked response, or one that lies about
+ // Content-Length, would otherwise pull the whole body into
+ // memory before anyone checks it.
+ if (total > MAX_PROXIED_IMAGE_BYTES) {
+ response.destroy();
+ resolveBody("too-large");
+ return;
+ }
+ chunks.push(chunk);
+ });
+ response.on("end", () => resolveBody(Buffer.concat(chunks)));
+ response.on("error", rejectBody);
+ }),
+ });
+ },
+ );
+
+ request.setTimeout(REQUEST_TIMEOUT_MS, () => {
+ request.destroy(new Error("Timed out fetching image"));
+ });
+ request.on("error", reject);
+ request.end();
+ });
+}
+
+/**
+ * Fetch a remote image for the proxy route. Every hop is re-parsed against the
+ * URL policy and every connection is pinned to a validated public address.
+ */
+export async function fetchRemoteImage(
+ raw: string,
+): Promise {
+ const firstTarget = parseProxyableImageUrl(raw);
+ if (!firstTarget) return { ok: false, reason: "unsupported-url" };
+ let target: URL = firstTarget;
+
+ for (let hop = 0; hop <= MAX_PROXY_REDIRECTS; hop++) {
+ let response: HopResult;
+ try {
+ response = await requestHop(target);
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException)?.code;
+ return {
+ ok: false,
+ reason: code === "EBLOCKED" ? "blocked-address" : "fetch-failed",
+ };
+ }
+
+ if (response.status >= 300 && response.status < 400) {
+ response.discard();
+ const location = response.headers.location;
+ const next: URL | null = location
+ ? parseProxyableImageUrl(new URL(location, target).href)
+ : null;
+ if (!next) return { ok: false, reason: "fetch-failed" };
+ target = next;
+ continue;
+ }
+
+ if (response.status < 200 || response.status >= 300) {
+ response.discard();
+ return { ok: false, reason: "fetch-failed" };
+ }
+
+ const contentType = String(response.headers["content-type"] ?? "");
+ if (!contentType.startsWith("image/")) {
+ response.discard();
+ return { ok: false, reason: "not-an-image" };
+ }
+
+ const declared = Number(response.headers["content-length"]);
+ if (Number.isFinite(declared) && declared > MAX_PROXIED_IMAGE_BYTES) {
+ response.discard();
+ return { ok: false, reason: "too-large" };
+ }
+
+ let body: Buffer | "too-large";
+ try {
+ body = await response.read();
+ } catch {
+ // coercion-ok: a truncated transfer has no partial-success form here;
+ // the caller answers 502 either way.
+ return { ok: false, reason: "fetch-failed" };
+ }
+ if (body === "too-large") return { ok: false, reason: "too-large" };
+
+ return { ok: true, contentType, body };
+ }
+
+ return { ok: false, reason: "too-many-redirects" };
+}
diff --git a/templates/slides/server/lib/image-proxy-url.test.ts b/templates/slides/server/lib/image-proxy-url.test.ts
new file mode 100644
index 0000000000..f00560aa2a
--- /dev/null
+++ b/templates/slides/server/lib/image-proxy-url.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from "vitest";
+
+import { isPrivateAddress, parseProxyableImageUrl } from "./image-proxy-url.js";
+
+describe("parseProxyableImageUrl", () => {
+ it("accepts a public https image URL", () => {
+ const url = parseProxyableImageUrl(
+ "https://nouveauraw.com/wp-content/uploads/2020/01/ZZ-Plant-800.png",
+ );
+ expect(url?.hostname).toBe("nouveauraw.com");
+ });
+
+ it("accepts plain http", () => {
+ expect(parseProxyableImageUrl("http://example.com/a.png")).not.toBeNull();
+ });
+
+ it("refuses non-http protocols", () => {
+ expect(parseProxyableImageUrl("file:///etc/passwd")).toBeNull();
+ expect(parseProxyableImageUrl("data:image/png;base64,AAA")).toBeNull();
+ expect(parseProxyableImageUrl("gopher://example.com/")).toBeNull();
+ });
+
+ it("refuses loopback and internal hostnames", () => {
+ expect(parseProxyableImageUrl("http://localhost/a.png")).toBeNull();
+ expect(parseProxyableImageUrl("http://foo.localhost/a.png")).toBeNull();
+ expect(parseProxyableImageUrl("http://printer.local/a.png")).toBeNull();
+ expect(parseProxyableImageUrl("http://vault.internal/a.png")).toBeNull();
+ expect(
+ parseProxyableImageUrl("http://metadata.google.internal/token"),
+ ).toBeNull();
+ });
+
+ it("refuses private and link-local IP literals", () => {
+ expect(parseProxyableImageUrl("http://127.0.0.1/a.png")).toBeNull();
+ expect(parseProxyableImageUrl("http://10.0.0.5/a.png")).toBeNull();
+ expect(parseProxyableImageUrl("http://192.168.1.10/a.png")).toBeNull();
+ expect(parseProxyableImageUrl("http://172.16.4.4/a.png")).toBeNull();
+ expect(parseProxyableImageUrl("http://169.254.169.254/latest")).toBeNull();
+ expect(parseProxyableImageUrl("http://[::1]/a.png")).toBeNull();
+ });
+
+ it("refuses embedded credentials so they are not replayed server-side", () => {
+ expect(
+ parseProxyableImageUrl("https://user:pass@example.com/a.png"),
+ ).toBeNull();
+ });
+
+ it("refuses unparseable input", () => {
+ expect(parseProxyableImageUrl("")).toBeNull();
+ expect(parseProxyableImageUrl("not a url")).toBeNull();
+ });
+
+ it("refuses a trailing-dot loopback spelling", () => {
+ expect(parseProxyableImageUrl("http://localhost./a.png")).toBeNull();
+ });
+});
+
+describe("isPrivateAddress", () => {
+ it("treats public addresses as public", () => {
+ expect(isPrivateAddress("8.8.8.8")).toBe(false);
+ expect(isPrivateAddress("2606:4700:4700::1111")).toBe(false);
+ });
+
+ it("treats RFC1918, loopback, and CGNAT as private", () => {
+ expect(isPrivateAddress("10.1.2.3")).toBe(true);
+ expect(isPrivateAddress("172.31.255.255")).toBe(true);
+ expect(isPrivateAddress("192.168.0.1")).toBe(true);
+ expect(isPrivateAddress("127.0.0.1")).toBe(true);
+ expect(isPrivateAddress("100.64.0.1")).toBe(true);
+ expect(isPrivateAddress("0.0.0.0")).toBe(true);
+ });
+
+ it("treats IPv6 loopback, unique-local, and link-local as private", () => {
+ expect(isPrivateAddress("::1")).toBe(true);
+ expect(isPrivateAddress("fd00::1")).toBe(true);
+ expect(isPrivateAddress("fe80::1")).toBe(true);
+ });
+
+ it("unwraps IPv4-mapped IPv6 before deciding", () => {
+ expect(isPrivateAddress("::ffff:169.254.169.254")).toBe(true);
+ expect(isPrivateAddress("::ffff:8.8.8.8")).toBe(false);
+ });
+
+ it("unwraps the hexadecimal spelling of IPv4-mapped addresses", () => {
+ // The same addresses as above, written without the dotted tail. A
+ // prefix/regex check on the text sees these as ordinary public IPv6.
+ expect(isPrivateAddress("::ffff:7f00:1")).toBe(true);
+ expect(isPrivateAddress("::ffff:a9fe:a9fe")).toBe(true);
+ expect(isPrivateAddress("::ffff:c0a8:1")).toBe(true);
+ expect(isPrivateAddress("::ffff:0808:0808")).toBe(false);
+ });
+
+ it("treats IPv4-compatible and NAT64 embeddings as their inner address", () => {
+ expect(isPrivateAddress("::7f00:1")).toBe(true);
+ expect(isPrivateAddress("64:ff9b::7f00:1")).toBe(true);
+ expect(isPrivateAddress("64:ff9b::8.8.8.8")).toBe(false);
+ });
+
+ it("ignores a zone index when classifying", () => {
+ expect(isPrivateAddress("fe80::1%eth0")).toBe(true);
+ });
+
+ it("catches fully expanded spellings", () => {
+ expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true);
+ expect(isPrivateAddress("fd00:0:0:0:0:0:0:1")).toBe(true);
+ expect(isPrivateAddress("ff02::1")).toBe(true);
+ });
+
+ it("treats anything unparseable as private", () => {
+ expect(isPrivateAddress("nonsense")).toBe(true);
+ });
+});
diff --git a/templates/slides/server/lib/image-proxy-url.ts b/templates/slides/server/lib/image-proxy-url.ts
new file mode 100644
index 0000000000..fce6b0b81c
--- /dev/null
+++ b/templates/slides/server/lib/image-proxy-url.ts
@@ -0,0 +1,192 @@
+import net from "node:net";
+
+/**
+ * Hostnames that never belong to a legitimate slide image but are classic
+ * SSRF targets. Cloud metadata endpoints resolve to a link-local address
+ * that `isPrivateAddress` already rejects; they are listed here so the
+ * request is refused before a DNS query is issued.
+ */
+const BLOCKED_HOSTNAMES = new Set([
+ "localhost",
+ "metadata.google.internal",
+ "metadata",
+]);
+
+const BLOCKED_SUFFIXES = [".localhost", ".local", ".internal", ".home.arpa"];
+
+/** Largest image we are willing to buffer and hand back to the browser. */
+export const MAX_PROXIED_IMAGE_BYTES = 15 * 1024 * 1024;
+
+/** Redirect hops to follow. Each hop is re-validated before it is fetched. */
+export const MAX_PROXY_REDIRECTS = 3;
+
+/**
+ * Whether an IP literal points somewhere inside our own infrastructure.
+ * Anything unroutable, loopback, link-local, or RFC1918 is refused, as is
+ * any address we cannot parse — unknown means unsafe here.
+ */
+export function isPrivateAddress(address: string): boolean {
+ const version = net.isIP(address);
+
+ if (version === 4) {
+ const parts = address.split(".").map((part) => Number(part));
+ if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) {
+ return true;
+ }
+ const [a, b] = parts;
+ if (a === 0 || a === 10 || a === 127) return true;
+ if (a === 169 && b === 254) return true;
+ if (a === 172 && b >= 16 && b <= 31) return true;
+ if (a === 192 && b === 168) return true;
+ if (a === 100 && b >= 64 && b <= 127) return true;
+ if (a >= 224) return true;
+ return false;
+ }
+
+ if (version === 6) {
+ // Classify on the expanded hextets, never on the text. `::ffff:127.0.0.1`
+ // and `::ffff:7f00:1` are the same address, and a prefix-matching check
+ // sees only the first.
+ const hextets = expandIpv6(address);
+ if (!hextets) return true;
+
+ const embedsIpv4 =
+ hextets.slice(0, 5).every((part) => part === 0) &&
+ (hextets[5] === 0xffff || hextets[5] === 0);
+ if (embedsIpv4 && (hextets[6] !== 0 || hextets[7] !== 0)) {
+ const ipv4 = [
+ hextets[6] >> 8,
+ hextets[6] & 0xff,
+ hextets[7] >> 8,
+ hextets[7] & 0xff,
+ ].join(".");
+ return isPrivateAddress(ipv4);
+ }
+
+ // Unspecified (::) and loopback (::1).
+ if (hextets.every((part) => part === 0)) return true;
+ if (hextets.slice(0, 7).every((part) => part === 0) && hextets[7] === 1) {
+ return true;
+ }
+ // Unique-local fc00::/7, link-local fe80::/10, multicast ff00::/8.
+ if ((hextets[0] & 0xfe00) === 0xfc00) return true;
+ if ((hextets[0] & 0xffc0) === 0xfe80) return true;
+ if ((hextets[0] & 0xff00) === 0xff00) return true;
+ // NAT64 well-known prefix 64:ff9b::/96 tunnels an IPv4 destination.
+ if (hextets[0] === 0x0064 && hextets[1] === 0xff9b) {
+ const ipv4 = [
+ hextets[6] >> 8,
+ hextets[6] & 0xff,
+ hextets[7] >> 8,
+ hextets[7] & 0xff,
+ ].join(".");
+ return isPrivateAddress(ipv4);
+ }
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Expand any textual IPv6 form into its 8 hextets, including `::` elision and
+ * a dotted IPv4 tail. Returns null when the input is not parseable.
+ */
+export function expandIpv6(address: string): number[] | null {
+ // Zone indices ("fe80::1%eth0") are routing hints, not part of the address.
+ const bare = address.toLowerCase().split("%")[0];
+ if (!bare) return null;
+
+ let head = bare;
+ let tail = "";
+ const elision = bare.indexOf("::");
+ if (elision !== -1) {
+ head = bare.slice(0, elision);
+ tail = bare.slice(elision + 2);
+ }
+
+ const parseGroups = (segment: string): string[] =>
+ segment.length === 0 ? [] : segment.split(":");
+
+ const headGroups = parseGroups(head);
+ const tailGroups = parseGroups(tail);
+ const all = [...headGroups, ...tailGroups];
+
+ // A dotted IPv4 tail occupies the final two hextets.
+ const last = all[all.length - 1];
+ let ipv4Tail: number[] | null = null;
+ if (last?.includes(".")) {
+ if (net.isIP(last) !== 4) return null;
+ const octets = last.split(".").map((part) => Number(part));
+ if (
+ octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
+ )
+ return null;
+ ipv4Tail = [(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]];
+ if (tailGroups.length > 0) tailGroups.pop();
+ else headGroups.pop();
+ }
+
+ const toHextets = (groups: string[]): number[] | null => {
+ const out: number[] = [];
+ for (const group of groups) {
+ if (!/^[0-9a-f]{1,4}$/.test(group)) return null;
+ out.push(parseInt(group, 16));
+ }
+ return out;
+ };
+
+ const headParts = toHextets(headGroups);
+ const tailParts = toHextets(tailGroups);
+ if (!headParts || !tailParts) return null;
+
+ const explicit = [...headParts, ...tailParts, ...(ipv4Tail ? ipv4Tail : [])]
+ .length;
+ if (explicit > 8) return null;
+
+ if (elision === -1) {
+ if (explicit !== 8) return null;
+ return [...headParts, ...(ipv4Tail ?? [])];
+ }
+
+ const fill = new Array(8 - explicit).fill(0) as number[];
+ return [...headParts, ...fill, ...tailParts, ...(ipv4Tail ?? [])];
+}
+
+/**
+ * Parse a caller-supplied image URL, refusing anything that is not a plain
+ * public http(s) resource. Returns null rather than throwing so callers can
+ * answer with a single 400.
+ */
+export function parseProxyableImageUrl(raw: string): URL | null {
+ if (!raw) return null;
+
+ let url: URL;
+ try {
+ url = new URL(raw);
+ } catch {
+ // coercion-ok: null is this function's documented "refused" result and
+ // the caller answers 400; an unparseable URL carries no other detail.
+ return null;
+ }
+
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
+ // Credentials in the URL would be replayed by the server on the user's
+ // behalf against a host they may not control.
+ if (url.username || url.password) return null;
+
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
+ if (!host) return null;
+ if (BLOCKED_HOSTNAMES.has(host)) return null;
+ if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return null;
+
+ const literal = host.startsWith("[") ? host.slice(1, -1) : host;
+ if (net.isIP(literal) && isPrivateAddress(literal)) return null;
+
+ return url;
+}
+
+// A hostname is no longer validated here before fetching. Resolving once for
+// the check and again for the connection let a DNS-rebinding host answer
+// differently each time, so the address check now lives in the socket's
+// `lookup` — see `publicOnlyLookup` in fetch-remote-image.ts.
diff --git a/templates/slides/server/routes/api/image-proxy.get.ts b/templates/slides/server/routes/api/image-proxy.get.ts
new file mode 100644
index 0000000000..83893e84ce
--- /dev/null
+++ b/templates/slides/server/routes/api/image-proxy.get.ts
@@ -0,0 +1,65 @@
+import { getSession } from "@agent-native/core/server";
+import { defineEventHandler, getQuery, setResponseStatus } from "h3";
+
+import {
+ fetchRemoteImage,
+ type RemoteImageFailure,
+} from "../../lib/fetch-remote-image.js";
+
+/**
+ * Re-serve a remote image from our own origin.
+ *
+ * PDF/PPTX export rasterizes the slide DOM through a canvas, and the browser
+ * blanks out any image whose host does not send `Access-Control-Allow-Origin`.
+ * No client-side flag can override that, so images on hosts without CORS have
+ * to come back through us to be same-origin.
+ *
+ * This is an authenticated, image-only, size-capped fetcher — not a general
+ * proxy. See `fetch-remote-image.ts` for the address pinning that keeps it
+ * from being turned into an SSRF primitive.
+ */
+const FAILURE_STATUS: Record = {
+ "unsupported-url": 400,
+ "blocked-address": 400,
+ "fetch-failed": 502,
+ "too-many-redirects": 502,
+ "not-an-image": 415,
+ "too-large": 413,
+};
+
+const FAILURE_MESSAGE: Record = {
+ "unsupported-url": "Unsupported image URL",
+ "blocked-address": "Unsupported image URL",
+ "fetch-failed": "Could not fetch image",
+ "too-many-redirects": "Too many redirects",
+ "not-an-image": "Not an image",
+ "too-large": "Image too large",
+};
+
+export default defineEventHandler(async (event) => {
+ const session = await getSession(event);
+ if (!session?.email) {
+ setResponseStatus(event, 401);
+ return { error: "Unauthorized" };
+ }
+
+ const raw = getQuery(event).url;
+ if (typeof raw !== "string") {
+ setResponseStatus(event, 400);
+ return { error: "Missing url" };
+ }
+
+ const result = await fetchRemoteImage(raw);
+ if (!result.ok) {
+ setResponseStatus(event, FAILURE_STATUS[result.reason]);
+ return { error: FAILURE_MESSAGE[result.reason] };
+ }
+
+ event.node?.res?.setHeader("Content-Type", result.contentType);
+ event.node?.res?.setHeader("Content-Length", String(result.body.byteLength));
+ event.node?.res?.setHeader("Cache-Control", "private, max-age=3600");
+ // The canvas reads these pixels back, so the response must be explicitly
+ // usable cross-origin even though it is served from our own host.
+ event.node?.res?.setHeader("Access-Control-Allow-Origin", "*");
+ return result.body;
+});