@agent-native/core 0.149.0 → 0.149.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/slides/actions/patch-deck.ts +18 -2
  3. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +46 -0
  4. package/corpus/templates/slides/app/components/editor/BlockBubbleMenu.tsx +194 -3
  5. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +10 -1
  6. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +5 -3
  7. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +26 -4
  8. package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +45 -0
  9. package/corpus/templates/slides/app/lib/export-pdf-client.ts +84 -25
  10. package/corpus/templates/slides/app/lib/import-uploaded-deck.ts +11 -0
  11. package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -0
  12. package/corpus/templates/slides/app/pages/Index.tsx +16 -9
  13. package/corpus/templates/slides/changelog/2026-08-08-google-slides-export-now-tells-you-why-a-deck-fell-back-to-a.md +6 -0
  14. package/corpus/templates/slides/changelog/2026-08-09-delete-key-now-removes-a-selected-image.md +6 -0
  15. package/corpus/templates/slides/changelog/2026-08-09-editing-a-slide-in-an-imported-deck-no-longer-fails-to-save.md +6 -0
  16. package/corpus/templates/slides/changelog/2026-08-09-fixed-pdf-and-custom-size-powerpoint-imports-rendering-with-.md +6 -0
  17. package/corpus/templates/slides/changelog/2026-08-09-images-from-sites-without-cors-no-longer-export-blank.md +6 -0
  18. package/corpus/templates/slides/changelog/2026-08-09-imported-pdf-headings-no-longer-lose-the-space-between-color.md +6 -0
  19. package/corpus/templates/slides/changelog/2026-08-09-imported-slide-text-no-longer-renders-too-large-for-its-box.md +6 -0
  20. package/corpus/templates/slides/changelog/2026-08-09-large-decks-no-longer-render-slowly-or-glitch-in-the-editor.md +6 -0
  21. package/corpus/templates/slides/changelog/2026-08-09-revise-highlighted-slide-text-with-ai.md +6 -0
  22. package/corpus/templates/slides/changelog/2026-08-09-the-deck-editor-no-longer-stays-dimmed-after-the-window-narro.md +6 -0
  23. package/corpus/templates/slides/server/handlers/import/html-converter.ts +115 -22
  24. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +11 -2
  25. package/corpus/templates/slides/server/lib/fetch-remote-image.ts +192 -0
  26. package/corpus/templates/slides/server/lib/image-proxy-url.ts +192 -0
  27. package/corpus/templates/slides/server/routes/api/image-proxy.get.ts +65 -0
  28. package/dist/client/progress/RunsTray.js +14 -1
  29. package/dist/collab/struct-routes.d.ts +1 -1
  30. package/dist/observability/routes.d.ts +1 -1
  31. package/dist/resources/handlers.d.ts +1 -1
  32. package/dist/secrets/routes.d.ts +3 -3
  33. package/package.json +1 -1
@@ -10,9 +10,16 @@
10
10
  * largest rendered element so a thumbnail's transform: scale(0.25)
11
11
  * doesn't shrink the captured pixels.
12
12
  */
13
+ import { appBasePath } from "@agent-native/core/client/api-path";
14
+
13
15
  import { type AspectRatio, getAspectRatioDims } from "./aspect-ratios";
14
16
  import { importExportModule } from "./dynamic-import";
15
17
 
18
+ /** Same-origin URL that re-serves a remote image, bypassing its missing CORS. */
19
+ export function imageProxyUrl(src: string): string {
20
+ return `${appBasePath()}/api/image-proxy?url=${encodeURIComponent(src)}`;
21
+ }
22
+
16
23
  /**
17
24
  * Cross-origin <img> elements without an explicit `crossOrigin="anonymous"`
18
25
  * attribute taint the canvas when rasterized via <foreignObject>, producing
@@ -21,8 +28,15 @@ import { importExportModule } from "./dynamic-import";
21
28
  * setting the attribute and re-assigning the same src. This is the root
22
29
  * cause of the "blank images in exported PDF" bug Rochkind reported.
23
30
  */
