@agent-native/core 0.124.4 → 0.124.6

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.
@@ -1,5 +1,17 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.124.6
4
+
5
+ ### Patch Changes
6
+
7
+ - ac8794b: Clarify credential resolver organization scope in generated workspace guidance.
8
+
9
+ ## 0.124.5
10
+
11
+ ### Patch Changes
12
+
13
+ - fc5f2db: Extract picture shape size and slide dimensions when parsing PPTX files, so importers can tell a full-bleed cover photo from a small inset image and preserve each image's real aspect ratio.
14
+
3
15
  ## 0.124.4
4
16
 
5
17
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.124.4",
3
+ "version": "0.124.6",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -10,6 +10,10 @@ export interface ParsedPptxImage {
10
10
  data: Uint8Array;
11
11
  mimeType: string;
12
12
  name: string;
13
+ /** Width / height of the picture shape on the slide, from its own placed size (not the source file's pixel dimensions). */
14
+ aspectRatio?: number;
15
+ /** True when the picture shape covers at least ~85% of the slide's width and height — a full-bleed background photo rather than an inset card image. */
16
+ fullBleed?: boolean;
13
17
  }
14
18
 
15
19
  export interface ParsedPptxSlide {
@@ -46,11 +50,13 @@ export async function parsePptxPresentation(
46
50
  if (!presentationXml)
47
51
  throw new Error("Invalid PPTX: missing ppt/presentation.xml");
48
52
  const presentation = parseXml(presentationXml);
53
+ const presentationRoot = record(record(presentation)?.["p:presentation"]);
49
54
  const slideIds = asArray(
50
- record(record(record(presentation)?.["p:presentation"])?.["p:sldIdLst"])?.[
51
- "p:sldId"
52
- ],
55
+ record(presentationRoot?.["p:sldIdLst"])?.["p:sldId"],
53
56
  ).map((entry) => stringValue(record(entry)?.["@_r:id"]) ?? "");
57
+ const sldSz = record(presentationRoot?.["p:sldSz"]);
58
+ const slideWidthEmu = Number(sldSz?.["@_cx"]) || undefined;
59
+ const slideHeightEmu = Number(sldSz?.["@_cy"]) || undefined;
54
60
  const relationshipsXml = await zip
55
61
  .file("ppt/_rels/presentation.xml.rels")
56
62
  ?.async("string");
@@ -95,9 +101,27 @@ export async function parsePptxPresentation(
95
101
  .file(relationshipPath)
96
102
  ?.async("string");
97
103
  if (slideRelationshipsXml) {
98
- for (const relationship of parseRelationships(
104
+ const slideRelationships = parseRelationships(
99
105
  parseXml(slideRelationshipsXml),
100
- ).values()) {
106
+ );
107
+ // Walk the slide's own picture shapes (in document order) rather than
108
+ // every image relationship, so each image carries the placed size the
109
+ // author gave it on the slide — that size is what tells a full-bleed
110
+ // cover photo apart from a small inset card photo, which a flat
111
+ // relationship scan has no way to know.
112
+ const pictureShapes: PictureShape[] = [];
113
+ collectPictureShapes(slide, pictureShapes);
114
+ reorderByDocumentPosition(pictureShapes, xml);
115
+ addBackgroundFillShape(
116
+ slide,
117
+ pictureShapes,
118
+ slideWidthEmu,
119
+ slideHeightEmu,
120
+ );
121
+ for (const shape of pictureShapes) {
122
+ if (!shape.embedId) continue;
123
+ const relationship = slideRelationships.get(shape.embedId);
124
+ if (!relationship) continue;
101
125
  if (
102
126
  !relationship.type.includes("/image") &&
103
127
  !/\.(png|jpe?g|gif|svg|webp|bmp|tiff?|emf|wmf)$/i.test(
@@ -114,10 +138,24 @@ export async function parsePptxPresentation(
114
138
  const image = zip.file(imagePath);
115
139
  if (!image) continue;
116
140
  const name = imagePath.split("/").at(-1) ?? "image";
141
+ const aspectRatio =
142
+ shape.widthEmu && shape.heightEmu
143
+ ? shape.widthEmu / shape.heightEmu
144
+ : undefined;
145
+ const fullBleed = Boolean(
146
+ shape.widthEmu &&
147
+ shape.heightEmu &&
148
+ slideWidthEmu &&
149
+ slideHeightEmu &&
150
+ shape.widthEmu / slideWidthEmu >= 0.85 &&
151
+ shape.heightEmu / slideHeightEmu >= 0.85,
152
+ );
117
153
  images.push({
118
154
  data: new Uint8Array(await image.async("nodebuffer")),
119
155
  mimeType: imageMimeType(name),
120
156
  name,
157
+ aspectRatio,
158
+ fullBleed,
121
159
  });
122
160
  }
123
161
  }
@@ -204,6 +242,222 @@ function collectTextRuns(
204
242
  }
205
243
  }
206
244
 
245
+ interface PictureShape {
246
+ embedId?: string;
247
+ widthEmu?: number;
248
+ heightEmu?: number;
249
+ }
250
+
251
+ /**
252
+ * Recursively find every embedded picture in a slide, in document order:
253
+ * plain `p:pic` shapes, and `p:sp` autoshapes whose fill is a picture
254
+ * (common for "photo cutout" shapes and full-bleed decorative rectangles).
255
+ * A group shape (`p:grpSp`) defines its own local coordinate space for its
256
+ * children (`chOff`/`chExt`) that can be scaled arbitrarily relative to the
257
+ * group's own placed size on the slide, so each level of nesting multiplies
258
+ * a running scale factor into the child sizes below it — without that, a
259
+ * picture inside a resized group would report its unscaled design-time
260
+ * size instead of how large it actually appears on the slide.
261
+ */
262
+ function collectPictureShapes(
263
+ value: unknown,
264
+ out: PictureShape[],
265
+ scaleX = 1,
266
+ scaleY = 1,
267
+ ): void {
268
+ const node = record(value);
269
+ if (!node) return;
270
+ for (const [key, child] of Object.entries(node)) {
271
+ if (key.startsWith("@_")) continue;
272
+ if (key === "p:pic") {
273
+ for (const picNode of asArray(child)) {
274
+ const pic = record(picNode);
275
+ const blip = record(record(pic?.["p:blipFill"])?.["a:blip"]);
276
+ out.push(
277
+ scaledShape(
278
+ stringValue(blip?.["@_r:embed"]),
279
+ extFromSpPr(pic),
280
+ scaleX,
281
+ scaleY,
282
+ ),
283
+ );
284
+ }
285
+ continue;
286
+ }
287
+ if (key === "p:sp") {
288
+ for (const spNode of asArray(child)) {
289
+ const sp = record(spNode);
290
+ const blip = record(
291
+ record(record(sp?.["p:spPr"])?.["a:blipFill"])?.["a:blip"],
292
+ );
293
+ const embedId = stringValue(blip?.["@_r:embed"]);
294
+ if (embedId) {
295
+ out.push(scaledShape(embedId, extFromSpPr(sp), scaleX, scaleY));
296
+ }
297
+ }
298
+ continue;
299
+ }
300
+ if (key === "p:grpSp") {
301
+ for (const groupNode of asArray(child)) {
302
+ const scale = groupChildScale(record(groupNode), scaleX, scaleY);
303
+ collectPictureShapes(groupNode, out, scale.x, scale.y);
304
+ }
305
+ continue;
306
+ }
307
+ for (const item of asArray(child)) {
308
+ collectPictureShapes(item, out, scaleX, scaleY);
309
+ }
310
+ }
311
+ }
312
+
313
+ /** Whether an `a:xfrm` `rot` value (60,000ths of a degree) is an odd multiple of 90° — a 90/270 turn that swaps effective width and height. */
314
+ function isOddQuarterTurn(rot: number): boolean {
315
+ if (!Number.isFinite(rot)) return false;
316
+ const quarterTurns = Math.round(rot / 60000 / 90);
317
+ return ((quarterTurns % 2) + 2) % 2 === 1;
318
+ }
319
+
320
+ /**
321
+ * A group's `chExt` is the coordinate space its children are authored in;
322
+ * `ext` is how large the group actually renders — the ratio between them is
323
+ * the extra scale nested children need on top of their own declared size.
324
+ * When the group itself is rotated a 90/270-degree turn, that ratio applies
325
+ * to the perpendicular axis on the slide, so the X/Y scale factors need
326
+ * swapping the same way a rotated picture's own width/height do.
327
+ */
328
+ function groupChildScale(
329
+ groupNode: Record<string, unknown> | null,
330
+ parentScaleX: number,
331
+ parentScaleY: number,
332
+ ): { x: number; y: number } {
333
+ const xfrm = record(record(groupNode?.["p:grpSpPr"])?.["a:xfrm"]);
334
+ const ext = record(xfrm?.["a:ext"]);
335
+ const chExt = record(xfrm?.["a:chExt"]);
336
+ const extCx = Number(ext?.["@_cx"]);
337
+ const extCy = Number(ext?.["@_cy"]);
338
+ const chExtCx = Number(chExt?.["@_cx"]);
339
+ const chExtCy = Number(chExt?.["@_cy"]);
340
+ let groupScaleX =
341
+ Number.isFinite(extCx) && Number.isFinite(chExtCx) && chExtCx > 0
342
+ ? extCx / chExtCx
343
+ : 1;
344
+ let groupScaleY =
345
+ Number.isFinite(extCy) && Number.isFinite(chExtCy) && chExtCy > 0
346
+ ? extCy / chExtCy
347
+ : 1;
348
+ if (isOddQuarterTurn(Number(xfrm?.["@_rot"]))) {
349
+ [groupScaleX, groupScaleY] = [groupScaleY, groupScaleX];
350
+ }
351
+ return { x: parentScaleX * groupScaleX, y: parentScaleY * groupScaleY };
352
+ }
353
+
354
+ /**
355
+ * `a:xfrm/a:ext` always stores the shape's *unrotated* design-time width and
356
+ * height — `a:xfrm/@rot` (in 60,000ths of a degree) rotates it around its
357
+ * own center afterward. For a 90/270-degree turn, the shape's effective
358
+ * on-slide footprint has its width and height swapped relative to that
359
+ * declared size, so a rotated full-bleed photo can otherwise get treated as
360
+ * a small inset, or a portrait image get an inverted aspect ratio.
361
+ */
362
+ function extFromSpPr(shapeNode: Record<string, unknown> | null): {
363
+ cx?: number;
364
+ cy?: number;
365
+ } {
366
+ const xfrm = record(record(shapeNode?.["p:spPr"])?.["a:xfrm"]);
367
+ const ext = record(xfrm?.["a:ext"]);
368
+ const cx = Number(ext?.["@_cx"]);
369
+ const cy = Number(ext?.["@_cy"]);
370
+ if (!Number.isFinite(cx) || cx <= 0 || !Number.isFinite(cy) || cy <= 0) {
371
+ return { cx: undefined, cy: undefined };
372
+ }
373
+ return isOddQuarterTurn(Number(xfrm?.["@_rot"]))
374
+ ? { cx: cy, cy: cx }
375
+ : { cx, cy };
376
+ }
377
+
378
+ function scaledShape(
379
+ embedId: string | undefined,
380
+ size: { cx?: number; cy?: number },
381
+ scaleX: number,
382
+ scaleY: number,
383
+ ): PictureShape {
384
+ return {
385
+ embedId,
386
+ widthEmu: size.cx !== undefined ? size.cx * scaleX : undefined,
387
+ heightEmu: size.cy !== undefined ? size.cy * scaleY : undefined,
388
+ };
389
+ }
390
+
391
+ /**
392
+ * fast-xml-parser groups sibling elements by tag name, so a slide whose
393
+ * picture-bearing shapes alternate types on the page (e.g. `p:sp`, `p:pic`,
394
+ * `p:sp`) comes out of `collectPictureShapes` re-sorted by tag rather than
395
+ * by the order they actually appear — every `p:sp` gets visited before any
396
+ * `p:pic`, even if a `p:pic` came first in the file. Re-derive the true
397
+ * order from the raw XML text instead: `<a:blip r:embed="...">` occurrences
398
+ * appear in exactly document order regardless of nesting, so they can be
399
+ * used to restore the author's original front-to-back ordering (which
400
+ * matters because downstream rendering treats the first image as primary).
401
+ */
402
+ function reorderByDocumentPosition(shapes: PictureShape[], xml: string): void {
403
+ const order: string[] = [];
404
+ const blipPattern = /<a:blip\b[^>]*\br:embed=(?:"([^"]+)"|'([^']+)')/g;
405
+ let match: RegExpExecArray | null;
406
+ while ((match = blipPattern.exec(xml))) order.push(match[1] ?? match[2]);
407
+
408
+ // The same relationship id can legitimately be reused across multiple
409
+ // placements (e.g. a repeated icon), so looking up a single fixed index
410
+ // per id would assign every reused shape the position of its first XML
411
+ // occurrence. Instead give each id a queue of its occurrence positions
412
+ // and consume one per shape, in the order shapes were collected.
413
+ const occurrences = new Map<string, number[]>();
414
+ order.forEach((embedId, index) => {
415
+ const queue = occurrences.get(embedId);
416
+ if (queue) queue.push(index);
417
+ else occurrences.set(embedId, [index]);
418
+ });
419
+ const positions = shapes.map((shape) => {
420
+ const queue = shape.embedId ? occurrences.get(shape.embedId) : undefined;
421
+ return queue && queue.length > 0 ? queue.shift()! : -1;
422
+ });
423
+ const sortedIndices = shapes.map((_, i) => i);
424
+ sortedIndices.sort((a, b) => positions[a] - positions[b]);
425
+ const sorted = sortedIndices.map((i) => shapes[i]);
426
+ shapes.splice(0, shapes.length, ...sorted);
427
+ }
428
+
429
+ /** Read the embed relationship id of a slide's background picture fill (`p:cSld/p:bg/p:bgPr/a:blipFill/a:blip`), if any. */
430
+ function extractBackgroundFillEmbedId(slide: unknown): string | undefined {
431
+ const root = record(slide);
432
+ const cSld = record(record(root?.["p:sld"])?.["p:cSld"] ?? root?.["p:cSld"]);
433
+ const bgPr = record(record(cSld?.["p:bg"])?.["p:bgPr"]);
434
+ const blip = record(record(bgPr?.["a:blipFill"])?.["a:blip"]);
435
+ return stringValue(blip?.["@_r:embed"]);
436
+ }
437
+
438
+ /**
439
+ * Add the slide's background picture fill, if any, as a synthetic
440
+ * full-slide-sized shape. It's unshifted to the front rather than appended:
441
+ * downstream rendering only ever uses the first image in the list, and a
442
+ * background fill is the slide's base layer — the primary visual — so a
443
+ * smaller foreground picture (a logo, an icon) must not bump it out of that
444
+ * slot.
445
+ */
446
+ function addBackgroundFillShape(
447
+ slide: unknown,
448
+ pictureShapes: PictureShape[],
449
+ slideWidthEmu: number | undefined,
450
+ slideHeightEmu: number | undefined,
451
+ ): void {
452
+ const embedId = extractBackgroundFillEmbedId(slide);
453
+ if (!embedId) return;
454
+ pictureShapes.unshift({
455
+ embedId,
456
+ widthEmu: slideWidthEmu,
457
+ heightEmu: slideHeightEmu,
458
+ });
459
+ }
460
+
207
461
  function runProperties(
208
462
  value: Record<string, unknown> | null,
209
463
  inherited: Omit<ParsedPptxTextRun, "content">,
@@ -177,9 +177,34 @@ workspace repo:
177
177
  npx agent-native doctor --only no-env-credentials
178
178
  ```
179
179
 
180
- Findings in files that already call `resolveCredential` are usually the
181
- sanctioned `resolveCredential(key, ctx) ?? process.env.KEY` fallback. The bugs
182
- are the files with **no** resolver at all.
180
+ ## `resolveCredential` sees exactly one organization
181
+
182
+ `resolveCredential(key, { userEmail, orgId })` checks the user scope, then the
183
+ one `orgId` you pass, then the solo workspace. That is the whole search. It is
184
+ correct for a signed-in request, and wrong for two common cases:
185
+
186
+ - **The caller has no organization.** A cron, a scheduled job, or any CLI run
187
+ without a real member identity resolves no `orgId`, so only the user and solo
188
+ scopes are ever consulted — and a shared key is in neither.
189
+ - **The key was synced under a different organization.** `app_secrets` has no
190
+ scope visible to every org (`readAppSecret` is strict equality on
191
+ `(scope, scope_id, key)`), so a key the vault UI advertises as available to
192
+ every app is unreadable from any org except the one that ran the sync.
193
+
194
+ Both produce the same misleading "not set" that the split brain above produces,
195
+ which is why swapping `process.env` for `resolveCredential` can look like a fix
196
+ and change nothing. A workspace app should read shared keys through a resolver
197
+ that also sweeps the caller's other memberships and a designated vault org —
198
+ see `resolveConnectorSecret` and `AGENT_VAULT_ORG_ID` in the builder-workspace
199
+ repo for the shape, including the boot-time assertion that the deployment really
200
+ is single-tenant before a non-membership-gated fallback is safe.
201
+
202
+ **The doctor guard does not catch this second form** — it looks for
203
+ `process.env` reads, and `resolveCredential` is not one. Grep for
204
+ `resolveCredential` yourself and confirm each call sits somewhere a single-org
205
+ lookup is genuinely the right question. Findings that pair it with a
206
+ `?? process.env.KEY` fallback are the sanctioned deploy-level escape hatch; the
207
+ bugs are calls whose value can only live in another org's vault.
183
208
 
184
209
  ## HTTP routes
185
210
 
@@ -177,9 +177,34 @@ workspace repo:
177
177
  npx agent-native doctor --only no-env-credentials
178
178
  ```
179
179
 
180
- Findings in files that already call `resolveCredential` are usually the
181
- sanctioned `resolveCredential(key, ctx) ?? process.env.KEY` fallback. The bugs
182
- are the files with **no** resolver at all.
180
+ ## `resolveCredential` sees exactly one organization
181
+
182
+ `resolveCredential(key, { userEmail, orgId })` checks the user scope, then the
183
+ one `orgId` you pass, then the solo workspace. That is the whole search. It is
184
+ correct for a signed-in request, and wrong for two common cases:
185
+
186
+ - **The caller has no organization.** A cron, a scheduled job, or any CLI run
187
+ without a real member identity resolves no `orgId`, so only the user and solo
188
+ scopes are ever consulted — and a shared key is in neither.
189
+ - **The key was synced under a different organization.** `app_secrets` has no
190
+ scope visible to every org (`readAppSecret` is strict equality on
191
+ `(scope, scope_id, key)`), so a key the vault UI advertises as available to
192
+ every app is unreadable from any org except the one that ran the sync.
193
+
194
+ Both produce the same misleading "not set" that the split brain above produces,
195
+ which is why swapping `process.env` for `resolveCredential` can look like a fix
196
+ and change nothing. A workspace app should read shared keys through a resolver
197
+ that also sweeps the caller's other memberships and a designated vault org —
198
+ see `resolveConnectorSecret` and `AGENT_VAULT_ORG_ID` in the builder-workspace
199
+ repo for the shape, including the boot-time assertion that the deployment really
200
+ is single-tenant before a non-membership-gated fallback is safe.
201
+
202
+ **The doctor guard does not catch this second form** — it looks for
203
+ `process.env` reads, and `resolveCredential` is not one. Grep for
204
+ `resolveCredential` yourself and confirm each call sits somewhere a single-org
205
+ lookup is genuinely the right question. Findings that pair it with a
206
+ `?? process.env.KEY` fallback are the sanctioned deploy-level escape hatch; the
207
+ bugs are calls whose value can only live in another org's vault.
183
208
 
184
209
  ## HTTP routes
185
210
 
@@ -177,9 +177,34 @@ workspace repo:
177
177
  npx agent-native doctor --only no-env-credentials
178
178
  ```
179
179
 
180
- Findings in files that already call `resolveCredential` are usually the
181
- sanctioned `resolveCredential(key, ctx) ?? process.env.KEY` fallback. The bugs
182
- are the files with **no** resolver at all.
180
+ ## `resolveCredential` sees exactly one organization
181
+
182
+ `resolveCredential(key, { userEmail, orgId })` checks the user scope, then the
183
+ one `orgId` you pass, then the solo workspace. That is the whole search. It is
184
+ correct for a signed-in request, and wrong for two common cases:
185
+
186
+ - **The caller has no organization.** A cron, a scheduled job, or any CLI run
187
+ without a real member identity resolves no `orgId`, so only the user and solo
188
+ scopes are ever consulted — and a shared key is in neither.
189
+ - **The key was synced under a different organization.** `app_secrets` has no
190
+ scope visible to every org (`readAppSecret` is strict equality on
191
+ `(scope, scope_id, key)`), so a key the vault UI advertises as available to
192
+ every app is unreadable from any org except the one that ran the sync.
193
+
194
+ Both produce the same misleading "not set" that the split brain above produces,
195
+ which is why swapping `process.env` for `resolveCredential` can look like a fix
196
+ and change nothing. A workspace app should read shared keys through a resolver
197
+ that also sweeps the caller's other memberships and a designated vault org —
198
+ see `resolveConnectorSecret` and `AGENT_VAULT_ORG_ID` in the builder-workspace
199
+ repo for the shape, including the boot-time assertion that the deployment really
200
+ is single-tenant before a non-membership-gated fallback is safe.
201
+
202
+ **The doctor guard does not catch this second form** — it looks for
203
+ `process.env` reads, and `resolveCredential` is not one. Grep for
204
+ `resolveCredential` yourself and confirm each call sits somewhere a single-org
205
+ lookup is genuinely the right question. Findings that pair it with a
206
+ `?? process.env.KEY` fallback are the sanctioned deploy-level escape hatch; the
207
+ bugs are calls whose value can only live in another org's vault.
183
208
 
184
209
  ## HTTP routes
185
210
 
@@ -10,6 +10,7 @@ import {
10
10
  } from "@agent-native/core/server/request-context";
11
11
  import { assertAccess } from "@agent-native/core/sharing";
12
12
  import { eq } from "drizzle-orm";
13
+ import pLimit from "p-limit";
13
14
  import { z } from "zod";
14
15
 
15
16
  import { getDb, schema } from "../server/db/index.js";
@@ -127,19 +128,28 @@ export default defineAction({
127
128
  if (detectedFormat === "pptx") {
128
129
  const { parsePptx } =
129
130
  await import("../server/handlers/import/pptx-parser.js");
130
- const { convertToSlideHtml } =
131
- await import("../server/handlers/import/html-converter.js");
132
131
  const presentation = await parsePptx(fileBuffer);
133
132
  const title = presentation.title || titleFromPath(filename);
134
133
 
135
134
  if (importIntoDeck) {
136
135
  if (!deckId) throw new Error("deckId is required to import into deck");
137
- const slides = presentation.slides.map((slide) => ({
138
- id: newSlideId(),
139
- content: convertToSlideHtml(slide),
140
- layout: slide.layoutHint ?? "content",
141
- notes: slide.notes,
142
- }));
136
+ await assertAccess("deck", deckId, "editor");
137
+ const pptxOwnerEmail = getRequestUserEmail();
138
+ if (!pptxOwnerEmail) throw new Error("no authenticated user");
139
+ const pptxThemeFont = presentation.theme?.fonts?.[0];
140
+ const uploadLimit = pLimit(4);
141
+ const pptxResults = await Promise.all(
142
+ presentation.slides.map((slide, i) =>
143
+ uploadLimit(() =>
144
+ buildPptxSlide(slide, i, pptxOwnerEmail, pptxThemeFont),
145
+ ),
146
+ ),
147
+ );
148
+ const slides = pptxResults.map((r) => r.slide);
149
+ const imagesSkipped = pptxResults.reduce(
150
+ (total, r) => total + r.imageSkippedCount,
151
+ 0,
152
+ );
143
153
  await replaceDeckSlides(deckId, title, slides, "import-file:pptx");
144
154
  return {
145
155
  format: "pptx",
@@ -148,6 +158,7 @@ export default defineAction({
148
158
  theme: presentation.theme,
149
159
  deckId,
150
160
  imported: true,
161
+ ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
151
162
  };
152
163
  }
153
164
 
@@ -452,6 +463,68 @@ function newSlideId(): string {
452
463
  return `slide-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
453
464
  }
454
465
 
466
+ // EMF/WMF (Windows metafiles) and TIFF are valid PPTX embed formats but
467
+ // browsers can't render them in an <img> tag — uploading and linking one
468
+ // would just produce a broken image icon.
469
+ const PPTX_BROWSER_RENDERABLE_IMAGE_MIME_TYPES = new Set([
470
+ "image/png",
471
+ "image/jpeg",
472
+ "image/gif",
473
+ "image/webp",
474
+ "image/svg+xml",
475
+ "image/bmp",
476
+ ]);
477
+
478
+ async function buildPptxSlide(
479
+ slide: import("../server/handlers/import/pptx-parser.js").ParsedSlide,
480
+ slideIndex: number,
481
+ ownerEmail: string,
482
+ themeFont: string | undefined,
483
+ ): Promise<{
484
+ slide: { id: string; content: string; layout: string; notes?: string };
485
+ imageSkippedCount: number;
486
+ }> {
487
+ const { convertToSlideHtml } =
488
+ await import("../server/handlers/import/html-converter.js");
489
+ const image = slide.images[0];
490
+ const uploadable =
491
+ image && PPTX_BROWSER_RENDERABLE_IMAGE_MIME_TYPES.has(image.mimeType)
492
+ ? image
493
+ : undefined;
494
+ const imageUrl = uploadable
495
+ ? await uploadFile({
496
+ data: Buffer.from(uploadable.data),
497
+ filename:
498
+ "pptx-import-" +
499
+ Date.now() +
500
+ "-s" +
501
+ slideIndex +
502
+ "-" +
503
+ uploadable.name,
504
+ mimeType: uploadable.mimeType,
505
+ ownerEmail,
506
+ recordAsset: false,
507
+ })
508
+ .then((result) => result?.url)
509
+ // A single slide's upload failing (network/API/rate-limit)
510
+ // shouldn't abort the whole deck replacement — fall back to a
511
+ // placeholder like an unsupported format would.
512
+ .catch(() => undefined)
513
+ : undefined;
514
+ return {
515
+ slide: {
516
+ id: newSlideId(),
517
+ content: convertToSlideHtml(slide, imageUrl, themeFont),
518
+ layout: slide.layoutHint ?? "content",
519
+ notes: slide.notes,
520
+ },
521
+ // Only the first image on a slide is ever uploaded, so every other
522
+ // image on that slide is unconditionally dropped too — not just the
523
+ // first one when it's unsupported.
524
+ imageSkippedCount: Math.max(0, slide.images.length - (imageUrl ? 1 : 0)),
525
+ };
526
+ }
527
+
455
528
  function titleFromPath(filePath: string): string {
456
529
  const base = path.basename(filePath, path.extname(filePath)).trim();
457
530
  return base || "Imported File";