@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,20 +1,65 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { writeAppState } from "@agent-native/core/application-state";
3
+ import { uploadFile } from "@agent-native/core/file-upload";
3
4
  import {
4
5
  getRequestUserEmail,
5
6
  getRequestOrgId,
6
7
  } from "@agent-native/core/server/request-context";
7
8
  import { assertAccess } from "@agent-native/core/sharing";
8
9
  import { eq } from "drizzle-orm";
10
+ import pLimit from "p-limit";
9
11
  import { z } from "zod";
10
12
 
11
13
  import { getDb, schema } from "../server/db/index.js";
12
14
  import { notifyClients } from "../server/handlers/decks.js";
13
15
  import { convertToSlideHtml } from "../server/handlers/import/html-converter.js";
14
- import { parsePptx } from "../server/handlers/import/pptx-parser.js";
16
+ import {
17
+ parsePptx,
18
+ type ParsedSlide,
19
+ } from "../server/handlers/import/pptx-parser.js";
15
20
  import { getDeckUrl } from "./_app-url.js";
16
21
  import { readUserUploadedFile } from "./_uploaded-files.js";
17
22
 
23
+ // EMF/WMF (Windows metafiles) and TIFF are valid PPTX embed formats but
24
+ // browsers can't render them in an <img> tag — uploading and linking one
25
+ // would just produce a broken image icon.
26
+ const BROWSER_RENDERABLE_IMAGE_MIME_TYPES = new Set([
27
+ "image/png",
28
+ "image/jpeg",
29
+ "image/gif",
30
+ "image/webp",
31
+ "image/svg+xml",
32
+ "image/bmp",
33
+ ]);
34
+
35
+ async function uploadFirstSlideImage(
36
+ slide: ParsedSlide,
37
+ slideIndex: number,
38
+ ownerEmail: string,
39
+ ): Promise<string | undefined> {
40
+ const image = slide.images[0];
41
+ if (!image || !BROWSER_RENDERABLE_IMAGE_MIME_TYPES.has(image.mimeType)) {
42
+ return undefined;
43
+ }
44
+ const filename =
45
+ "pptx-import-" + Date.now() + "-s" + slideIndex + "-" + image.name;
46
+ try {
47
+ const result = await uploadFile({
48
+ data: Buffer.from(image.data),
49
+ filename,
50
+ mimeType: image.mimeType,
51
+ ownerEmail,
52
+ recordAsset: false,
53
+ });
54
+ return result?.url;
55
+ } catch {
56
+ // A single slide's upload failing (network/API/rate-limit) shouldn't
57
+ // abort the whole deck import — that slide's text still imports fine,
58
+ // it just falls back to a placeholder like an unsupported format would.
59
+ return undefined;
60
+ }
61
+ }
62
+
18
63
  export default defineAction({
19
64
  description:
20
65
  "Import a PPTX file and create a slide deck from it. " +
@@ -43,24 +88,63 @@ export default defineAction({
43
88
  const presentation = await parsePptx(fileBuffer);
44
89
 
45
90
  const deckTitle = title || presentation.title || "Imported Presentation";
91
+ const ownerEmail = getRequestUserEmail();
92
+ if (!ownerEmail) throw new Error("no authenticated user");
93
+ const themeFont = presentation.theme?.fonts?.[0];
46
94
 
47
- // Convert each parsed slide to our HTML format
48
- const slides = presentation.slides.map((parsedSlide, i) => {
49
- const html = convertToSlideHtml(parsedSlide);
50
- return {
51
- id: `slide-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
52
- content: html,
53
- layout: parsedSlide.layoutHint ?? "content",
54
- notes: parsedSlide.notes,
55
- };
56
- });
95
+ // Check edit access before uploading any embedded images — uploads are
96
+ // a side effect with real storage cost, so an unauthorized caller must
97
+ // be rejected before that side effect happens, not after.
98
+ if (deckId) {
99
+ await assertAccess("deck", deckId, "editor");
100
+ }
101
+
102
+ // Convert each parsed slide to our HTML format, uploading the first
103
+ // embedded image (if any) so it renders as a real image instead of a
104
+ // text placeholder. Concurrency is capped so a large deck doesn't fire
105
+ // one outbound upload per slide at once. An image can end up unused
106
+ // (unsupported format, or upload storage not configured) without
107
+ // failing the whole import — the slide's text still imports fine — but
108
+ // that shouldn't be a silent, invisible degradation, so it's counted
109
+ // and returned to the caller.
110
+ const uploadLimit = pLimit(4);
111
+ const results = await Promise.all(
112
+ presentation.slides.map((parsedSlide, i) =>
113
+ uploadLimit(async () => {
114
+ const imageUrl = await uploadFirstSlideImage(
115
+ parsedSlide,
116
+ i,
117
+ ownerEmail,
118
+ );
119
+ const html = convertToSlideHtml(parsedSlide, imageUrl, themeFont);
120
+ return {
121
+ slide: {
122
+ id: `slide-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
123
+ content: html,
124
+ layout: parsedSlide.layoutHint ?? "content",
125
+ notes: parsedSlide.notes,
126
+ },
127
+ // Only the first image on a slide is ever uploaded, so every
128
+ // other image on that slide is unconditionally dropped too —
129
+ // not just the first one when it's unsupported.
130
+ imageSkippedCount: Math.max(
131
+ 0,
132
+ parsedSlide.images.length - (imageUrl ? 1 : 0),
133
+ ),
134
+ };
135
+ }),
136
+ ),
137
+ );
138
+ const slides = results.map((r) => r.slide);
139
+ const imagesSkipped = results.reduce(
140
+ (total, r) => total + r.imageSkippedCount,
141
+ 0,
142
+ );
57
143
 
58
144
  const db = getDb();
59
145
  const now = new Date().toISOString();
60
146
 
61
147
  if (deckId) {
62
- await assertAccess("deck", deckId, "editor");
63
-
64
148
  const existing = await db
65
149
  .select()
66
150
  .from(schema.decks)
@@ -89,6 +173,7 @@ export default defineAction({
89
173
  theme: presentation.theme,
90
174
  imported: true,
91
175
  url: getDeckUrl(deckId),
176
+ ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
92
177
  };
93
178
  }
94
179
 
@@ -99,11 +184,7 @@ export default defineAction({
99
184
  id,
100
185
  title: deckTitle,
101
186
  data: JSON.stringify(data),
102
- ownerEmail: (() => {
103
- const e = getRequestUserEmail();
104
- if (!e) throw new Error("no authenticated user");
105
- return e;
106
- })(),
187
+ ownerEmail,
107
188
  orgId: getRequestOrgId(),
108
189
  createdAt: now,
109
190
  updatedAt: now,
@@ -119,6 +200,7 @@ export default defineAction({
119
200
  theme: presentation.theme,
120
201
  imported: true,
121
202
  url: getDeckUrl(id),
203
+ ...(imagesSkipped > 0 ? { imagesSkipped } : {}),
122
204
  };
123
205
  },
124
206
  });
@@ -50,11 +50,22 @@ export function buildFullBleedImageSlideHtml(
50
50
  /** Wrap text in formatting tags based on run properties. */
51
51
  function formatRun(run: ParsedTextRun): string {
52
52
  let text = esc(run.content);
53
+ if (run.color)
54
+ text = `<span style="color: ${esc(run.color)};">${text}</span>`;
53
55
  if (run.bold) text = `<strong>${text}</strong>`;
54
56
  if (run.italic) text = `<em>${text}</em>`;
55
57
  return text;
56
58
  }
57
59
 
60
+ const DEFAULT_IMPORT_FONT = "'Poppins', sans-serif";
61
+
62
+ /** Turn an extracted PPTX theme font name into a safe CSS font-family value, falling back to the default when absent. */
63
+ function cssFontFamily(themeFont: string | undefined): string {
64
+ if (!themeFont) return DEFAULT_IMPORT_FONT;
65
+ const safeName = themeFont.replace(/["']/g, "").trim();
66
+ return safeName ? `'${safeName}', sans-serif` : DEFAULT_IMPORT_FONT;
67
+ }
68
+
58
69
  /**
59
70
  * Group text runs into logical paragraphs.
60
71
  * In PPTX, paragraph boundaries are typically between runs with different
@@ -85,25 +96,41 @@ function groupIntoParagraphs(texts: ParsedTextRun[]): ParsedTextRun[][] {
85
96
  return paragraphs;
86
97
  }
87
98
 
88
- /** Determine slide layout and generate HTML. */
89
- export function convertToSlideHtml(slide: ParsedSlide): string {
99
+ /**
100
+ * Determine slide layout and generate HTML. `imageUrl` is the hosted URL
101
+ * for the slide's first embedded image (already uploaded by the caller) —
102
+ * pass undefined when the slide has no image or the upload failed, and the
103
+ * builders fall back to a text placeholder instead of a broken `<img>`.
104
+ * `themeFont` is the presentation's extracted theme font, if any, so
105
+ * imported slides keep the source deck's typeface instead of always
106
+ * rendering in Poppins.
107
+ */
108
+ export function convertToSlideHtml(
109
+ slide: ParsedSlide,
110
+ imageUrl?: string,
111
+ themeFont?: string,
112
+ ): string {
90
113
  const paragraphs = groupIntoParagraphs(slide.texts);
114
+ const fontFamily = cssFontFamily(themeFont);
91
115
 
92
- // Determine layout
93
- if (slide.layoutHint === "title" || paragraphs.length <= 2) {
94
- return buildTitleSlide(paragraphs, slide);
116
+ // An embedded image always wins the layout choice — a forced title slide
117
+ // has no room to show it, which is how imports used to silently drop
118
+ // photos from otherwise short/title-shaped slides.
119
+ if (slide.images.length > 0) {
120
+ return buildImageSlide(paragraphs, slide, imageUrl, fontFamily);
95
121
  }
96
122
 
97
- if (slide.images.length > 0) {
98
- return buildImageSlide(paragraphs, slide);
123
+ if (slide.layoutHint === "title" || paragraphs.length <= 2) {
124
+ return buildTitleSlide(paragraphs, slide, fontFamily);
99
125
  }
100
126
 
101
- return buildContentSlide(paragraphs, slide);
127
+ return buildContentSlide(paragraphs, slide, fontFamily);
102
128
  }
103
129
 
104
130
  function buildTitleSlide(
105
131
  paragraphs: ParsedTextRun[][],
106
132
  slide: ParsedSlide,
133
+ fontFamily: string,
107
134
  ): string {
108
135
  const titlePara = paragraphs[0] ?? [];
109
136
  const subtitlePara = paragraphs[1] ?? [];
@@ -111,19 +138,15 @@ function buildTitleSlide(
111
138
  const titleText = titlePara.map(formatRun).join(" ") || "Untitled Slide";
112
139
  const subtitleText = subtitlePara.map(formatRun).join(" ");
113
140
 
114
- let imageHtml = "";
115
- if (slide.images.length > 0) {
116
- imageHtml = `\n <div class="fmd-img-placeholder" style="width: 100%; height: 200px; border-radius: 12px; margin-top: 32px;">Imported image: ${esc(slide.images[0].name)}</div>`;
117
- }
118
-
119
- return `<div class="fmd-slide" style="padding: 80px 110px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; font-family: 'Poppins', sans-serif;">
120
- <h1 style="font-size: 64px; font-weight: 900; color: #fff; line-height: 1.1; letter-spacing: -2px; margin: 0 0 24px 0;">${titleText}</h1>${subtitleText ? `\n <p style="font-size: 22px; color: rgba(255,255,255,0.55); margin: 0;">${subtitleText}</p>` : ""}${imageHtml}
141
+ return `<div class="fmd-slide" style="padding: 80px 110px; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; font-family: ${fontFamily};">
142
+ <h1 style="font-size: 64px; font-weight: 900; color: #fff; line-height: 1.1; letter-spacing: -2px; margin: 0 0 24px 0;">${titleText}</h1>${subtitleText ? `\n <p style="font-size: 22px; color: rgba(255,255,255,0.55); margin: 0;">${subtitleText}</p>` : ""}
121
143
  </div>`;
122
144
  }
123
145
 
124
146
  function buildContentSlide(
125
147
  paragraphs: ParsedTextRun[][],
126
148
  slide: ParsedSlide,
149
+ fontFamily: string,
127
150
  ): string {
128
151
  // First paragraph is the heading, rest are bullet points
129
152
  const headingPara = paragraphs[0] ?? [];
@@ -148,20 +171,86 @@ ${bulletItems}
148
171
  </div>`;
149
172
  }
150
173
 
151
- let imageHtml = "";
152
- if (slide.images.length > 0) {
153
- imageHtml = `\n <div class="fmd-img-placeholder" style="width: 100%; height: 300px; border-radius: 12px; margin-top: 24px;">Imported image: ${esc(slide.images[0].name)}</div>`;
154
- }
155
-
156
- return `<div class="fmd-slide" style="padding: 80px 110px; display: flex; flex-direction: column; justify-content: flex-start; font-family: 'Poppins', sans-serif;">
174
+ return `<div class="fmd-slide" style="padding: 80px 110px; display: flex; flex-direction: column; justify-content: flex-start; font-family: ${fontFamily};">
157
175
  <div style="font-size: 14px; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; color: #00E5FF; margin-bottom: 16px;">IMPORTED</div>
158
- <h2 style="font-size: 40px; font-weight: 900; color: #fff; line-height: 1.15; letter-spacing: -1px; margin: 0 0 48px 0;">${headingText}</h2>${bulletsHtml}${imageHtml}
176
+ <h2 style="font-size: 40px; font-weight: 900; color: #fff; line-height: 1.15; letter-spacing: -1px; margin: 0 0 48px 0;">${headingText}</h2>${bulletsHtml}
159
177
  </div>`;
160
178
  }
161
179
 
180
+ /**
181
+ * Render the slide's embedded image, or a text placeholder if it couldn't
182
+ * be uploaded. `objectFit` defaults to `contain` — the stacked-image layout
183
+ * sizes its box to the shape's own placed aspect ratio specifically so the
184
+ * source photo isn't cropped, but the embedded file's actual pixel ratio
185
+ * can still differ slightly from that placed ratio, and `cover` would crop
186
+ * to fill the box in that case, defeating the point. `cover` is only
187
+ * correct for a full-bleed background image, which intentionally fills its
188
+ * box edge-to-edge.
189
+ */
190
+ function imageOrPlaceholder(
191
+ imageUrl: string | undefined,
192
+ imageName: string,
193
+ style: string,
194
+ objectFit: "cover" | "contain" = "contain",
195
+ ): string {
196
+ if (imageUrl) {
197
+ return `<img src="${esc(imageUrl)}" alt="" style="${style} object-fit: ${objectFit};" />`;
198
+ }
199
+ return `<div class="fmd-img-placeholder" style="${style}">Imported image: ${esc(imageName)}</div>`;
200
+ }
201
+
202
+ /**
203
+ * A PPTX slide's picture and heading always go through one of two real
204
+ * designs, decided by how big the photo was placed on the original slide —
205
+ * not by a single fixed template:
206
+ * - a near-full-slide photo (a cover/section photo) had its title overlaid
207
+ * on top of it in the original, so it's rendered full-bleed with the
208
+ * text overlaid over a legibility scrim;
209
+ * - a smaller inset photo (a card-style illustration) had its caption
210
+ * stacked below it, so it's rendered that way, sized to the image's own
211
+ * aspect ratio instead of a fixed box that would crop or stretch it.
212
+ */
162
213
  function buildImageSlide(
163
214
  paragraphs: ParsedTextRun[][],
164
215
  slide: ParsedSlide,
216
+ imageUrl: string | undefined,
217
+ fontFamily: string,
218
+ ): string {
219
+ if (imageUrl && slide.images[0]?.fullBleed) {
220
+ return buildOverlayImageSlide(paragraphs, imageUrl, fontFamily);
221
+ }
222
+ return buildStackedImageSlide(paragraphs, slide, imageUrl, fontFamily);
223
+ }
224
+
225
+ /** Full-bleed photo with the heading/caption overlaid at the bottom behind a gradient scrim. */
226
+ function buildOverlayImageSlide(
227
+ paragraphs: ParsedTextRun[][],
228
+ imageUrl: string,
229
+ fontFamily: string,
230
+ ): string {
231
+ const headingPara = paragraphs[0] ?? [];
232
+ const headingHtml = headingPara.map(formatRun).join(" ") || "Slide";
233
+
234
+ const captionParas = paragraphs.slice(1);
235
+ const captionHtml = captionParas
236
+ .map((para) => para.map(formatRun).join(" "))
237
+ .join(" ");
238
+
239
+ return `<div class="fmd-slide" style="position: relative; width: 100%; height: 100%; overflow: hidden;">
240
+ <img src="${esc(imageUrl)}" alt="" style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;" />
241
+ <div style="position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.35) 55%, rgba(0,0,0,0) 80%);"></div>
242
+ <div style="position: absolute; left: 0; right: 0; bottom: 0; padding: 56px 70px; font-family: ${fontFamily};">
243
+ <h2 style="font-size: 40px; font-weight: 900; color: #fff; line-height: 1.15; letter-spacing: -1px; margin: 0 0 ${captionHtml ? "12px" : "0"} 0;">${headingHtml}</h2>${captionHtml ? `\n <p style="font-size: 18px; color: rgba(255,255,255,0.75); line-height: 1.5; margin: 0;">${captionHtml}</p>` : ""}
244
+ </div>
245
+ </div>`;
246
+ }
247
+
248
+ /** Photo card on top (sized to its own aspect ratio), heading/caption below. */
249
+ function buildStackedImageSlide(
250
+ paragraphs: ParsedTextRun[][],
251
+ slide: ParsedSlide,
252
+ imageUrl: string | undefined,
253
+ fontFamily: string,
165
254
  ): string {
166
255
  const headingPara = paragraphs[0] ?? [];
167
256
  const headingText = headingPara.map(formatRun).join(" ") || "Slide";
@@ -172,11 +261,24 @@ function buildImageSlide(
172
261
  .join(" ");
173
262
 
174
263
  const imageName = slide.images[0]?.name ?? "image";
175
-
176
- return `<div class="fmd-slide" style="padding: 80px 110px; display: flex; flex-direction: column; justify-content: flex-start; font-family: 'Poppins', sans-serif;">
177
- <div style="font-size: 14px; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; color: #00E5FF; margin-bottom: 16px;">IMPORTED</div>
178
- <h2 style="font-size: 40px; font-weight: 900; color: #fff; line-height: 1.15; letter-spacing: -1px; margin: 0 0 32px 0;">${headingText}</h2>
179
- <div class="fmd-img-placeholder" style="width: 100%; height: 300px; border-radius: 12px;">Imported image: ${esc(imageName)}</div>${captionText ? `\n <p style="font-size: 18px; color: rgba(255,255,255,0.55); margin: 24px 0 0 0;">${captionText}</p>` : ""}
264
+ // Size the box to the image's own placed aspect ratio instead of a fixed
265
+ // height, so portrait and landscape source photos both render undistorted
266
+ // a fixed height forced `object-fit: cover` to crop whichever
267
+ // orientation didn't match the assumed box.
268
+ const aspectRatio = slide.images[0]?.aspectRatio ?? 16 / 9;
269
+ // `max-width` (not `width: 100%`) so the aspect-ratio box is never forced
270
+ // wider than the height cap allows — pinning width to 100% while also
271
+ // capping height made `object-fit: cover` crop the image to fit, which
272
+ // defeated the point of sizing the box to its real aspect ratio.
273
+ const imageHtml = imageOrPlaceholder(
274
+ imageUrl,
275
+ imageName,
276
+ `display: block; max-width: 100%; max-height: 320px; aspect-ratio: ${aspectRatio}; border-radius: 12px; margin: 0 auto 24px;`,
277
+ );
278
+
279
+ return `<div class="fmd-slide" style="padding: 64px 90px; display: flex; flex-direction: column; justify-content: flex-start; font-family: ${fontFamily};">
280
+ ${imageHtml}
281
+ <h2 style="font-size: 32px; font-weight: 900; color: #fff; line-height: 1.2; letter-spacing: -0.5px; margin: 0 0 12px 0;">${headingText}</h2>${captionText ? `\n <p style="font-size: 16px; color: rgba(255,255,255,0.7); line-height: 1.5; margin: 0;">${captionText}</p>` : ""}
180
282
  </div>`;
181
283
  }
182
284
 
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- ok?: undefined;
30
29
  error: string;
30
+ ok?: undefined;
31
31
  } | {
32
32
  error?: undefined;
33
33
  ok: boolean;
@@ -9,6 +9,10 @@ export interface ParsedPptxImage {
9
9
  data: Uint8Array;
10
10
  mimeType: string;
11
11
  name: string;
12
+ /** Width / height of the picture shape on the slide, from its own placed size (not the source file's pixel dimensions). */
13
+ aspectRatio?: number;
14
+ /** 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. */
15
+ fullBleed?: boolean;
12
16
  }
13
17
  export interface ParsedPptxSlide {
14
18
  texts: ParsedPptxTextRun[];
@@ -1 +1 @@
1
- {"version":3,"file":"pptx.d.ts","sourceRoot":"","sources":["../../src/ingestion/pptx.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC3B,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,KAAK,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;CAC/C;AAYD,wBAAsB,qBAAqB,CACzC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,sBAAsB,CAAC,CAgHjC"}
1
+ {"version":3,"file":"pptx.d.ts","sourceRoot":"","sources":["../../src/ingestion/pptx.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,2HAA2H;IAC3H,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wJAAwJ;IACxJ,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,iBAAiB,EAAE,CAAC;IAC3B,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,KAAK,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;CAC/C;AAYD,wBAAsB,qBAAqB,CACzC,UAAU,EAAE,UAAU,GACrB,OAAO,CAAC,sBAAsB,CAAC,CAkJjC"}
@@ -7,7 +7,11 @@ export async function parsePptxPresentation(fileBuffer) {
7
7
  if (!presentationXml)
8
8
  throw new Error("Invalid PPTX: missing ppt/presentation.xml");
9
9
  const presentation = parseXml(presentationXml);
10
- const slideIds = asArray(record(record(record(presentation)?.["p:presentation"])?.["p:sldIdLst"])?.["p:sldId"]).map((entry) => stringValue(record(entry)?.["@_r:id"]) ?? "");
10
+ const presentationRoot = record(record(presentation)?.["p:presentation"]);
11
+ const slideIds = asArray(record(presentationRoot?.["p:sldIdLst"])?.["p:sldId"]).map((entry) => stringValue(record(entry)?.["@_r:id"]) ?? "");
12
+ const sldSz = record(presentationRoot?.["p:sldSz"]);
13
+ const slideWidthEmu = Number(sldSz?.["@_cx"]) || undefined;
14
+ const slideHeightEmu = Number(sldSz?.["@_cy"]) || undefined;
11
15
  const relationshipsXml = await zip
12
16
  .file("ppt/_rels/presentation.xml.rels")
13
17
  ?.async("string");
@@ -50,7 +54,22 @@ export async function parsePptxPresentation(fileBuffer) {
50
54
  .file(relationshipPath)
51
55
  ?.async("string");
52
56
  if (slideRelationshipsXml) {
53
- for (const relationship of parseRelationships(parseXml(slideRelationshipsXml)).values()) {
57
+ const slideRelationships = parseRelationships(parseXml(slideRelationshipsXml));
58
+ // Walk the slide's own picture shapes (in document order) rather than
59
+ // every image relationship, so each image carries the placed size the
60
+ // author gave it on the slide — that size is what tells a full-bleed
61
+ // cover photo apart from a small inset card photo, which a flat
62
+ // relationship scan has no way to know.
63
+ const pictureShapes = [];
64
+ collectPictureShapes(slide, pictureShapes);
65
+ reorderByDocumentPosition(pictureShapes, xml);
66
+ addBackgroundFillShape(slide, pictureShapes, slideWidthEmu, slideHeightEmu);
67
+ for (const shape of pictureShapes) {
68
+ if (!shape.embedId)
69
+ continue;
70
+ const relationship = slideRelationships.get(shape.embedId);
71
+ if (!relationship)
72
+ continue;
54
73
  if (!relationship.type.includes("/image") &&
55
74
  !/\.(png|jpe?g|gif|svg|webp|bmp|tiff?|emf|wmf)$/i.test(relationship.target)) {
56
75
  continue;
@@ -64,10 +83,21 @@ export async function parsePptxPresentation(fileBuffer) {
64
83
  if (!image)
65
84
  continue;
66
85
  const name = imagePath.split("/").at(-1) ?? "image";
86
+ const aspectRatio = shape.widthEmu && shape.heightEmu
87
+ ? shape.widthEmu / shape.heightEmu
88
+ : undefined;
89
+ const fullBleed = Boolean(shape.widthEmu &&
90
+ shape.heightEmu &&
91
+ slideWidthEmu &&
92
+ slideHeightEmu &&
93
+ shape.widthEmu / slideWidthEmu >= 0.85 &&
94
+ shape.heightEmu / slideHeightEmu >= 0.85);
67
95
  images.push({
68
96
  data: new Uint8Array(await image.async("nodebuffer")),
69
97
  mimeType: imageMimeType(name),
70
98
  name,
99
+ aspectRatio,
100
+ fullBleed,
71
101
  });
72
102
  }
73
103
  }
@@ -149,6 +179,181 @@ function collectTextRuns(value, runs, inherited = {}) {
149
179
  collectTextRuns(item, runs, inherited);
150
180
  }
151
181
  }
182
+ /**
183
+ * Recursively find every embedded picture in a slide, in document order:
184
+ * plain `p:pic` shapes, and `p:sp` autoshapes whose fill is a picture
185
+ * (common for "photo cutout" shapes and full-bleed decorative rectangles).
186
+ * A group shape (`p:grpSp`) defines its own local coordinate space for its
187
+ * children (`chOff`/`chExt`) that can be scaled arbitrarily relative to the
188
+ * group's own placed size on the slide, so each level of nesting multiplies
189
+ * a running scale factor into the child sizes below it — without that, a
190
+ * picture inside a resized group would report its unscaled design-time
191
+ * size instead of how large it actually appears on the slide.
192
+ */
193
+ function collectPictureShapes(value, out, scaleX = 1, scaleY = 1) {
194
+ const node = record(value);
195
+ if (!node)
196
+ return;
197
+ for (const [key, child] of Object.entries(node)) {
198
+ if (key.startsWith("@_"))
199
+ continue;
200
+ if (key === "p:pic") {
201
+ for (const picNode of asArray(child)) {
202
+ const pic = record(picNode);
203
+ const blip = record(record(pic?.["p:blipFill"])?.["a:blip"]);
204
+ out.push(scaledShape(stringValue(blip?.["@_r:embed"]), extFromSpPr(pic), scaleX, scaleY));
205
+ }
206
+ continue;
207
+ }
208
+ if (key === "p:sp") {
209
+ for (const spNode of asArray(child)) {
210
+ const sp = record(spNode);
211
+ const blip = record(record(record(sp?.["p:spPr"])?.["a:blipFill"])?.["a:blip"]);
212
+ const embedId = stringValue(blip?.["@_r:embed"]);
213
+ if (embedId) {
214
+ out.push(scaledShape(embedId, extFromSpPr(sp), scaleX, scaleY));
215
+ }
216
+ }
217
+ continue;
218
+ }
219
+ if (key === "p:grpSp") {
220
+ for (const groupNode of asArray(child)) {
221
+ const scale = groupChildScale(record(groupNode), scaleX, scaleY);
222
+ collectPictureShapes(groupNode, out, scale.x, scale.y);
223
+ }
224
+ continue;
225
+ }
226
+ for (const item of asArray(child)) {
227
+ collectPictureShapes(item, out, scaleX, scaleY);
228
+ }
229
+ }
230
+ }
231
+ /** 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. */
232
+ function isOddQuarterTurn(rot) {
233
+ if (!Number.isFinite(rot))
234
+ return false;
235
+ const quarterTurns = Math.round(rot / 60000 / 90);
236
+ return ((quarterTurns % 2) + 2) % 2 === 1;
237
+ }
238
+ /**
239
+ * A group's `chExt` is the coordinate space its children are authored in;
240
+ * `ext` is how large the group actually renders — the ratio between them is
241
+ * the extra scale nested children need on top of their own declared size.
242
+ * When the group itself is rotated a 90/270-degree turn, that ratio applies
243
+ * to the perpendicular axis on the slide, so the X/Y scale factors need
244
+ * swapping the same way a rotated picture's own width/height do.
245
+ */
246
+ function groupChildScale(groupNode, parentScaleX, parentScaleY) {
247
+ const xfrm = record(record(groupNode?.["p:grpSpPr"])?.["a:xfrm"]);
248
+ const ext = record(xfrm?.["a:ext"]);
249
+ const chExt = record(xfrm?.["a:chExt"]);
250
+ const extCx = Number(ext?.["@_cx"]);
251
+ const extCy = Number(ext?.["@_cy"]);
252
+ const chExtCx = Number(chExt?.["@_cx"]);
253
+ const chExtCy = Number(chExt?.["@_cy"]);
254
+ let groupScaleX = Number.isFinite(extCx) && Number.isFinite(chExtCx) && chExtCx > 0
255
+ ? extCx / chExtCx
256
+ : 1;
257
+ let groupScaleY = Number.isFinite(extCy) && Number.isFinite(chExtCy) && chExtCy > 0
258
+ ? extCy / chExtCy
259
+ : 1;
260
+ if (isOddQuarterTurn(Number(xfrm?.["@_rot"]))) {
261
+ [groupScaleX, groupScaleY] = [groupScaleY, groupScaleX];
262
+ }
263
+ return { x: parentScaleX * groupScaleX, y: parentScaleY * groupScaleY };
264
+ }
265
+ /**
266
+ * `a:xfrm/a:ext` always stores the shape's *unrotated* design-time width and
267
+ * height — `a:xfrm/@rot` (in 60,000ths of a degree) rotates it around its
268
+ * own center afterward. For a 90/270-degree turn, the shape's effective
269
+ * on-slide footprint has its width and height swapped relative to that
270
+ * declared size, so a rotated full-bleed photo can otherwise get treated as
271
+ * a small inset, or a portrait image get an inverted aspect ratio.
272
+ */
273
+ function extFromSpPr(shapeNode) {
274
+ const xfrm = record(record(shapeNode?.["p:spPr"])?.["a:xfrm"]);
275
+ const ext = record(xfrm?.["a:ext"]);
276
+ const cx = Number(ext?.["@_cx"]);
277
+ const cy = Number(ext?.["@_cy"]);
278
+ if (!Number.isFinite(cx) || cx <= 0 || !Number.isFinite(cy) || cy <= 0) {
279
+ return { cx: undefined, cy: undefined };
280
+ }
281
+ return isOddQuarterTurn(Number(xfrm?.["@_rot"]))
282
+ ? { cx: cy, cy: cx }
283
+ : { cx, cy };
284
+ }
285
+ function scaledShape(embedId, size, scaleX, scaleY) {
286
+ return {
287
+ embedId,
288
+ widthEmu: size.cx !== undefined ? size.cx * scaleX : undefined,
289
+ heightEmu: size.cy !== undefined ? size.cy * scaleY : undefined,
290
+ };
291
+ }
292
+ /**
293
+ * fast-xml-parser groups sibling elements by tag name, so a slide whose
294
+ * picture-bearing shapes alternate types on the page (e.g. `p:sp`, `p:pic`,
295
+ * `p:sp`) comes out of `collectPictureShapes` re-sorted by tag rather than
296
+ * by the order they actually appear — every `p:sp` gets visited before any
297
+ * `p:pic`, even if a `p:pic` came first in the file. Re-derive the true
298
+ * order from the raw XML text instead: `<a:blip r:embed="...">` occurrences
299
+ * appear in exactly document order regardless of nesting, so they can be
300
+ * used to restore the author's original front-to-back ordering (which
301
+ * matters because downstream rendering treats the first image as primary).
302
+ */
303
+ function reorderByDocumentPosition(shapes, xml) {
304
+ const order = [];
305
+ const blipPattern = /<a:blip\b[^>]*\br:embed=(?:"([^"]+)"|'([^']+)')/g;
306
+ let match;
307
+ while ((match = blipPattern.exec(xml)))
308
+ order.push(match[1] ?? match[2]);
309
+ // The same relationship id can legitimately be reused across multiple
310
+ // placements (e.g. a repeated icon), so looking up a single fixed index
311
+ // per id would assign every reused shape the position of its first XML
312
+ // occurrence. Instead give each id a queue of its occurrence positions
313
+ // and consume one per shape, in the order shapes were collected.
314
+ const occurrences = new Map();
315
+ order.forEach((embedId, index) => {
316
+ const queue = occurrences.get(embedId);
317
+ if (queue)
318
+ queue.push(index);
319
+ else
320
+ occurrences.set(embedId, [index]);
321
+ });
322
+ const positions = shapes.map((shape) => {
323
+ const queue = shape.embedId ? occurrences.get(shape.embedId) : undefined;
324
+ return queue && queue.length > 0 ? queue.shift() : -1;
325
+ });
326
+ const sortedIndices = shapes.map((_, i) => i);
327
+ sortedIndices.sort((a, b) => positions[a] - positions[b]);
328
+ const sorted = sortedIndices.map((i) => shapes[i]);
329
+ shapes.splice(0, shapes.length, ...sorted);
330
+ }
331
+ /** Read the embed relationship id of a slide's background picture fill (`p:cSld/p:bg/p:bgPr/a:blipFill/a:blip`), if any. */
332
+ function extractBackgroundFillEmbedId(slide) {
333
+ const root = record(slide);
334
+ const cSld = record(record(root?.["p:sld"])?.["p:cSld"] ?? root?.["p:cSld"]);
335
+ const bgPr = record(record(cSld?.["p:bg"])?.["p:bgPr"]);
336
+ const blip = record(record(bgPr?.["a:blipFill"])?.["a:blip"]);
337
+ return stringValue(blip?.["@_r:embed"]);
338
+ }
339
+ /**
340
+ * Add the slide's background picture fill, if any, as a synthetic
341
+ * full-slide-sized shape. It's unshifted to the front rather than appended:
342
+ * downstream rendering only ever uses the first image in the list, and a
343
+ * background fill is the slide's base layer — the primary visual — so a
344
+ * smaller foreground picture (a logo, an icon) must not bump it out of that
345
+ * slot.
346
+ */
347
+ function addBackgroundFillShape(slide, pictureShapes, slideWidthEmu, slideHeightEmu) {
348
+ const embedId = extractBackgroundFillEmbedId(slide);
349
+ if (!embedId)
350
+ return;
351
+ pictureShapes.unshift({
352
+ embedId,
353
+ widthEmu: slideWidthEmu,
354
+ heightEmu: slideHeightEmu,
355
+ });
356
+ }
152
357
  function runProperties(value, inherited) {
153
358
  if (!value)
154
359
  return inherited;