24
- export async function preloadImagesWithCors(root: HTMLElement): Promise<void> {
31
+ export async function preloadImagesWithCors(
32
+ root: HTMLElement,
33
+ ): Promise<() => void> {
25
34
  const imgs = Array.from(root.querySelectorAll<HTMLImageElement>("img"));
35
+ // The slide DOM is the live editor canvas. Anything rewritten here would
36
+ // otherwise be picked up by the next save and persisted into the deck, so
37
+ // every mutation is recorded and undone once the capture is done.
38
+ const restores: Array<() => void> = [];
39
+
26
40
  await Promise.all(
27
41
  imgs.map(async (img) => {
28
42
  const src = img.currentSrc || img.src;
@@ -35,30 +49,59 @@ export async function preloadImagesWithCors(root: HTMLElement): Promise<void> {
35
49
  isCrossOrigin = false;
36
50
  }
37
51
  if (!isCrossOrigin) return;
52
+
53
+ const originalCrossOrigin = img.getAttribute("crossorigin");
54
+ const originalSrc = img.getAttribute("src");
55
+ const restore = () => {
56
+ if (originalCrossOrigin === null) img.removeAttribute("crossorigin");
57
+ else img.setAttribute("crossorigin", originalCrossOrigin);
58
+ if (originalSrc === null) img.removeAttribute("src");
59
+ else img.setAttribute("src", originalSrc);
60
+ };
61
+
38
62
  if (img.crossOrigin === "anonymous") {
39
63
  // Already CORS-enabled; just make sure it's decoded.
40
64
  try {
41
65
  await img.decode();
66
+ return;
42
67
  } catch {
43
- /* ignore */
68
+ // coercion-ok: a failed direct load is the signal to try the proxy,
69
+ // and the proxy attempt below reports its own failure.
70
+ }
71
+ } else {
72
+ img.crossOrigin = "anonymous";
73
+ // Re-set src to retrigger the load with the new CORS attribute.
74
+ img.src = src;
75
+ restores.push(restore);
76
+ try {
77
+ await img.decode();
78
+ return;
79
+ } catch {
80
+ // coercion-ok: same as above — this is the CORS probe, not the
81
+ // final outcome.
44
82
  }
45
- return;
46
83
  }
84
+
85
+ // The host does not send Access-Control-Allow-Origin, and no client-side
86
+ // flag can override that. Re-serve the image from our own origin so the
87
+ // canvas stays clean instead of rasterizing a blank rect.
88
+ if (!restores.includes(restore)) restores.push(restore);
47
89
  img.crossOrigin = "anonymous";
48
- // Re-set src to retrigger the load with the new CORS attribute.
49
- img.src = src;
90
+ img.src = imageProxyUrl(src);
50
91
  try {
51
92
  await img.decode();
52
93
  } catch (err) {
53
- // Server didn't return Access-Control-Allow-Origin. The screenshot
54
- // will be blank for this image — log so the user can swap the host.
55
94
  console.warn(
56
- `[export-pdf] CORS-tainted image likely caused blank render: ${src}`,
95
+ `[export-pdf] image could not be loaded for export: ${src}`,
57
96
  err,
58
97
  );
59
98
  }
60
99
  }),
61
100
  );
101
+
102
+ return () => {
103
+ for (const restore of restores) restore();
104
+ };
62
105
  }
63
106
 
64
107
  export function findSlideExportSource(
@@ -132,23 +175,39 @@ export async function exportDeckAsPdf(
132
175
  // Force CORS-enabled re-fetch on every cross-origin <img> before
133
176
  // capture — otherwise the canvas tainting check inside modern-screenshot
134
177
  // produces a blank rect for the image.
135
- await preloadImagesWithCors(source);
136
-
137
- const dataUrl = await domToJpeg(source, {
138
- width: dims.width,
139
- height: dims.height,
140
- scale: 2, // 2x for crisp text
141
- backgroundColor: "#000000",
142
- quality: 0.92,
143
- // Pair with the in-DOM CORS preload above. modern-screenshot's
144
- // internal image fetcher needs no-cache so re-issued requests don't
145
- // get served the original tainted (no-CORS) response from the HTTP
146
- // cache, and an anonymous-CORS request mode so the response itself
147
- // is usable on a clean canvas.
148
- fetch: {
149
- requestInit: { cache: "no-cache", mode: "cors", credentials: "omit" },
150
- },
151
- });
178
+ const restoreImages = await preloadImagesWithCors(source);
179
+
180
+ let dataUrl: string;
181
+ try {
182
+ dataUrl = await domToJpeg(source, {
183
+ width: dims.width,
184
+ height: dims.height,
185
+ scale: 2, // 2x for crisp text
186
+ // guard:allow-raw-color — a PDF page has no theme to follow.
187
+ backgroundColor: "#000000",
188
+ quality: 0.92,
189
+ // Pair with the in-DOM CORS preload above. modern-screenshot's
190
+ // internal image fetcher needs no-cache so re-issued requests don't
191
+ // get served the original tainted (no-CORS) response from the HTTP
192
+ // cache, and an anonymous-CORS request mode so the response itself
193
+ // is usable on a clean canvas.
194
+ //
195
+ // `same-origin` rather than `omit`: images the preload rewrote to
196
+ // /api/image-proxy are same-origin and that route needs the session
197
+ // cookie, so omitting credentials would 401 exactly the images this
198
+ // is meant to rescue. Cross-origin requests still go out anonymously,
199
+ // which is what CORS mode requires.
200
+ fetch: {
201
+ requestInit: {
202
+ cache: "no-cache",
203
+ mode: "cors",
204
+ credentials: "same-origin",
205
+ },
206
+ },
207
+ });
208
+ } finally {
209
+ restoreImages();
210
+ }
152
211
 
153
212
  if (i > 0) pdf.addPage([dims.width, dims.height], orientation);
154
213
  pdf.addImage(dataUrl, "JPEG", 0, 0, dims.width, dims.height);
@@ -9,6 +9,16 @@ export type ImportedSourceDeck = {
9
9
  imagesSkipped: number;
10
10
  };
11
11
 
12
+ /**
13
+ * PDF/PPTX import renders every page (image extraction, per-page fidelity
14
+ * parsing) and can run well past the client's default 60s action timeout on
15
+ * large or image-heavy files. A timeout here only aborts the client's wait —
16
+ * the server keeps importing and the deck still ends up with slides — so the
17
+ * default made the editor silently fail on a deck that had, or was about to
18
+ * have, real content.
19
+ */
20
+ export const IMPORT_ACTION_TIMEOUT_MS = 5 * 60 * 1000;
21
+
12
22
  function sourceFormat(file: UploadedFile): "pdf" | "pptx" | null {
13
23
  const name = file.originalName.toLowerCase();
14
24
  if (name.endsWith(".pptx")) return "pptx";
@@ -57,6 +67,7 @@ export async function importUploadedDeckIntoDeck(
57
67
  deckId,
58
68
  importIntoDeck: true,
59
69
  },
70
+ { timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
60
71
  ),
61
72
  );
62
73
  if (result.imported !== true || result.deckId !== deckId) {
@@ -345,6 +345,18 @@ export default function DeckEditor() {
345
345
  if (!generating) setAddSlideGenerating(false);
346
346
  }, [generating]);
347
347
 
348
+ // Below `md` the rail is a drawer behind a full-viewport dimming scrim; at
349
+ // `md` and up it's docked with no scrim. `sidebarOpen` is seeded from the
350
+ // width at mount only, so a window that starts wide and is then narrowed
351
+ // (or an editor opened in a resizable preview pane) keeps `sidebarOpen`
352
+ // true while the scrim stops being `md:hidden` — dimming the whole editor
353
+ // with no way to dismiss it.
354
+ useEffect(() => {
355
+ const onResize = () => setSidebarOpen(window.innerWidth >= 768);
356
+ window.addEventListener("resize", onResize);
357
+ return () => window.removeEventListener("resize", onResize);
358
+ }, []);
359
+
348
360
  const previousSlideIdsRef = useRef<string[]>([]);
349
361
  useEffect(() => {
350
362
  const currentSlideIds = deck?.slides.map((slide) => slide.id) ?? [];
@@ -59,6 +59,7 @@ import { createDeckAgentMessage } from "@/lib/agent-visible-message";
59
59
  import { savePromptToComposerDraft } from "@/lib/composer-draft";
60
60
  import { sortDecksByRecency } from "@/lib/deck-sorting";
61
61
  import {
62
+ IMPORT_ACTION_TIMEOUT_MS,
62
63
  importUploadedDeckIntoDeck,
63
64
  type ImportedSourceDeck,
64
65
  } from "@/lib/import-uploaded-deck";
@@ -813,9 +814,11 @@ export default function Index() {
813
814
  let importedReference: ImportedReference | null = null;
814
815
  let generationFiles = uploaded;
815
816
  if (pptxReference) {
816
- const imported = (await callAction("import-pptx", {
817
- filePath: pptxReference.path,
818
- })) as {
817
+ const imported = (await callAction(
818
+ "import-pptx",
819
+ { filePath: pptxReference.path },
820
+ { timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
821
+ )) as {
819
822
  id?: unknown;
820
823
  imported?: unknown;
821
824
  slideCount?: unknown;
@@ -854,12 +857,16 @@ export default function Index() {
854
857
  );
855
858
  }
856
859
  try {
857
- const imported = (await callAction("import-file", {
858
- filePath: pdfReference.path,
859
- format: "pdf",
860
- deckId: referenceDeck.id,
861
- importIntoDeck: true,
862
- })) as {
860
+ const imported = (await callAction(
861
+ "import-file",
862
+ {
863
+ filePath: pdfReference.path,
864
+ format: "pdf",
865
+ deckId: referenceDeck.id,
866
+ importIntoDeck: true,
867
+ },
868
+ { timeoutMs: IMPORT_ACTION_TIMEOUT_MS },
869
+ )) as {
863
870
  imported?: unknown;
864
871
  deckId?: unknown;
865
872
  pageCount?: unknown;
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-08
4
+ ---
5
+
6
+ Google Slides export now tells you why a deck fell back to a .pptx download instead of reporting success.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-09
4
+ ---
5
+
6
+ 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.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-09
4
+ ---
5
+
6
+ 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.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-09
4
+ ---
5
+
6
+ Fixed PDF and custom-size PowerPoint imports rendering with distorted, mispositioned images and text on non-16:9 pages.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-09
4
+ ---
5
+
6
+ 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.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-09
4
+ ---
5
+
6
+ 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".
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-09
4
+ ---
5
+
6
+ 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.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-09
4
+ ---
5
+
6
+ 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.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-08-09
4
+ ---
5
+
6
+ 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.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-09
4
+ ---
5
+
6
+ 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.
@@ -1,3 +1,5 @@
1
+ import { ASPECT_RATIOS } from "@shared/aspect-ratios";
2
+
1
3
  import type {
2
4
  ParsedElement,
3
5
  ParsedParagraph,
@@ -165,10 +167,37 @@ export function convertToSlideHtml(
165
167
 
166
168
  const DEFAULT_SLIDE_WIDTH_EMU = 9144000;
167
169
  const DEFAULT_SLIDE_HEIGHT_EMU = 5143500;
168
- const CSS_PX_PER_POINT = 96 / 72;
169
170
  const DEFAULT_PPTX_BACKGROUND = "#000000"; // guard:allow-raw-color - preserve PPTX black when no background is declared
170
171
  const DEFAULT_PPTX_FOREGROUND = "#ffffff"; // guard:allow-raw-color - preserve PPTX white when no run color is declared
171
172
 
173
+ /**
174
+ * The absolute px box `toSlidePxX`/`toSlidePxY` scale positions and sizes
175
+ * against. It must match the aspect-ratio preset the deck actually renders
176
+ * into (`ASPECT_RATIOS`, chosen by the import actions' own
177
+ * `nearestAspectRatio`) rather than a fixed 16:9 box: a PDF page or a custom
178
+ * PPTX slide size is routinely portrait or square, and scaling its elements
179
+ * against a 960x540 reference while the deck itself renders in an 864x1080
180
+ * (or other) box stretches every element by the ratio between the two
181
+ * boxes, most visibly squashing everything into the top fraction of a
182
+ * taller-than-540 canvas.
183
+ */
184
+ function referenceBoxForSlide(
185
+ widthEmu: number,
186
+ heightEmu: number,
187
+ ): { width: number; height: number } {
188
+ const target = widthEmu / heightEmu;
189
+ let best: { width: number; height: number } = ASPECT_RATIOS["16:9"];
190
+ let bestDiff = Infinity;
191
+ for (const preset of Object.values(ASPECT_RATIOS)) {
192
+ const diff = Math.abs(preset.width / preset.height - target);
193
+ if (diff < bestDiff) {
194
+ bestDiff = diff;
195
+ best = preset;
196
+ }
197
+ }
198
+ return { width: best.width, height: best.height };
199
+ }
200
+
172
201
  function buildFidelitySlide(
173
202
  slide: ParsedSlide,
174
203
  imageUrls: string | Record<string, string> | undefined,
@@ -176,9 +205,10 @@ function buildFidelitySlide(
176
205
  ): string {
177
206
  const widthEmu = slide.widthEmu || DEFAULT_SLIDE_WIDTH_EMU;
178
207
  const heightEmu = slide.heightEmu || DEFAULT_SLIDE_HEIGHT_EMU;
208
+ const refBox = referenceBoxForSlide(widthEmu, heightEmu);
179
209
  const background = slide.backgroundColor ?? DEFAULT_PPTX_BACKGROUND;
180
210
  const gridStyle = slide.backgroundGrid
181
- ? `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;`
211
+ ? `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;`
182
212
  : "";
183
213
  const elements = slide.elements ?? [];
184
214
  const html = elements
@@ -188,6 +218,7 @@ function buildFidelitySlide(
188
218
  index,
189
219
  widthEmu,
190
220
  heightEmu,
221
+ refBox,
191
222
  imageUrls,
192
223
  themeFont,
193
224
  ),
@@ -203,10 +234,11 @@ function buildFidelityElement(
203
234
  index: number,
204
235
  widthEmu: number,
205
236
  heightEmu: number,
237
+ refBox: { width: number; height: number },
206
238
  imageUrls: string | Record<string, string> | undefined,
207
239
  themeFont: string | undefined,
208
240
  ): string {
209
- 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;`;
241
+ 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;`;
210
242
  const rotation = element.rotation
211
243
  ? ` transform: rotate(${element.rotation}deg); transform-origin: center center;`
212
244
  : "";
@@ -218,12 +250,18 @@ function buildFidelityElement(
218
250
  return `<div class="fmd-pptx-image" data-pptx-element-kind="image" data-pptx-image-name="${esc(element.image?.name ?? "image")}"${objectId} style="${position}${rotation} overflow: hidden;">${url ? `<img src="${esc(url)}" alt="" style="${imageStyle}" />` : `<div class="fmd-img-placeholder" style="width:100%;height:100%;">Imported image: ${esc(element.image?.name ?? "image")}</div>`}</div>`;
219
251
  }
220
252
 
221
- const decoration = shapeDecoration(element, widthEmu);
253
+ const decoration = shapeDecoration(element, widthEmu, refBox.width);
222
254
  if (element.kind === "shape") {
223
255
  return `<div class="fmd-pptx-shape" data-pptx-element-kind="shape"${objectId} style="${position}${rotation}${decoration}"></div>`;
224
256
  }
225
257
 
226
- const textStyle = textBoxStyle(element, widthEmu, heightEmu, themeFont);
258
+ const textStyle = textBoxStyle(
259
+ element,
260
+ widthEmu,
261
+ heightEmu,
262
+ refBox,
263
+ themeFont,
264
+ );
227
265
  const defaultFontWeight = element.placeholderType === "title" ? 700 : 400;
228
266
  const paragraphs = (element.paragraphs ?? [])
229
267
  .map((paragraph, paragraphIndex) =>
@@ -231,6 +269,7 @@ function buildFidelityElement(
231
269
  paragraph,
232
270
  paragraphIndex,
233
271
  widthEmu,
272
+ refBox.width,
234
273
  themeFont,
235
274
  defaultFontWeight,
236
275
  ),
@@ -239,12 +278,41 @@ function buildFidelityElement(
239
278
  return `<div class="fmd-pptx-text" data-pptx-element-kind="text"${objectId} style="${position}${rotation}${decoration}${textStyle}">${paragraphs}</div>`;
240
279
  }
241
280
 
242
- function toSlidePxX(valueEmu: number, slideWidthEmu: number): number {
243
- return Math.round((valueEmu / slideWidthEmu) * 960 * 1000) / 1000;
281
+ function toSlidePxX(
282
+ valueEmu: number,
283
+ slideWidthEmu: number,
284
+ refWidthPx: number,
285
+ ): number {
286
+ return Math.round((valueEmu / slideWidthEmu) * refWidthPx * 1000) / 1000;
244
287
  }
245
288
 
246
- function toSlidePxY(valueEmu: number, slideHeightEmu: number): number {
247
- return Math.round((valueEmu / slideHeightEmu) * 540 * 1000) / 1000;
289
+ function toSlidePxY(
290
+ valueEmu: number,
291
+ slideHeightEmu: number,
292
+ refHeightPx: number,
293
+ ): number {
294
+ return Math.round((valueEmu / slideHeightEmu) * refHeightPx * 1000) / 1000;
295
+ }
296
+
297
+ const EMU_PER_POINT = 12700;
298
+
299
+ /**
300
+ * A run's font size (and paragraph spacing) is stored in points, a physical
301
+ * unit independent of the source slide's own canvas size — unlike
302
+ * position/size EMUs, a fixed `pt * 96/72` conversion doesn't know how far
303
+ * `toSlidePxX`/`toSlidePxY` scaled that canvas down (or up) to fit the
304
+ * deck's aspect-ratio box. Converting the point value to EMU first and
305
+ * running it through the same `toSlidePxX` scale keeps text sized
306
+ * proportionally to its box on every source slide size, not just the one
307
+ * physical size (10in wide) that happens to make the fixed conversion agree
308
+ * with the 16:9 preset's box.
309
+ */
310
+ function ptToSlidePx(
311
+ valuePt: number,
312
+ widthEmu: number,
313
+ refWidthPx: number,
314
+ ): number {
315
+ return toSlidePxX(valuePt * EMU_PER_POINT, widthEmu, refWidthPx);
248
316
  }
249
317
 
250
318
  function imageUrlForElement(
@@ -263,10 +331,14 @@ function imageRenderStyle(element: ParsedElement): string {
263
331
  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;`;
264
332
  }
265
333
 
266
- function shapeDecoration(element: ParsedElement, widthEmu: number): string {
334
+ function shapeDecoration(
335
+ element: ParsedElement,
336
+ widthEmu: number,
337
+ refWidthPx: number,
338
+ ): string {
267
339
  const fill = element.fill ? `background: ${esc(element.fill)};` : "";
268
340
  const line = element.lineColor
269
- ? `border: ${Math.max(1, toSlidePxX(element.lineWidth ?? 12700, widthEmu))}px solid ${esc(element.lineColor)};`
341
+ ? `border: ${Math.max(1, toSlidePxX(element.lineWidth ?? 12700, widthEmu, refWidthPx))}px solid ${esc(element.lineColor)};`
270
342
  : "";
271
343
  const radius = element.shapeType === "roundRect" ? "border-radius: 6px;" : "";
272
344
  return `${fill}${line}${radius}`;
@@ -276,13 +348,16 @@ function textBoxStyle(
276
348
  element: ParsedElement,
277
349
  widthEmu: number,
278
350
  heightEmu: number,
351
+ refBox: { width: number; height: number },
279
352
  themeFont: string | undefined,
280
353
  ): string {
281
354
  const padding = element.padding;
282
- const left = padding ? toSlidePxX(padding.left, widthEmu) : 0;
283
- const right = padding ? toSlidePxX(padding.right, widthEmu) : 0;
284
- const top = padding ? toSlidePxY(padding.top, heightEmu) : 0;
285
- const bottom = padding ? toSlidePxY(padding.bottom, heightEmu) : 0;
355
+ const left = padding ? toSlidePxX(padding.left, widthEmu, refBox.width) : 0;
356
+ const right = padding ? toSlidePxX(padding.right, widthEmu, refBox.width) : 0;
357
+ const top = padding ? toSlidePxY(padding.top, heightEmu, refBox.height) : 0;
358
+ const bottom = padding
359
+ ? toSlidePxY(padding.bottom, heightEmu, refBox.height)
360
+ : 0;
286
361
  const align = element.paragraphs?.[0]?.alignment ?? "left";
287
362
  const vertical =
288
363
  element.verticalAlign === "middle"
@@ -297,37 +372,55 @@ function buildFidelityParagraph(
297
372
  paragraph: ParsedParagraph,
298
373
  paragraphIndex: number,
299
374
  widthEmu: number,
375
+ refWidthPx: number,
300
376
  themeFont: string | undefined,
301
377
  defaultFontWeight: number,
302
378
  ): string {
303
379
  const firstRun = paragraph.runs[0];
304
- const fontSize = (firstRun?.fontSize ?? 18) * CSS_PX_PER_POINT;
380
+ const fontSize = ptToSlidePx(firstRun?.fontSize ?? 18, widthEmu, refWidthPx);
305
381
  const lineHeight = paragraph.lineSpacing ?? 1.2;
382
+ const bulletFontSize = ptToSlidePx(
383
+ paragraph.bulletSize ?? firstRun?.fontSize ?? 18,
384
+ widthEmu,
385
+ refWidthPx,
386
+ );
306
387
  const bullet = paragraph.bulletChar
307
- ? `<span aria-hidden="true" style="display:inline-block;width:${fontSize * 0.75}px;min-width:${fontSize * 0.75}px;margin-right:${fontSize * 0.65}px;color:${esc(paragraph.bulletColor ?? firstRun?.color ?? DEFAULT_PPTX_FOREGROUND)};font-family:${cssFontFamily(paragraph.bulletFontFamily ?? themeFont)};font-size:${(paragraph.bulletSize ?? firstRun?.fontSize ?? 18) * CSS_PX_PER_POINT}px;">${esc(paragraph.bulletChar)}</span>`
388
+ ? `<span aria-hidden="true" style="display:inline-block;width:${fontSize * 0.75}px;min-width:${fontSize * 0.75}px;margin-right:${fontSize * 0.65}px;color:${esc(paragraph.bulletColor ?? firstRun?.color ?? DEFAULT_PPTX_FOREGROUND)};font-family:${cssFontFamily(paragraph.bulletFontFamily ?? themeFont)};font-size:${bulletFontSize}px;">${esc(paragraph.bulletChar)}</span>`
308
389
  : "";
309
390
  const marginLeft = paragraph.marginLeftEmu
310
- ? toSlidePxX(paragraph.marginLeftEmu, widthEmu)
391
+ ? toSlidePxX(paragraph.marginLeftEmu, widthEmu, refWidthPx)
311
392
  : 0;
312
393
  const indent = paragraph.indentEmu
313
- ? toSlidePxX(paragraph.indentEmu, widthEmu)
394
+ ? toSlidePxX(paragraph.indentEmu, widthEmu, refWidthPx)
314
395
  : 0;
315
396
  const spacingBefore = paragraph.spaceBeforePt ?? 0;
316
397
  const spacingAfter = paragraph.spaceAfterPt ?? 0;
317
398
  const bulletMargin = paragraph.bulletChar ? `margin-left:${indent}px;` : "";
399
+ const marginBefore = ptToSlidePx(spacingBefore, widthEmu, refWidthPx);
400
+ const marginAfter = ptToSlidePx(spacingAfter, widthEmu, refWidthPx);
318
401
  const text = paragraph.runs
319
- .map((run) => formatFidelityRun(run, themeFont, defaultFontWeight))
402
+ .map((run) =>
403
+ formatFidelityRun(
404
+ run,
405
+ widthEmu,
406
+ refWidthPx,
407
+ themeFont,
408
+ defaultFontWeight,
409
+ ),
410
+ )
320
411
  .join("");
321
- return `<p data-pptx-paragraph="${paragraphIndex}" style="display:block;flex:0 0 auto;text-align:${paragraph.alignment ?? "left"};white-space:pre-wrap;margin:${spacingBefore * CSS_PX_PER_POINT}px 0 ${spacingAfter * CSS_PX_PER_POINT}px;line-height:${lineHeight};font-size:${fontSize}px;min-height:${fontSize * lineHeight}px;padding-left:${marginLeft}px;text-indent:${paragraph.bulletChar ? 0 : indent}px;">${bullet.replace("display:inline-block;", `display:inline-block;${bulletMargin}`)}${text}</p>`;
412
+ return `<p data-pptx-paragraph="${paragraphIndex}" style="display:block;flex:0 0 auto;text-align:${paragraph.alignment ?? "left"};white-space:pre-wrap;margin:${marginBefore}px 0 ${marginAfter}px;line-height:${lineHeight};font-size:${fontSize}px;min-height:${fontSize * lineHeight}px;padding-left:${marginLeft}px;text-indent:${paragraph.bulletChar ? 0 : indent}px;">${bullet.replace("display:inline-block;", `display:inline-block;${bulletMargin}`)}${text}</p>`;
322
413
  }
323
414
 
324
415
  function formatFidelityRun(
325
416
  run: ParsedTextRun,
417
+ widthEmu: number,
418
+ refWidthPx: number,
326
419
  themeFont: string | undefined,
327
420
  defaultFontWeight = 400,
328
421
  ): string {
329
422
  const styles = [
330
- `font-size:${(run.fontSize ?? 18) * CSS_PX_PER_POINT}px`,
423
+ `font-size:${ptToSlidePx(run.fontSize ?? 18, widthEmu, refWidthPx)}px`,
331
424
  `font-family:${cssFontFamily(run.fontFamily ?? themeFont)}`,
332
425
  `color:${esc(run.color ?? DEFAULT_PPTX_FOREGROUND)}`,
333
426
  `font-weight:${run.bold ? 700 : fontWeightForFamily(run.fontFamily, defaultFontWeight)}`,
@@ -380,14 +380,23 @@ export function mergeLineRuns(items: TextRunBox[]): TextRunBox[] {
380
380
  prev.italic === item.italic &&
381
381
  prev.underline === item.underline &&
382
382
  prev.href === item.href;
383
+ const needsSpace =
384
+ prev !== undefined && item.left - prev.right > item.fontSize * 0.25;
383
385
  if (prev && sameStyle) {
384
- const needsSpace = item.left - prev.right > item.fontSize * 0.25;
385
386
  prev.text += (needsSpace ? " " : "") + item.text;
386
387
  prev.right = Math.max(prev.right, item.right);
387
388
  prev.top = Math.min(prev.top, item.top);
388
389
  prev.bottom = Math.max(prev.bottom, item.bottom);
389
390
  } else {
390
- runs.push({ ...item });
391
+ // A word-sized gap has to survive a style change too. Only the
392
+ // same-style branch used to re-add it, so a heading whose colour
393
+ // changed mid-line ("7 Air " + "purifying") lost the space at the
394
+ // boundary and rendered as one jammed-together word.
395
+ const separator =
396
+ needsSpace && !/\s$/.test(prev?.text ?? "") && !/^\s/.test(item.text)
397
+ ? " "
398
+ : "";
399
+ runs.push({ ...item, text: separator + item.text });
391
400
  }
392
401
  }
393
402
  return runs;