@agent-native/core 0.157.9 → 0.157.10

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 (107) hide show
  1. package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +5 -2
  2. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
  3. package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +18 -6
  4. package/corpus/templates/analytics/server/plugins/db.ts +139 -0
  5. package/corpus/templates/slides/AGENTS.md +14 -10
  6. package/corpus/templates/slides/actions/export-pptx.ts +1094 -94
  7. package/corpus/templates/slides/actions/import-file.ts +16 -1
  8. package/corpus/templates/slides/actions/import-pptx.ts +61 -43
  9. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
  10. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
  11. package/corpus/templates/slides/app/global.css +19 -1
  12. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
  13. package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
  14. package/corpus/templates/slides/app/lib/export-pptx-client.ts +556 -25
  15. package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
  16. package/corpus/templates/slides/server/handlers/import/html-converter.ts +1176 -26
  17. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +13 -0
  18. package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
  19. package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
  20. package/dist/agent/harness/migrations.js +24 -3
  21. package/dist/agent/production-agent.js +11 -0
  22. package/dist/agent/run-loop-with-resume.js +21 -4
  23. package/dist/automations/service.d.ts +6 -0
  24. package/dist/automations/service.js +35 -0
  25. package/dist/cli/code-agent-connector.d.ts +4 -1
  26. package/dist/cli/code-agent-connector.js +154 -23
  27. package/dist/cli/code-agent-executor.js +255 -13
  28. package/dist/cli/code-agent-runs.d.ts +1 -0
  29. package/dist/cli/code-agent-runs.js +2 -1
  30. package/dist/cli/portal-workspace.d.ts +49 -0
  31. package/dist/cli/portal-workspace.js +320 -0
  32. package/dist/client/AssistantChat.d.ts +1 -1
  33. package/dist/client/AssistantChat.js +39 -61
  34. package/dist/client/ConnectBuilderCard.js +33 -5
  35. package/dist/client/agent-chat-adapter.js +4 -0
  36. package/dist/client/analytics-platform.d.ts +2 -0
  37. package/dist/client/analytics-platform.js +12 -0
  38. package/dist/client/analytics.d.ts +3 -0
  39. package/dist/client/analytics.js +10 -1
  40. package/dist/client/chat/index.d.ts +1 -0
  41. package/dist/client/chat/index.js +1 -0
  42. package/dist/client/chat/run-recovery.d.ts +2 -1
  43. package/dist/client/chat/run-recovery.js +2 -2
  44. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  45. package/dist/client/chat-first/apps-rail.js +11 -11
  46. package/dist/client/chat-first/surface-tabs.d.ts +1 -1
  47. package/dist/client/chat-first/surface-tabs.js +38 -36
  48. package/dist/client/chat-first/types.d.ts +5 -0
  49. package/dist/client/chat-first.d.ts +1 -1
  50. package/dist/client/chat-first.js +4 -4
  51. package/dist/client/desktop-local-code-change.d.ts +10 -0
  52. package/dist/client/desktop-local-code-change.js +16 -0
  53. package/dist/client/index.d.ts +1 -0
  54. package/dist/client/index.js +1 -0
  55. package/dist/client/observability/ThumbsFeedback.js +21 -7
  56. package/dist/client/terminal/AgentTerminal.d.ts +9 -1
  57. package/dist/client/terminal/AgentTerminal.js +38 -3
  58. package/dist/client/terminal/index.d.ts +1 -1
  59. package/dist/client/terminal/index.js +1 -1
  60. package/dist/client/track.js +9 -1
  61. package/dist/client/use-action.js +3 -0
  62. package/dist/collab/struct-routes.d.ts +1 -1
  63. package/dist/deploy/build.js +1 -1
  64. package/dist/ingestion/pptx.d.ts +78 -0
  65. package/dist/ingestion/pptx.js +991 -107
  66. package/dist/integrations/index.d.ts +3 -3
  67. package/dist/integrations/index.js +2 -2
  68. package/dist/integrations/plugin.js +95 -6
  69. package/dist/integrations/remote-commands-store.d.ts +5 -0
  70. package/dist/integrations/remote-commands-store.js +63 -22
  71. package/dist/integrations/remote-devices-store.d.ts +2 -1
  72. package/dist/integrations/remote-devices-store.js +46 -0
  73. package/dist/integrations/remote-types.d.ts +20 -0
  74. package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
  75. package/dist/jobs/actions/list-recurring-jobs.js +5 -1
  76. package/dist/jobs/frontmatter.d.ts +16 -0
  77. package/dist/jobs/frontmatter.js +53 -0
  78. package/dist/jobs/remote-execution.d.ts +42 -0
  79. package/dist/jobs/remote-execution.js +287 -0
  80. package/dist/jobs/scheduler.js +102 -3
  81. package/dist/jobs/tools.js +50 -3
  82. package/dist/notifications/routes.d.ts +4 -4
  83. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  84. package/dist/provider-api/actions/provider-api.d.ts +14 -14
  85. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  86. package/dist/resources/handlers.d.ts +1 -1
  87. package/dist/server/action-routes.js +4 -2
  88. package/dist/server/agent-chat-plugin.js +6 -0
  89. package/dist/server/agent-run-context.d.ts +1 -0
  90. package/dist/server/agent-run-context.js +9 -0
  91. package/dist/server/core-routes-plugin.js +8 -1
  92. package/dist/server/request-context.d.ts +2 -0
  93. package/dist/shared/analytics-platform.d.ts +6 -0
  94. package/dist/shared/analytics-platform.js +14 -0
  95. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  96. package/dist/shared/mcp-embed-headers.js +1 -1
  97. package/dist/styles/agent-native.css +83 -103
  98. package/dist/terminal/cli-registry.js +5 -0
  99. package/dist/tracking/registry.js +9 -1
  100. package/dist/triggers/actions/list-automations.d.ts +3 -0
  101. package/dist/triggers/actions/list-automations.js +3 -0
  102. package/dist/triggers/actions/manage-automation.d.ts +9 -0
  103. package/dist/triggers/actions/manage-automation.js +14 -2
  104. package/dist/triggers/actions.js +82 -7
  105. package/docs/content/template-content-developers.mdx +48 -0
  106. package/docs/content/toolkit-ui.mdx +57 -0
  107. package/package.json +2 -2
@@ -40,6 +40,24 @@ function getStyle(style: string, prop: string): string | null {
40
40
  */
41
41
  function colorToHex(color: string): { hex: string; transparency?: number } {
42
42
  if (!color) return { hex: "FFFFFF" };
43
+ const parsed = parseCssColor(color);
44
+ if (parsed) return parsed;
45
+ console.warn(
46
+ `[export-pptx] unrecognized color "${color}", defaulting to white`,
47
+ );
48
+ return { hex: "FFFFFF" };
49
+ }
50
+
51
+ /**
52
+ * The recognition half of `colorToHex`, reporting `undefined` rather than white
53
+ * for a value that is not a color at all. A caller that can act on "not a
54
+ * color" — a gradient deciding whether its first argument is a direction or a
55
+ * stop — must not be handed a color the source never contained.
56
+ */
57
+ function parseCssColor(
58
+ color: string,
59
+ ): { hex: string; transparency?: number } | undefined {
60
+ if (!color) return undefined;
43
61
 
44
62
  // Strip quotes / trim
45
63
  color = color.replace(/['"]/g, "").trim();
@@ -99,10 +117,94 @@ function colorToHex(color: string): { hex: string; transparency?: number } {
99
117
  const hex = named[color.toLowerCase()];
100
118
  if (hex) return { hex };
101
119
 
120
+ return undefined;
121
+ }
122
+
123
+ /**
124
+ * Resolve a CSS `background`/`background-color` value to the single solid fill
125
+ * PowerPoint can hold. pptxgenjs has no gradient fill (`ShapeFillProps.type` is
126
+ * `'none' | 'solid'`), so a gradient must collapse to one stop — but dropping
127
+ * the fill entirely, as this used to, exports the shape emptier than the source
128
+ * rather than merely flatter. Pick the first stop that is not fully
129
+ * transparent, which is the stop a `<a:gradFill>` leads with.
130
+ */
131
+ function cssFillToSolid(
132
+ value: string | null | undefined,
133
+ ): { hex: string; transparency?: number } | undefined {
134
+ if (!value) return undefined;
135
+ if (!/gradient\(/i.test(value)) return colorToHex(value);
136
+ for (const stop of value.matchAll(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)/gi)) {
137
+ const parsed = colorToHex(stop[0]);
138
+ if ((parsed.transparency ?? 0) < 100) return parsed;
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ /** The deck templates' own family, used only when no HTML declares one. */
144
+ const DEFAULT_DECK_FONT_FACE = "Poppins";
145
+
146
+ /** First family name of a CSS `font-family` declaration, unquoted. */
147
+ function cssFontFace(style: string): string | undefined {
148
+ return (
149
+ getStyle(style, "font-family")
150
+ ?.replace(/["']/g, "")
151
+ .split(",")[0]
152
+ ?.trim() || undefined
153
+ );
154
+ }
155
+
156
+ /**
157
+ * A preset name copied from `<a:prstGeom prst="...">` only survives if
158
+ * PowerPoint knows it, and pptxgenjs writes whatever string it is handed
159
+ * straight into `prst`. Report an unrecognized one the way `colorToHex`
160
+ * reports an unreadable color instead of shipping a file PowerPoint rejects.
161
+ */
162
+ export function resolveShapeType(
163
+ shapeTypes: Record<string, string>,
164
+ shapeType: string | undefined,
165
+ ): PptxGenJS.ShapeType {
166
+ if (!shapeType) return "rect" as PptxGenJS.ShapeType;
167
+ const known = shapeTypes[shapeType];
168
+ if (known) return known as PptxGenJS.ShapeType;
102
169
  console.warn(
103
- `[export-pptx] unrecognized color "${color}", defaulting to white`,
170
+ `[export-pptx] unrecognized shape geometry "${shapeType}", defaulting to a rectangle`,
104
171
  );
105
- return { hex: "FFFFFF" };
172
+ return "rect" as PptxGenJS.ShapeType;
173
+ }
174
+
175
+ /**
176
+ * CSS border styles PowerPoint can draw. `solid` needs no `dashType`, so it is
177
+ * absent here; anything unlisted (`double`, `groove`, ...) also falls through to
178
+ * a solid line, which is the closest single-stroke approximation.
179
+ */
180
+ const CSS_BORDER_DASH_TYPES: Record<
181
+ string,
182
+ NonNullable<PptxGenJS.ShapeLineProps["dashType"]>
183
+ > = {
184
+ dashed: "dash",
185
+ dotted: "sysDot",
186
+ };
187
+
188
+ const CSS_BORDER_STYLE_PATTERN =
189
+ /([\d.]+)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)/i;
190
+
191
+ interface ParsedCssBorder {
192
+ widthPx: number;
193
+ dashType?: NonNullable<PptxGenJS.ShapeLineProps["dashType"]>;
194
+ color: string;
195
+ }
196
+
197
+ /** Parse a CSS `border` shorthand. `none`/`hidden` borders yield `undefined`. */
198
+ function parseCssBorder(
199
+ border: string | null | undefined,
200
+ ): ParsedCssBorder | undefined {
201
+ const match = border?.match(CSS_BORDER_STYLE_PATTERN);
202
+ if (!match) return undefined;
203
+ return {
204
+ widthPx: Number.parseFloat(match[1]),
205
+ dashType: CSS_BORDER_DASH_TYPES[match[2].toLowerCase()],
206
+ color: match[3],
207
+ };
106
208
  }
107
209
 
108
210
  /**
@@ -129,10 +231,46 @@ function pxToPt(
129
231
  return Math.round((px / pxPerInch) * 72);
130
232
  }
131
233
 
234
+ const EMU_PER_INCH = 914400;
235
+
236
+ /**
237
+ * The page size an imported slide carries from its source `<p:sldSz>`. The
238
+ * importer lays elements out against the nearest `ASPECT_RATIOS` preset box, so
239
+ * exporting onto that preset's inches instead re-pages the deck: a 10x5.625in
240
+ * source comes back 13.33x7.5in, and a 16:10 source (creandum) comes back 16:9
241
+ * with every element stretched 11% vertically, because the preset it snapped to
242
+ * is not its real shape. Scaling the preset's px box onto the source's own
243
+ * inches makes both round trips exact.
244
+ */
245
+ export function sourcePageInches(
246
+ html: string,
247
+ ): { w: number; h: number } | undefined {
248
+ const widthEmu = Number(html.match(/data-slide-width-emu="(\d+)"/)?.[1]);
249
+ const heightEmu = Number(html.match(/data-slide-height-emu="(\d+)"/)?.[1]);
250
+ if (!widthEmu || !heightEmu) return undefined;
251
+ const w = widthEmu / EMU_PER_INCH;
252
+ const h = heightEmu / EMU_PER_INCH;
253
+ // PowerPoint itself refuses a page outside 1-56in; a value beyond that is a
254
+ // corrupt attribute, not a page size, and must not silently re-page the deck.
255
+ if (w < 1 || w > 56 || h < 1 || h > 56) {
256
+ console.warn(
257
+ `[export-pptx] ignoring out-of-range source page size ${widthEmu}x${heightEmu} EMU`,
258
+ );
259
+ return undefined;
260
+ }
261
+ return { w, h };
262
+ }
263
+
264
+ /** `dims` re-based onto the source page size when the slide declares one. */
265
+ function withSourcePageInches(dims: SlideDims, html: string): SlideDims {
266
+ const pptxInches = sourcePageInches(html);
267
+ return pptxInches ? { ...dims, pptxInches } : dims;
268
+ }
269
+
132
270
  interface TextElement {
133
271
  text: string;
134
- fontSize: number; // in pt
135
- fontFace: string;
272
+ fontSize?: number; // in pt; omitted when the source declares none
273
+ fontFace?: string; // omitted when the source declares none
136
274
  color: string; // 6-char hex
137
275
  transparency?: number; // 0-100, percent transparent
138
276
  bold: boolean;
@@ -142,8 +280,9 @@ interface TextElement {
142
280
  h: number; // inches
143
281
  align?: "left" | "center" | "right";
144
282
  letterSpacing?: number;
145
- lineSpacing?: number;
283
+ lineSpacingMultiple?: number;
146
284
  runs?: TextRunElement[];
285
+ rotate?: number; // degrees clockwise
147
286
  order?: number;
148
287
  }
149
288
 
@@ -166,6 +305,7 @@ interface ImageElement {
166
305
  y: number;
167
306
  w: number;
168
307
  h: number;
308
+ rotate?: number; // degrees clockwise
169
309
  order?: number;
170
310
  }
171
311
 
@@ -178,8 +318,13 @@ interface ShapeElement {
178
318
  fillTransparency?: number; // 0-100, percent transparent
179
319
  lineColor?: string;
180
320
  lineTransparency?: number; // 0-100, percent transparent
181
- lineWidth?: number;
182
- rounded?: boolean;
321
+ lineWidth?: number; // in pt
322
+ lineDashType?: NonNullable<PptxGenJS.ShapeLineProps["dashType"]>;
323
+ /** A PowerPoint preset geometry name, or `custGeom` when `points` carry a traced outline. */
324
+ shapeType?: string;
325
+ rectRadius?: number; // inches; roundRect corner radius
326
+ points?: PptxGenJS.ShapeProps["points"];
327
+ rotate?: number; // degrees clockwise
183
328
  order?: number;
184
329
  }
185
330
 
@@ -189,6 +334,10 @@ interface TableElement {
189
334
  y: number;
190
335
  w: number;
191
336
  h: number;
337
+ /** Per-column widths in inches, from the source `a:tblGrid`; absent when the HTML declares none. */
338
+ colW?: number[];
339
+ /** Per-row heights in inches, from the source `a:tr/@h`; absent when the HTML declares none. */
340
+ rowH?: number[];
192
341
  order?: number;
193
342
  }
194
343
 
@@ -230,6 +379,8 @@ interface ParsedSlide {
230
379
  grid?: GridElement;
231
380
  bgColor: string;
232
381
  bgTransparency?: number; // 0-100, percent transparent
382
+ /** The slide background's CSS gradient, when it has one. `bgColor` still holds its flattened stop for the shape-level path. */
383
+ bgGradient?: string;
233
384
  }
234
385
 
235
386
  /**
@@ -242,7 +393,7 @@ export function parseSlideHtml(
242
393
  slideNumber = 1,
243
394
  ): ParsedSlide {
244
395
  assertServerPptxExportable(html, slideNumber);
245
- const dims = getAspectRatioDims(aspectRatio);
396
+ const dims = withSourcePageInches(getAspectRatioDims(aspectRatio), html);
246
397
  if (html.includes('data-imported-pdf="true"')) {
247
398
  return parseImportedPdfSlideHtml(html, dims);
248
399
  }
@@ -261,14 +412,24 @@ export function parseSlideHtml(
261
412
 
262
413
  // Check for background color on the outer .fmd-slide div
263
414
  const slideStyleMatch = html.match(/class="fmd-slide"[^>]*style="([^"]*)"/);
264
- if (slideStyleMatch) {
265
- const bg = getStyle(slideStyleMatch[1], "background(?:-color)?");
266
- if (bg) {
267
- const parsedBg = colorToHex(bg);
268
- bgColor = parsedBg.hex;
269
- bgTransparency = parsedBg.transparency;
270
- }
415
+ const slideBackground = slideStyleMatch
416
+ ? getStyle(slideStyleMatch[1], "background(?:-color)?")
417
+ : null;
418
+ const bgGradient =
419
+ slideBackground && /gradient\(/i.test(slideBackground)
420
+ ? slideBackground
421
+ : undefined;
422
+ const parsedBg = cssFillToSolid(slideBackground);
423
+ if (parsedBg) {
424
+ bgColor = parsedBg.hex;
425
+ bgTransparency = parsedBg.transparency;
271
426
  }
427
+ // Deck templates set the family once on the wrapper; individual headings and
428
+ // paragraphs only override it. Reading it is what keeps a Work Sans or
429
+ // Montserrat design system from exporting as this template's default.
430
+ const slideFontFace = slideStyleMatch
431
+ ? cssFontFace(slideStyleMatch[1])
432
+ : undefined;
272
433
 
273
434
  // Extract padding from the .fmd-slide wrapper
274
435
  const paddingStr = slideStyleMatch
@@ -421,7 +582,7 @@ export function parseSlideHtml(
421
582
  texts.push({
422
583
  text,
423
584
  fontSize: pxToPt(fontSize, dims),
424
- fontFace: "Poppins",
585
+ fontFace: cssFontFace(style) ?? slideFontFace ?? DEFAULT_DECK_FONT_FACE,
425
586
  color: parsedColor.hex,
426
587
  transparency: parsedColor.transparency,
427
588
  bold,
@@ -430,24 +591,50 @@ export function parseSlideHtml(
430
591
  w: contentW,
431
592
  h: elHeight + 0.2,
432
593
  letterSpacing: letterSpacing ? parseFloat(letterSpacing) : undefined,
433
- lineSpacing: lineH
434
- ? Math.round(lineH * pxToPt(fontSize, dims))
435
- : undefined,
594
+ lineSpacingMultiple: lineH,
436
595
  });
437
596
 
438
597
  yPos += elHeight + pxToIn(marginBottom, dims) + 0.1;
439
598
  }
440
599
  }
441
600
 
442
- return { texts, images, shapes, tables: [], bgColor, bgTransparency };
601
+ return {
602
+ texts,
603
+ images,
604
+ shapes,
605
+ tables: [],
606
+ bgColor,
607
+ bgTransparency,
608
+ ...(bgGradient ? { bgGradient } : {}),
609
+ };
443
610
  }
444
611
 
445
- type SlideDims = ReturnType<typeof getAspectRatioDims>;
612
+ /**
613
+ * Structural rather than `ReturnType<typeof getAspectRatioDims>`: the px box
614
+ * stays the aspect-ratio preset the importer laid out against, while
615
+ * `pptxInches` is re-based onto the source page size, so the two halves no
616
+ * longer come from the same preset literal.
617
+ */
618
+ interface SlideDims {
619
+ width: number;
620
+ height: number;
621
+ pptxInches: { w: number; h: number };
622
+ }
623
+
624
+ /**
625
+ * The `.fmd-slide` wrapper's inline style. The delimiter has to be captured and
626
+ * back-referenced: the importer writes `font-family: 'Work Sans', sans-serif`
627
+ * into a double-quoted attribute, so a naive `[^"']*` body silently truncates
628
+ * the style at the first font name and hides every declaration after it.
629
+ */
630
+ function slideWrapperStyle(html: string): string | undefined {
631
+ return html.match(
632
+ /class=(["'])[^"']*\bfmd-slide\b[^"']*\1[^>]*\bstyle=(["'])([\s\S]*?)\2/i,
633
+ )?.[3];
634
+ }
446
635
 
447
636
  function parseImportedPdfSlideHtml(html: string, dims: SlideDims): ParsedSlide {
448
- const outerStyle = html.match(
449
- /class=["'][^"']*\bfmd-slide\b[^"']*["'][^>]*style=["']([^"']*)["']/i,
450
- )?.[1];
637
+ const outerStyle = slideWrapperStyle(html);
451
638
  const parsedBg = colorToHex(
452
639
  outerStyle
453
640
  ? (getStyle(outerStyle, "background(?:-color)?") ?? "#000000") // guard:allow-raw-color - imported PDF fallback
@@ -504,21 +691,29 @@ function parseImportedPdfSlideHtml(html: string, dims: SlideDims): ParsedSlide {
504
691
  };
505
692
  }
506
693
 
694
+ // Must stay identical to `DEFAULT_PPTX_BACKGROUND` / `DEFAULT_PPTX_FOREGROUND`
695
+ // in server/handlers/import/html-converter.ts. Export previously inverted both
696
+ // (black background, white text), so an undecorated slide came back out of a
697
+ // round trip with its colors flipped rather than unchanged.
698
+ const IMPORTED_PPTX_BACKGROUND_FALLBACK = "#ffffff"; // guard:allow-raw-color - mirrors the importer's PPTX default
699
+ const IMPORTED_PPTX_FOREGROUND_FALLBACK = "111827"; // guard:allow-raw-color - mirrors the importer's PPTX default
700
+
507
701
  function parseImportedSlideHtml(html: string, dims: SlideDims): ParsedSlide {
508
702
  const texts: TextElement[] = [];
509
703
  const images: ImageElement[] = [];
510
704
  const shapes: ShapeElement[] = [];
511
705
  const tables: TableElement[] = [];
512
- const outerStyle = html.match(
513
- /class=["'][^"']*\bfmd-slide\b[^"']*["'][^>]*style=["']([^"']*)["']/i,
514
- )?.[1];
515
- const parsedBg = colorToHex(
516
- outerStyle
517
- ? (getStyle(outerStyle, "background(?:-color)?") ?? "#000000") // guard:allow-raw-color - PPTX background fallback
518
- : "#000000", // guard:allow-raw-color - PPTX background fallback
519
- );
706
+ const outerStyle = slideWrapperStyle(html);
707
+ const background = outerStyle
708
+ ? getStyle(outerStyle, "background(?:-color)?")
709
+ : undefined;
710
+ const parsedBg =
711
+ cssFillToSolid(background) ?? colorToHex(IMPORTED_PPTX_BACKGROUND_FALLBACK);
520
712
  const bgColor = parsedBg.hex;
521
713
  const bgTransparency = parsedBg.transparency;
714
+ const bgGradient =
715
+ background && /gradient\(/i.test(background) ? background : undefined;
716
+ const slideFontFace = outerStyle ? cssFontFace(outerStyle) : undefined;
522
717
  const grid = outerStyle ? parseImportedGrid(outerStyle) : undefined;
523
718
  const elementRegex =
524
719
  /<div\b([^>]*\bdata-pptx-element-kind=["'](text|image|shape|table)["'][^>]*)>([\s\S]*?)<\/div>/gi;
@@ -530,36 +725,52 @@ function parseImportedSlideHtml(html: string, dims: SlideDims): ParsedSlide {
530
725
  const style = getAttribute(attrs, "style") ?? "";
531
726
  const geometry = importedGeometry(style, dims);
532
727
  if (!geometry) continue;
728
+ const rotate = importedRotation(style);
533
729
 
534
730
  if (kind === "image") {
535
731
  const imageAttrs = innerHtml.match(/<img\b([^>]*)>/i)?.[1] ?? "";
536
732
  const src = getAttribute(imageAttrs, "src");
537
733
  if (src) {
538
- images.push({ src, ...geometry, order: match.index });
734
+ images.push({
735
+ src,
736
+ ...geometry,
737
+ ...(rotate != null ? { rotate } : {}),
738
+ order: match.index,
739
+ });
539
740
  }
540
741
  continue;
541
742
  }
542
743
 
543
744
  if (kind === "shape") {
544
- const fill = getStyle(style, "background(?:-color)?");
545
- const border = getStyle(style, "border");
546
- const borderMatch = border?.match(/([\d.]+)px\s+solid\s+(.+)/i);
547
- const parsedFill =
548
- fill && !fill.includes("gradient") ? colorToHex(fill) : undefined;
549
- const parsedLine = borderMatch ? colorToHex(borderMatch[2]) : undefined;
745
+ const parsedFill = cssFillToSolid(
746
+ getStyle(style, "background(?:-color)?"),
747
+ );
748
+ const border = parseCssBorder(getStyle(style, "border"));
749
+ const parsedLine = border ? colorToHex(border.color) : undefined;
750
+ const outline = importedFreeformStroke(innerHtml, dims);
550
751
  shapes.push({
551
752
  ...geometry,
552
753
  ...(parsedFill
553
754
  ? { fill: parsedFill.hex, fillTransparency: parsedFill.transparency }
554
755
  : {}),
555
- ...(borderMatch && parsedLine
756
+ ...(border && parsedLine
556
757
  ? {
557
758
  lineColor: parsedLine.hex,
558
759
  lineTransparency: parsedLine.transparency,
559
- lineWidth: Number(borderMatch[1]),
760
+ // `pxToPt` rounds, so a hairline needs the same 0.5pt floor the
761
+ // table borders use or the outline rounds away to nothing.
762
+ lineWidth: Math.max(0.5, pxToPt(border.widthPx, dims)),
763
+ ...(border.dashType ? { lineDashType: border.dashType } : {}),
560
764
  }
561
- : {}),
562
- rounded: style.includes("border-radius"),
765
+ : outline
766
+ ? {
767
+ lineColor: outline.color.hex,
768
+ lineTransparency: outline.color.transparency,
769
+ lineWidth: outline.width,
770
+ }
771
+ : {}),
772
+ ...importedShapeGeometry(attrs, style, outline?.path, geometry, dims),
773
+ ...(rotate != null ? { rotate } : {}),
563
774
  order: match.index,
564
775
  });
565
776
  continue;
@@ -568,7 +779,12 @@ function parseImportedSlideHtml(html: string, dims: SlideDims): ParsedSlide {
568
779
  if (kind === "table") {
569
780
  const rows = importedTableRows(innerHtml, dims);
570
781
  if (rows.length > 0) {
571
- tables.push({ ...geometry, rows, order: match.index });
782
+ tables.push({
783
+ ...geometry,
784
+ rows,
785
+ ...importedTableTracks(innerHtml, rows, geometry),
786
+ order: match.index,
787
+ });
572
788
  }
573
789
  continue;
574
790
  }
@@ -581,28 +797,48 @@ function parseImportedSlideHtml(html: string, dims: SlideDims): ParsedSlide {
581
797
  const lineHeight = firstParagraph
582
798
  ? getStyle(firstParagraph, "line-height")
583
799
  : null;
584
- const fontSize = firstRun?.options.fontSize ?? 18;
585
800
  const alignValue = getStyle(style, "text-align");
801
+ // Per-run faces still win inside `addText`; this only supplies the
802
+ // box-level default for runs that declare none, so it must be the source
803
+ // deck's own theme font rather than this template's.
804
+ const boxFontFace = firstRun?.options.fontFace ?? slideFontFace;
586
805
  texts.push({
587
806
  text: runs.map((run) => run.text).join(""),
588
- fontSize,
589
- fontFace: firstRun?.options.fontFace ?? "Poppins",
590
- color: firstRun?.options.color ?? "FFFFFF",
807
+ ...(firstRun?.options.fontSize != null
808
+ ? { fontSize: firstRun.options.fontSize }
809
+ : {}),
810
+ ...(boxFontFace ? { fontFace: boxFontFace } : {}),
811
+ color: firstRun?.options.color ?? IMPORTED_PPTX_FOREGROUND_FALLBACK,
591
812
  transparency: firstRun?.options.transparency,
592
- bold: firstRun?.options.bold ?? false,
813
+ // pptxgenjs copies a box-level option onto any run whose own value is
814
+ // falsy (`!textObj.options[key]`), so a box default of `bold: true` taken
815
+ // from the first run overwrites every later run that said `bold: false` —
816
+ // a bold heading made its whole text box bold. The box may only default
817
+ // to bold when the runs already agree.
818
+ bold: runs.length > 0 && runs.every((run) => run.options.bold === true),
593
819
  align:
594
820
  alignValue === "center" || alignValue === "right" ? alignValue : "left",
595
- lineSpacing: lineHeight ? Number(lineHeight) * fontSize : undefined,
821
+ lineSpacingMultiple: lineHeight ? Number(lineHeight) : undefined,
596
822
  x: geometry.x,
597
823
  y: geometry.y,
598
824
  w: geometry.w,
599
825
  h: geometry.h,
600
826
  runs,
827
+ ...(rotate != null ? { rotate } : {}),
601
828
  order: match.index,
602
829
  });
603
830
  }
604
831
 
605
- return { texts, images, shapes, tables, grid, bgColor, bgTransparency };
832
+ return {
833
+ texts,
834
+ images,
835
+ shapes,
836
+ tables,
837
+ grid,
838
+ bgColor,
839
+ bgTransparency,
840
+ ...(bgGradient ? { bgGradient } : {}),
841
+ };
606
842
  }
607
843
 
608
844
  function parseImportedGrid(style: string): GridElement | undefined {
@@ -665,6 +901,335 @@ function pxToInY(px: number, dims: SlideDims): number {
665
901
  return (px / dims.height) * dims.pptxInches.h;
666
902
  }
667
903
 
904
+ /**
905
+ * The `<a:xfrm rot="...">` the importer rendered as `transform: rotate(Ndeg)`.
906
+ * Dropping it exports a rotated object square to the slide, which on a ring of
907
+ * six curved arrows leaves six copies of the same arrow stacked at the top
908
+ * rather than a ring.
909
+ */
910
+ function importedRotation(style: string): number | undefined {
911
+ const degrees = Number.parseFloat(
912
+ getStyle(style, "transform")?.match(
913
+ /rotate\(\s*(-?[\d.]+)deg\s*\)/i,
914
+ )?.[1] ?? "",
915
+ );
916
+ return Number.isFinite(degrees) && degrees !== 0 ? degrees : undefined;
917
+ }
918
+
919
+ /**
920
+ * Recover the shape's PowerPoint geometry. `<a:prstGeom prst="...">` is the
921
+ * only lossless carrier — CSS cannot distinguish a trapezoid from a hexagon
922
+ * once both are `clip-path: polygon(...)` — so the importer's
923
+ * `data-pptx-shape-type` attribute wins when present. Otherwise trace what CSS
924
+ * can prove: a freeform outline and a polygon become custom outlines, and a
925
+ * border radius becomes an ellipse or a real corner radius instead of
926
+ * pptxgen's default roundRect.
927
+ */
928
+ function importedShapeGeometry(
929
+ attrs: string,
930
+ style: string,
931
+ strokeOutline: string | undefined,
932
+ size: { w: number; h: number },
933
+ dims: SlideDims,
934
+ ): Pick<ShapeElement, "shapeType" | "rectRadius" | "points"> {
935
+ const preset = getAttribute(attrs, "data-pptx-shape-type");
936
+ if (preset) return { shapeType: preset };
937
+
938
+ const clipPath = getStyle(style, "clip-path");
939
+ // A stroke-only freeform carries no clip (clipping an unfilled box would eat
940
+ // half the stroke), so its overlay path is the only outline it has.
941
+ const outline =
942
+ clipPath?.match(/path\(\s*(["']?)([^"')]*)\1\s*\)/i)?.[2] ?? strokeOutline;
943
+ if (outline) {
944
+ const points = svgPathPoints(outline, dims);
945
+ if (points) return { shapeType: "custGeom", points };
946
+ }
947
+
948
+ const polygon = clipPath?.match(/polygon\(([^)]*)\)/i)?.[1];
949
+ if (polygon) {
950
+ const points = clipPathPolygonPoints(polygon, size, dims);
951
+ if (points) return { shapeType: "custGeom", points };
952
+ }
953
+
954
+ const radius = getStyle(style, "border-radius")?.split(/\s+/)[0];
955
+ if (!radius) return {};
956
+ const shortSide = Math.min(size.w, size.h);
957
+ const value = Number.parseFloat(radius);
958
+ if (!Number.isFinite(value) || value <= 0) return {};
959
+ if (radius.endsWith("%")) {
960
+ return value >= 50
961
+ ? { shapeType: "ellipse" }
962
+ : { shapeType: "roundRect", rectRadius: (value / 100) * shortSide };
963
+ }
964
+ return {
965
+ shapeType: "roundRect",
966
+ // A pill (`border-radius: 9999px`) clamps to the half-side PowerPoint caps
967
+ // its `adj` value at, rather than overflowing the shape.
968
+ rectRadius: Math.min(pxToIn(value, dims), shortSide / 2),
969
+ };
970
+ }
971
+
972
+ /**
973
+ * A freeform outline's stroke follows the path, not the box, so the importer
974
+ * draws it as an SVG overlay instead of a `border`. Read that overlay back:
975
+ * it carries the shape's line color and width, and for a line-art pictogram —
976
+ * no fill, so nothing to clip — it is the only copy of the outline itself.
977
+ */
978
+ function importedFreeformStroke(
979
+ innerHtml: string,
980
+ dims: SlideDims,
981
+ ):
982
+ | { path: string; color: ReturnType<typeof colorToHex>; width: number }
983
+ | undefined {
984
+ const pathAttrs = innerHtml.match(/<svg\b[^>]*>\s*<path\b([^>]*)>/i)?.[1];
985
+ if (!pathAttrs) return undefined;
986
+ const path = getAttribute(pathAttrs, "d");
987
+ const stroke = getAttribute(pathAttrs, "stroke");
988
+ if (!path || !stroke) return undefined;
989
+ const widthPx = Number.parseFloat(
990
+ getAttribute(pathAttrs, "stroke-width") ?? "1",
991
+ );
992
+ return {
993
+ path: decodeHtmlText(path),
994
+ color: colorToHex(stroke),
995
+ // The same 0.5pt floor the CSS-border path uses: `pxToPt` rounds, and a
996
+ // hairline outline that rounds to 0pt is an invisible shape.
997
+ width: Math.max(0.5, pxToPt(Number.isFinite(widthPx) ? widthPx : 1, dims)),
998
+ };
999
+ }
1000
+
1001
+ type CustomGeometryPoint = NonNullable<ShapeElement["points"]>[number];
1002
+
1003
+ /** Numbers each SVG path command consumes. The importer emits only these. */
1004
+ const SVG_PATH_ARITY: Record<string, number> = {
1005
+ M: 2,
1006
+ L: 2,
1007
+ Q: 4,
1008
+ C: 6,
1009
+ A: 7,
1010
+ };
1011
+
1012
+ /**
1013
+ * One SVG number. The importer writes path data in its shortest legal
1014
+ * spelling — `-1.7 0-1.2-.2` is four numbers, not one token per space — so
1015
+ * splitting on whitespace reads a 200-country map as a single unreadable
1016
+ * command and exports the whole thing as rectangles.
1017
+ */
1018
+ const SVG_PATH_NUMBER = /[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi;
1019
+
1020
+ /**
1021
+ * Trace the importer's freeform outline — `clip-path: path('...')`, or the
1022
+ * stroke overlay it draws for an unfilled one — as pptxgenjs custom geometry.
1023
+ * Every command `customGeometryPath`/`blockArcPath` emit has an exact
1024
+ * counterpart in `ShapeProps.points`: `moveTo`/`lnTo` are bare points and
1025
+ * `cubicBezTo`/`quadBezTo`/`arcTo` are its three curve types, so nothing is
1026
+ * flattened. Coordinates are the shape's own pixel box, which converts to the
1027
+ * inches `addShape` writes into `<a:path>` the same way a polygon's do.
1028
+ *
1029
+ * Returns `undefined` rather than a partial outline when a command cannot be
1030
+ * read, so the caller falls back to a whole rectangle instead of exporting a
1031
+ * fragment of the shape.
1032
+ */
1033
+ function svgPathPoints(
1034
+ path: string,
1035
+ dims: SlideDims,
1036
+ ): NonNullable<ShapeElement["points"]> | undefined {
1037
+ const points: CustomGeometryPoint[] = [];
1038
+ const toX = (px: number) => pxToIn(px, dims);
1039
+ const toY = (px: number) => pxToInY(px, dims);
1040
+ let cursorX = 0;
1041
+ let cursorY = 0;
1042
+ let subpathX = 0;
1043
+ let subpathY = 0;
1044
+ let seenCommand = false;
1045
+
1046
+ for (const match of path.matchAll(/([A-Za-z])([^A-Za-z]*)/g)) {
1047
+ const command = match[1].toUpperCase();
1048
+ const relative = match[1] !== command;
1049
+ seenCommand = true;
1050
+ const args = (match[2].match(SVG_PATH_NUMBER) ?? []).map(Number);
1051
+ if (command === "Z") {
1052
+ if (args.length > 0) return undefined;
1053
+ points.push({ close: true });
1054
+ // `z` returns the pen to where the subpath started; the next relative
1055
+ // command steps from there, not from the last point drawn.
1056
+ cursorX = subpathX;
1057
+ cursorY = subpathY;
1058
+ continue;
1059
+ }
1060
+ const arity = SVG_PATH_ARITY[command];
1061
+ if (
1062
+ !arity ||
1063
+ args.length === 0 ||
1064
+ args.length % arity !== 0 ||
1065
+ args.some((value) => !Number.isFinite(value))
1066
+ ) {
1067
+ console.warn(
1068
+ `[export-pptx] unreadable SVG path command "${match[0].trim().slice(0, 40)}" in a shape outline; exported as a rectangle`,
1069
+ );
1070
+ return undefined;
1071
+ }
1072
+ for (let i = 0; i < args.length; i += arity) {
1073
+ const chunk = args.slice(i, i + arity);
1074
+ // A relative command steps from the point its own segment starts at —
1075
+ // including a curve's control points, which are offsets from that same
1076
+ // point rather than from each other.
1077
+ const originX = relative ? cursorX : 0;
1078
+ const originY = relative ? cursorY : 0;
1079
+ const atX = (index: number) => originX + chunk[index];
1080
+ const atY = (index: number) => originY + chunk[index];
1081
+ const endX = atX(arity - 2);
1082
+ const endY = atY(arity - 1);
1083
+ if (command === "M") {
1084
+ // A repeated pair after `M` is an implicit lineto, and every `M` past
1085
+ // the first opens a subpath pptxgenjs only reopens for `moveTo`.
1086
+ points.push(
1087
+ i === 0
1088
+ ? { x: toX(endX), y: toY(endY), moveTo: true }
1089
+ : { x: toX(endX), y: toY(endY) },
1090
+ );
1091
+ if (i === 0) {
1092
+ subpathX = endX;
1093
+ subpathY = endY;
1094
+ }
1095
+ } else if (command === "L") {
1096
+ points.push({ x: toX(endX), y: toY(endY) });
1097
+ } else if (command === "C") {
1098
+ points.push({
1099
+ x: toX(endX),
1100
+ y: toY(endY),
1101
+ curve: {
1102
+ type: "cubic",
1103
+ x1: toX(atX(0)),
1104
+ y1: toY(atY(1)),
1105
+ x2: toX(atX(2)),
1106
+ y2: toY(atY(3)),
1107
+ },
1108
+ });
1109
+ } else if (command === "Q") {
1110
+ points.push({
1111
+ x: toX(endX),
1112
+ y: toY(endY),
1113
+ curve: { type: "quadratic", x1: toX(atX(0)), y1: toY(atY(1)) },
1114
+ });
1115
+ } else {
1116
+ // Only an arc's endpoint is relative: its radii and flags are not
1117
+ // coordinates.
1118
+ const arc = svgArcToPptxCurve(
1119
+ cursorX,
1120
+ cursorY,
1121
+ [...chunk.slice(0, 5), endX, endY],
1122
+ dims,
1123
+ );
1124
+ if (!arc) return undefined;
1125
+ points.push(arc);
1126
+ }
1127
+ cursorX = endX;
1128
+ cursorY = endY;
1129
+ }
1130
+ }
1131
+
1132
+ if (!seenCommand) return undefined;
1133
+ return points.length > 0 ? points : undefined;
1134
+ }
1135
+
1136
+ /**
1137
+ * `<a:arcTo>` names an arc by its radii and its start/swing angles around a
1138
+ * center it derives from the current point; SVG's `A` names the same arc by
1139
+ * its endpoint plus two flags. This is the SVG spec's endpoint-to-center
1140
+ * conversion (F.6.5) with the x-axis rotation the importer never emits left
1141
+ * out, so the ring segments and rounded freeforms come back as true arcs
1142
+ * rather than as chords across them.
1143
+ */
1144
+ function svgArcToPptxCurve(
1145
+ startX: number,
1146
+ startY: number,
1147
+ [radiusX, radiusY, rotation, largeArc, sweep, endX, endY]: number[],
1148
+ dims: SlideDims,
1149
+ ): CustomGeometryPoint | undefined {
1150
+ if (rotation !== 0) {
1151
+ console.warn(
1152
+ `[export-pptx] rotated elliptical arc (${rotation}deg) in a shape outline; exported as a rectangle`,
1153
+ );
1154
+ return undefined;
1155
+ }
1156
+ const point = { x: pxToIn(endX, dims), y: pxToInY(endY, dims) };
1157
+ let rx = Math.abs(radiusX);
1158
+ let ry = Math.abs(radiusY);
1159
+ // A zero radius is a straight line in SVG, and PowerPoint draws nothing at
1160
+ // all for one.
1161
+ if (!(rx > 0) || !(ry > 0)) return point;
1162
+
1163
+ const midX = (startX - endX) / 2;
1164
+ const midY = (startY - endY) / 2;
1165
+ const scale = (midX * midX) / (rx * rx) + (midY * midY) / (ry * ry);
1166
+ if (scale > 1) {
1167
+ rx *= Math.sqrt(scale);
1168
+ ry *= Math.sqrt(scale);
1169
+ }
1170
+ const denominator = rx * rx * midY * midY + ry * ry * midX * midX;
1171
+ const numerator = rx * rx * ry * ry - denominator;
1172
+ const factor =
1173
+ (largeArc !== sweep ? 1 : -1) *
1174
+ Math.sqrt(Math.max(0, numerator / denominator));
1175
+ const centerX = (factor * (rx * midY)) / ry + (startX + endX) / 2;
1176
+ const centerY = (-factor * (ry * midX)) / rx + (startY + endY) / 2;
1177
+ const startAngle = Math.atan2(
1178
+ (startY - centerY) / ry,
1179
+ (startX - centerX) / rx,
1180
+ );
1181
+ const endAngle = Math.atan2((endY - centerY) / ry, (endX - centerX) / rx);
1182
+ let swingAngle = endAngle - startAngle;
1183
+ if (sweep && swingAngle < 0) swingAngle += 2 * Math.PI;
1184
+ if (!sweep && swingAngle > 0) swingAngle -= 2 * Math.PI;
1185
+
1186
+ const toDegrees = (radians: number) => (radians * 180) / Math.PI;
1187
+ return {
1188
+ ...point,
1189
+ curve: {
1190
+ type: "arc",
1191
+ wR: pxToIn(rx, dims),
1192
+ hR: pxToInY(ry, dims),
1193
+ // pptxgenjs subtracts a full turn from anything over 360 before scaling
1194
+ // to 60000ths of a degree, so the start angle has to arrive inside one.
1195
+ stAng: (toDegrees(startAngle) + 360) % 360,
1196
+ swAng: toDegrees(swingAngle),
1197
+ },
1198
+ };
1199
+ }
1200
+
1201
+ /**
1202
+ * `clip-path: polygon(x y, ...)` traced as pptxgenjs custom-geometry points,
1203
+ * in inches relative to the shape's own box.
1204
+ */
1205
+ function clipPathPolygonPoints(
1206
+ polygon: string,
1207
+ size: { w: number; h: number },
1208
+ dims: SlideDims,
1209
+ ): NonNullable<ShapeElement["points"]> | undefined {
1210
+ const points: Array<{ x: number; y: number }> = [];
1211
+ for (const pair of polygon.split(",")) {
1212
+ const [rawX, rawY] = pair.trim().split(/\s+/);
1213
+ const x = clipPathCoord(rawX, size.w, dims, pxToIn);
1214
+ const y = clipPathCoord(rawY, size.h, dims, pxToInY);
1215
+ if (x == null || y == null) return undefined;
1216
+ points.push({ x, y });
1217
+ }
1218
+ return points.length >= 3 ? [...points, { close: true }] : undefined;
1219
+ }
1220
+
1221
+ function clipPathCoord(
1222
+ raw: string | undefined,
1223
+ side: number,
1224
+ dims: SlideDims,
1225
+ toInches: (px: number, dims: SlideDims) => number,
1226
+ ): number | undefined {
1227
+ if (!raw) return undefined;
1228
+ const value = Number.parseFloat(raw);
1229
+ if (!Number.isFinite(value)) return undefined;
1230
+ return raw.endsWith("%") ? (value / 100) * side : toInches(value, dims);
1231
+ }
1232
+
668
1233
  function importedTextRuns(html: string, dims: SlideDims): TextRunElement[] {
669
1234
  const paragraphs = [...html.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)].map(
670
1235
  (match) => match[1],
@@ -690,6 +1255,101 @@ function importedTextRuns(html: string, dims: SlideDims): TextRunElement[] {
690
1255
  return runs;
691
1256
  }
692
1257
 
1258
+ /**
1259
+ * No table-level `border` or `margin`: pptxgenjs cascades every table option
1260
+ * onto each cell that declares none of its own, so the white rule and the flat
1261
+ * 0.04in padding this used to pass were stamped on every cell of every
1262
+ * imported table — a grid the source never drew, over the cell margins it did.
1263
+ * Both now travel per cell, from that cell's own CSS.
1264
+ */
1265
+ export function tableOptions(table: TableElement): PptxGenJS.TableProps {
1266
+ return {
1267
+ x: table.x,
1268
+ y: table.y,
1269
+ w: table.w,
1270
+ h: table.h,
1271
+ ...(table.colW ? { colW: table.colW } : {}),
1272
+ ...(table.rowH ? { rowH: table.rowH } : {}),
1273
+ autoPage: false,
1274
+ };
1275
+ }
1276
+
1277
+ /** Inline style of the first `<p>` in a fragment, which is where the importer puts paragraph-level alignment. */
1278
+ function firstParagraphStyle(html: string): string | undefined {
1279
+ return html.match(/<p\b[^>]*style=["']([^"']*)["']/i)?.[1];
1280
+ }
1281
+
1282
+ /**
1283
+ * The `<colgroup>` and `<tr>` percentages the importer derives from
1284
+ * `a:tblGrid/a:gridCol` and `a:tr/@h`, back as the inch sizes `addTable`
1285
+ * needs. Without them pptxgenjs divides the box evenly, so a table exports
1286
+ * with columns and rows the source never had — nine equal columns in place of
1287
+ * gamesfund's 1001175/836125/862000/899775... grid.
1288
+ *
1289
+ * A track list that does not cover every column (pptxgenjs counts colspans) or
1290
+ * every row is dropped whole: a partial `colW` makes pptxgenjs warn and fall
1291
+ * back anyway, and a partial `rowH` would size some rows from the source and
1292
+ * the rest from the box.
1293
+ */
1294
+ function importedTableTracks(
1295
+ html: string,
1296
+ rows: TableRow[],
1297
+ box: { w: number; h: number },
1298
+ ): { colW?: number[]; rowH?: number[] } {
1299
+ const percentages = (pattern: RegExp) => {
1300
+ const values = [...html.matchAll(pattern)].map((match) =>
1301
+ Number.parseFloat(match[1]),
1302
+ );
1303
+ return values.every((value) => Number.isFinite(value) && value > 0)
1304
+ ? values
1305
+ : [];
1306
+ };
1307
+ const columnCount = (rows[0] ?? []).reduce(
1308
+ (total, cell) => total + (Number(cell.options?.colspan) || 1),
1309
+ 0,
1310
+ );
1311
+ const colW = percentages(
1312
+ /<col\b[^>]*\bstyle=["'][^"']*\bwidth\s*:\s*([\d.]+)%/gi,
1313
+ );
1314
+ const rowH = percentages(
1315
+ /<tr\b[^>]*\bstyle=["'][^"']*\bheight\s*:\s*([\d.]+)%/gi,
1316
+ );
1317
+ return {
1318
+ ...(colW.length === columnCount
1319
+ ? { colW: colW.map((percent) => (percent / 100) * box.w) }
1320
+ : {}),
1321
+ ...(rowH.length === rows.length
1322
+ ? { rowH: rowH.map((percent) => (percent / 100) * box.h) }
1323
+ : {}),
1324
+ };
1325
+ }
1326
+
1327
+ /**
1328
+ * A cell's CSS `padding` as pptxgenjs's `[top, right, bottom, left]` margin.
1329
+ * The library reads any side >= 1 as points rather than inches, so a padding
1330
+ * that large is left to its default instead of exporting an inch as a point.
1331
+ */
1332
+ function importedCellMargin(
1333
+ style: string,
1334
+ dims: SlideDims,
1335
+ ): [number, number, number, number] | undefined {
1336
+ const parts = getStyle(style, "padding")
1337
+ ?.trim()
1338
+ .split(/\s+/)
1339
+ .map((value) => Number.parseFloat(value));
1340
+ if (!parts?.length || parts.length > 4) return undefined;
1341
+ if (parts.some((value) => !Number.isFinite(value) || value < 0))
1342
+ return undefined;
1343
+ const [top, right = top, bottom = top, left = right] = parts;
1344
+ const margin: [number, number, number, number] = [
1345
+ pxToInY(top, dims),
1346
+ pxToIn(right, dims),
1347
+ pxToInY(bottom, dims),
1348
+ pxToIn(left, dims),
1349
+ ];
1350
+ return margin.some((inches) => inches >= 1) ? undefined : margin;
1351
+ }
1352
+
693
1353
  function importedTableRows(html: string, dims: SlideDims): TableRow[] {
694
1354
  return [...html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map(
695
1355
  (rowMatch): TableRow =>
@@ -699,13 +1359,11 @@ function importedTableRows(html: string, dims: SlideDims): TableRow[] {
699
1359
  const cellHtml = cellMatch[2];
700
1360
  const style = getAttribute(attrs, "style") ?? "";
701
1361
  const runs = importedTextRuns(cellHtml, dims);
702
- const fill = getStyle(style, "background(?:-color)?");
703
- const parsedFill = fill ? colorToHex(fill) : undefined;
704
- const border = getStyle(style, "border");
705
- const borderMatch = border?.match(/([\d.]+)px\s+solid\s+(.+)/i);
706
- const parsedBorder = borderMatch
707
- ? colorToHex(borderMatch[2])
708
- : undefined;
1362
+ const parsedFill = cssFillToSolid(
1363
+ getStyle(style, "background(?:-color)?"),
1364
+ );
1365
+ const border = parseCssBorder(getStyle(style, "border"));
1366
+ const parsedBorder = border ? colorToHex(border.color) : undefined;
709
1367
  const colSpan = Number.parseInt(
710
1368
  getAttribute(attrs, "colspan") ?? "",
711
1369
  10,
@@ -714,9 +1372,16 @@ function importedTableRows(html: string, dims: SlideDims): TableRow[] {
714
1372
  getAttribute(attrs, "rowspan") ?? "",
715
1373
  10,
716
1374
  );
717
- const align = getStyle(style, "text-align");
1375
+ // The importer writes the cell's alignment on the paragraph, not the
1376
+ // `<td>`; reading only the cell style exported every imported table
1377
+ // left-aligned regardless of what the source `a:pPr algn` said.
1378
+ const align =
1379
+ getStyle(style, "text-align") ??
1380
+ getStyle(firstParagraphStyle(cellHtml) ?? "", "text-align");
718
1381
  const verticalAlign = getStyle(style, "vertical-align");
1382
+ const margin = importedCellMargin(style, dims);
719
1383
  const options: NonNullable<TableCell["options"]> = {
1384
+ ...(margin ? { margin } : {}),
720
1385
  ...(align === "center" || align === "right" || align === "justify"
721
1386
  ? { align }
722
1387
  : {}),
@@ -735,15 +1400,16 @@ function importedTableRows(html: string, dims: SlideDims): TableRow[] {
735
1400
  },
736
1401
  }
737
1402
  : {}),
738
- ...(parsedBorder && borderMatch
1403
+ ...(parsedBorder && border
739
1404
  ? {
740
1405
  border: {
741
- type: "solid" as const,
1406
+ // pptxgenjs table borders only offer solid or dash, so a
1407
+ // dotted rule maps to the nearest broken stroke.
1408
+ type: border.dashType
1409
+ ? ("dash" as const)
1410
+ : ("solid" as const),
742
1411
  color: parsedBorder.hex,
743
- pt: Math.max(
744
- 0.5,
745
- pxToPt(Number.parseFloat(borderMatch[1]), dims),
746
- ),
1412
+ pt: Math.max(0.5, pxToPt(border.widthPx, dims)),
747
1413
  },
748
1414
  }
749
1415
  : {}),
@@ -769,10 +1435,7 @@ function importedRunOptions(
769
1435
  dims: SlideDims,
770
1436
  ): TextRunElement["options"] {
771
1437
  const fontSizePx = cssPx(style, "font-size");
772
- const fontFamily = getStyle(style, "font-family")
773
- ?.replace(/["']/g, "")
774
- .split(",")[0]
775
- ?.trim();
1438
+ const fontFamily = cssFontFace(style);
776
1439
  const fontWeight = getStyle(style, "font-weight");
777
1440
  const colorValue = getStyle(style, "color");
778
1441
  const parsedColor = colorValue
@@ -826,6 +1489,255 @@ function decodeHtmlText(value: string): string {
826
1489
  .replace(/&#x25cf;/gi, "●");
827
1490
  }
828
1491
 
1492
+ /** Split a CSS function argument list on top-level commas — an rgb/rgba stop carries its own. */
1493
+ function splitTopLevel(value: string): string[] {
1494
+ const parts: string[] = [];
1495
+ let depth = 0;
1496
+ let start = 0;
1497
+ for (let i = 0; i < value.length; i++) {
1498
+ const char = value[i];
1499
+ if (char === "(") depth++;
1500
+ else if (char === ")") depth--;
1501
+ else if (char === "," && depth === 0) {
1502
+ parts.push(value.slice(start, i));
1503
+ start = i + 1;
1504
+ }
1505
+ }
1506
+ parts.push(value.slice(start));
1507
+ return parts.map((part) => part.trim()).filter(Boolean);
1508
+ }
1509
+
1510
+ /** `<a:srgbClr>` carrying the alpha an rgba or 8-digit-hex value declares. */
1511
+ function drawingMlColor(parsed: {
1512
+ hex: string;
1513
+ transparency?: number;
1514
+ }): string {
1515
+ return parsed.transparency
1516
+ ? `<a:srgbClr val="${parsed.hex}"><a:alpha val="${Math.round((100 - parsed.transparency) * 1000)}"/></a:srgbClr>`
1517
+ : `<a:srgbClr val="${parsed.hex}"/>`;
1518
+ }
1519
+
1520
+ /**
1521
+ * The inverse of the importer's `parseGradientFill` (packages/core
1522
+ * `ingestion/pptx.ts`): CSS measures a linear angle clockwise from "up",
1523
+ * OOXML's `<a:lin ang>` clockwise from the positive x-axis, so the two differ
1524
+ * by 90°, and `<a:fillToRect>`'s edge insets collapse to the radial focus.
1525
+ *
1526
+ * Returns `undefined` for a gradient it cannot express (`conic`, an angle unit
1527
+ * other than `deg`, a single stop) so the caller keeps the flattened solid fill
1528
+ * instead of writing a background PowerPoint rejects.
1529
+ */
1530
+ export function cssGradientToDrawingMl(css: string): string | undefined {
1531
+ const match = css.trim().match(/^(linear|radial)-gradient\(([\s\S]*)\)$/i);
1532
+ if (!match) return undefined;
1533
+ const kind = match[1].toLowerCase();
1534
+ const args = splitTopLevel(match[2]);
1535
+
1536
+ // A gradient's first argument is either its configuration (`45deg`,
1537
+ // `to right`, `circle at 20% 30%`) or already the first color stop, and one
1538
+ // thing tells them apart: a stop starts with a color. Recognizing only the
1539
+ // configuration spellings we had handled let every other one — `to right`, a
1540
+ // bare `circle` — fall through to `colorToHex`, which turned a direction
1541
+ // keyword into a white stop the source never had.
1542
+ const config =
1543
+ args.length > 0 && !parseCssColor(splitGradientStop(args[0]).color)
1544
+ ? args[0]
1545
+ : undefined;
1546
+ const stopArgs = config === undefined ? args : args.slice(1);
1547
+ if (stopArgs.length < 2) return undefined;
1548
+
1549
+ let geometry: string;
1550
+ if (kind === "linear") {
1551
+ const cssAngle = linearGradientAngle(config);
1552
+ if (cssAngle === undefined) return undefined;
1553
+ const ang = Math.round(((((cssAngle - 90) % 360) + 360) % 360) * 60000);
1554
+ geometry = `<a:lin ang="${ang}" scaled="0"/>`;
1555
+ } else {
1556
+ const center = radialGradientCenter(config);
1557
+ if (!center) return undefined;
1558
+ const pct = (value: number) => Math.round(value * 1000);
1559
+ geometry = `<a:path path="circle"><a:fillToRect l="${pct(center.x)}" t="${pct(center.y)}" r="${pct(100 - center.x)}" b="${pct(100 - center.y)}"/></a:path>`;
1560
+ }
1561
+
1562
+ const stops: string[] = [];
1563
+ for (const [index, stopArg] of stopArgs.entries()) {
1564
+ const { color, position } = splitGradientStop(stopArg);
1565
+ const parsed = parseCssColor(color);
1566
+ if (!parsed) return undefined;
1567
+ const resolved = position ?? (index / (stopArgs.length - 1)) * 100;
1568
+ const clamped = Math.min(100, Math.max(0, resolved));
1569
+ stops.push(
1570
+ `<a:gs pos="${Math.round(clamped * 1000)}">${drawingMlColor(parsed)}</a:gs>`,
1571
+ );
1572
+ }
1573
+ return `<a:gradFill rotWithShape="1"><a:gsLst>${stops.join("")}</a:gsLst>${geometry}</a:gradFill>`;
1574
+ }
1575
+
1576
+ /** Split a gradient stop into its color token and optional position. */
1577
+ function splitGradientStop(arg: string): { color: string; position?: number } {
1578
+ const match = arg.match(/\s([\d.]+)%$/);
1579
+ return match
1580
+ ? { color: arg.slice(0, match.index).trim(), position: Number(match[1]) }
1581
+ : { color: arg };
1582
+ }
1583
+
1584
+ /**
1585
+ * CSS `to <side-or-corner>` as its angle. A corner's true angle depends on the
1586
+ * box's aspect ratio, but `<a:lin scaled="0">` takes an absolute one, so the
1587
+ * 45° diagonals are the closest fixed stand-in.
1588
+ */
1589
+ const LINEAR_GRADIENT_SIDE_ANGLES: Record<string, number> = {
1590
+ top: 0,
1591
+ "top right": 45,
1592
+ "right top": 45,
1593
+ right: 90,
1594
+ "bottom right": 135,
1595
+ "right bottom": 135,
1596
+ bottom: 180,
1597
+ "bottom left": 225,
1598
+ "left bottom": 225,
1599
+ left: 270,
1600
+ "top left": 315,
1601
+ "left top": 315,
1602
+ };
1603
+
1604
+ /** The CSS angle a `linear-gradient` configuration argument names, or `undefined` when it names one we cannot express. */
1605
+ function linearGradientAngle(config: string | undefined): number | undefined {
1606
+ if (config === undefined) return 180; // CSS defaults to `to bottom`
1607
+ const degrees = config.match(/^(-?[\d.]+)deg$/i)?.[1];
1608
+ if (degrees !== undefined) return Number(degrees);
1609
+ const side = config.match(/^to\s+([a-z]+(?:\s+[a-z]+)?)$/i)?.[1];
1610
+ return side === undefined
1611
+ ? undefined
1612
+ : LINEAR_GRADIENT_SIDE_ANGLES[side.toLowerCase().replace(/\s+/g, " ")];
1613
+ }
1614
+
1615
+ /** CSS position keywords as a percentage along their own axis. */
1616
+ const RADIAL_POSITION_PERCENTS: Record<string, number> = {
1617
+ left: 0,
1618
+ top: 0,
1619
+ center: 50,
1620
+ right: 100,
1621
+ bottom: 100,
1622
+ };
1623
+
1624
+ /** The focus a `radial-gradient` configuration argument names, or `undefined` when it names one we cannot express. */
1625
+ function radialGradientCenter(
1626
+ config: string | undefined,
1627
+ ): { x: number; y: number } | undefined {
1628
+ if (config === undefined) return { x: 50, y: 50 };
1629
+ const at = config.match(/\bat\s+(.+)$/i)?.[1].trim();
1630
+ if (at === undefined) return { x: 50, y: 50 };
1631
+ const axes = at.split(/\s+/).map((axis) => {
1632
+ const percent = axis.match(/^([\d.]+)%$/)?.[1];
1633
+ return percent !== undefined
1634
+ ? Number(percent)
1635
+ : RADIAL_POSITION_PERCENTS[axis.toLowerCase()];
1636
+ });
1637
+ // A lone value positions the x axis and centers the y axis.
1638
+ const [x, y = 50] = axes;
1639
+ if (axes.length > 2 || x === undefined || y === undefined) return undefined;
1640
+ return { x, y };
1641
+ }
1642
+
1643
+ /** `<a:clrScheme>` requires every slot, in this order. */
1644
+ const THEME_COLOR_SLOTS = [
1645
+ "dk1",
1646
+ "lt1",
1647
+ "dk2",
1648
+ "lt2",
1649
+ "accent1",
1650
+ "accent2",
1651
+ "accent3",
1652
+ "accent4",
1653
+ "accent5",
1654
+ "accent6",
1655
+ "hlink",
1656
+ "folHlink",
1657
+ ] as const;
1658
+
1659
+ /** The deck's own palette as a `<a:clrScheme>`, or `undefined` when a slot is missing. */
1660
+ export function themeClrSchemeXml(
1661
+ colorsByName: Record<string, string>,
1662
+ ): string | undefined {
1663
+ const slots: string[] = [];
1664
+ for (const slot of THEME_COLOR_SLOTS) {
1665
+ const color = colorsByName[slot];
1666
+ if (!color) return undefined;
1667
+ slots.push(`<a:${slot}>${drawingMlColor(colorToHex(color))}</a:${slot}>`);
1668
+ }
1669
+ return `<a:clrScheme name="Deck">${slots.join("")}</a:clrScheme>`;
1670
+ }
1671
+
1672
+ function replaceOnce(
1673
+ xml: string,
1674
+ pattern: RegExp,
1675
+ replacement: string,
1676
+ what: string,
1677
+ ): string {
1678
+ if (!pattern.test(xml)) {
1679
+ throw new Error(
1680
+ `[export-pptx] could not rewrite ${what}: pptxgenjs no longer emits the node this patches. Nothing was written rather than shipping a deck that silently lost it.`,
1681
+ );
1682
+ }
1683
+ return xml.replace(pattern, () => replacement);
1684
+ }
1685
+
1686
+ /**
1687
+ * Rewrite the two parts pptxgenjs hardcodes. `ShapeFillProps.type` is only
1688
+ * `'none' | 'solid'` and `makeXmlTheme` templates a fixed Office `clrScheme`,
1689
+ * so a deck's theme palette and its gradient slide backgrounds cannot be
1690
+ * expressed through the API at all; they have to be patched into the finished
1691
+ * package. Shape-level gradients stay flattened — unlike `<p:bg>` there is no
1692
+ * stable node to address per shape, so that one really is the library ceiling.
1693
+ */
1694
+ export async function applyDeckIdentity(
1695
+ buffer: Buffer,
1696
+ args: {
1697
+ themeColors?: Record<string, string>;
1698
+ /** 0-based slide index -> `<a:gradFill>` XML. */
1699
+ slideGradients: Map<number, string>;
1700
+ },
1701
+ ): Promise<Buffer> {
1702
+ const clrScheme = args.themeColors
1703
+ ? themeClrSchemeXml(args.themeColors)
1704
+ : undefined;
1705
+ if (!clrScheme && args.slideGradients.size === 0) return buffer;
1706
+
1707
+ const JSZip = (await import("jszip")).default;
1708
+ const zip = await JSZip.loadAsync(buffer);
1709
+ const rewrite = async (
1710
+ partPath: string,
1711
+ pattern: RegExp,
1712
+ replacement: string,
1713
+ what: string,
1714
+ ) => {
1715
+ const xml = await zip.file(partPath)?.async("string");
1716
+ if (xml === undefined) {
1717
+ throw new Error(`[export-pptx] generated package is missing ${partPath}`);
1718
+ }
1719
+ zip.file(partPath, replaceOnce(xml, pattern, replacement, what));
1720
+ };
1721
+
1722
+ if (clrScheme) {
1723
+ await rewrite(
1724
+ "ppt/theme/theme1.xml",
1725
+ /<a:clrScheme name="[^"]*">[\s\S]*?<\/a:clrScheme>/,
1726
+ clrScheme,
1727
+ "the theme color scheme",
1728
+ );
1729
+ }
1730
+ for (const [slideIndex, gradFill] of args.slideGradients) {
1731
+ await rewrite(
1732
+ `ppt/slides/slide${slideIndex + 1}.xml`,
1733
+ /<p:bg>[\s\S]*?<\/p:bg>/,
1734
+ `<p:bg><p:bgPr>${gradFill}<a:effectLst/></p:bgPr></p:bg>`,
1735
+ `slide ${slideIndex + 1}'s gradient background`,
1736
+ );
1737
+ }
1738
+ return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
1739
+ }
1740
+
829
1741
  /**
830
1742
  * Fetch a URL and return it as a base64 data URI.
831
1743
  *
@@ -899,7 +1811,23 @@ export default defineAction({
899
1811
  )
900
1812
  ? rawAspectRatio
901
1813
  : undefined;
902
- const dims = getAspectRatioDims(aspectRatio);
1814
+ // `<p:sldSz>` is per presentation, so the first slide that declares a
1815
+ // source page size settles it for the whole export; `parseSlideHtml`
1816
+ // re-derives the same value per slide.
1817
+ const slideContents: string[] = slides.map((slide: unknown) =>
1818
+ slide &&
1819
+ typeof slide === "object" &&
1820
+ typeof (slide as { content?: unknown }).content === "string"
1821
+ ? (slide as { content: string }).content
1822
+ : "",
1823
+ );
1824
+ const sourcePage = slideContents
1825
+ .map(sourcePageInches)
1826
+ .find((page) => page !== undefined);
1827
+ const dims: SlideDims = {
1828
+ ...getAspectRatioDims(aspectRatio),
1829
+ ...(sourcePage ? { pptxInches: sourcePage } : {}),
1830
+ };
903
1831
 
904
1832
  const PptxGenJS = (await import("pptxgenjs")).default;
905
1833
  const pptx = new PptxGenJS();
@@ -919,20 +1847,46 @@ export default defineAction({
919
1847
  }
920
1848
  pptx.author = "Agent Native Slides";
921
1849
  pptx.title = row.title;
1850
+ // The font scheme is the one half of the theme pptxgenjs does expose;
1851
+ // without it a themed deck re-imports as Calibri Light / Calibri.
1852
+ const [headFontFace, bodyFontFace] = deckThemeFonts(deckData);
1853
+ if (headFontFace) pptx.theme = { headFontFace, bodyFontFace };
1854
+
1855
+ const slideGradients = new Map<number, string>();
1856
+ let backgroundGradientsFlattened = 0;
922
1857
 
923
1858
  for (const [slideIndex, slide] of slides.entries()) {
924
1859
  const pptxSlide = pptx.addSlide();
925
- const slideContent =
926
- slide && typeof slide === "object" && typeof slide.content === "string"
927
- ? slide.content
928
- : "";
929
- const { texts, images, shapes, tables, grid, bgColor, bgTransparency } =
930
- parseSlideHtml(slideContent, aspectRatio, slideIndex + 1);
931
-
1860
+ const slideContent = slideContents[slideIndex];
1861
+ const {
1862
+ texts,
1863
+ images,
1864
+ shapes,
1865
+ tables,
1866
+ grid,
1867
+ bgColor,
1868
+ bgTransparency,
1869
+ bgGradient,
1870
+ } = parseSlideHtml(slideContent, aspectRatio, slideIndex + 1);
1871
+
1872
+ // The solid fill stays the pre-rewrite value: `applyDeckIdentity`
1873
+ // replaces the whole `<p:bg>` when the gradient survives, and this is
1874
+ // what the deck falls back to when it does not.
932
1875
  pptxSlide.background = {
933
1876
  color: bgColor,
934
1877
  ...(bgTransparency != null ? { transparency: bgTransparency } : {}),
935
1878
  };
1879
+ if (bgGradient) {
1880
+ const gradFill = cssGradientToDrawingMl(bgGradient);
1881
+ if (gradFill) {
1882
+ slideGradients.set(slideIndex, gradFill);
1883
+ } else {
1884
+ backgroundGradientsFlattened++;
1885
+ console.warn(
1886
+ `[export-pptx] slide ${slideIndex + 1} background "${bgGradient}" has no DrawingML equivalent; exported as flat #${bgColor}`,
1887
+ );
1888
+ }
1889
+ }
936
1890
 
937
1891
  if (grid) {
938
1892
  const gridWidth = pxToIn(grid.stepX, dims);
@@ -999,20 +1953,21 @@ export default defineAction({
999
1953
  y: t.y,
1000
1954
  w: t.w,
1001
1955
  h: t.h,
1002
- fontSize: t.fontSize,
1003
- fontFace: t.fontFace,
1004
1956
  color: t.color,
1005
1957
  bold: t.bold,
1006
1958
  align: t.align || "left",
1007
1959
  valign: "top" as const,
1008
1960
  wrap: true,
1961
+ ...(t.fontSize != null ? { fontSize: t.fontSize } : {}),
1962
+ ...(t.fontFace != null ? { fontFace: t.fontFace } : {}),
1009
1963
  ...(t.transparency != null ? { transparency: t.transparency } : {}),
1010
1964
  ...(t.letterSpacing != null
1011
1965
  ? { charSpacing: t.letterSpacing }
1012
1966
  : {}),
1013
- ...(t.lineSpacing != null
1014
- ? { lineSpacingMultiple: t.lineSpacing / t.fontSize }
1967
+ ...(t.lineSpacingMultiple != null
1968
+ ? { lineSpacingMultiple: t.lineSpacingMultiple }
1015
1969
  : {}),
1970
+ ...(t.rotate != null ? { rotate: t.rotate } : {}),
1016
1971
  };
1017
1972
  if (t.runs?.length) {
1018
1973
  pptxSlide.addText(t.runs, options);
@@ -1029,28 +1984,25 @@ export default defineAction({
1029
1984
  y: img.y,
1030
1985
  w: img.w,
1031
1986
  h: img.h,
1987
+ ...(img.rotate != null ? { rotate: img.rotate } : {}),
1032
1988
  });
1033
1989
  }
1034
1990
  } else if (object.kind === "table") {
1035
- const table = object.value;
1036
- pptxSlide.addTable(table.rows, {
1037
- x: table.x,
1038
- y: table.y,
1039
- w: table.w,
1040
- h: table.h,
1041
- autoPage: false,
1042
- border: { type: "solid", color: "FFFFFF", pt: 0.5 },
1043
- margin: 0.04,
1044
- });
1991
+ pptxSlide.addTable(object.value.rows, tableOptions(object.value));
1045
1992
  } else {
1046
1993
  const shape = object.value;
1047
1994
  pptxSlide.addShape(
1048
- shape.rounded ? pptx.ShapeType.roundRect : pptx.ShapeType.rect,
1995
+ resolveShapeType(pptx.ShapeType, shape.shapeType),
1049
1996
  {
1050
1997
  x: shape.x,
1051
1998
  y: shape.y,
1052
1999
  w: shape.w,
1053
2000
  h: shape.h,
2001
+ ...(shape.rectRadius != null
2002
+ ? { rectRadius: shape.rectRadius }
2003
+ : {}),
2004
+ ...(shape.rotate != null ? { rotate: shape.rotate } : {}),
2005
+ ...(shape.points ? { points: shape.points } : {}),
1054
2006
  ...(shape.fill
1055
2007
  ? {
1056
2008
  fill: {
@@ -1066,6 +2018,9 @@ export default defineAction({
1066
2018
  line: {
1067
2019
  color: shape.lineColor,
1068
2020
  width: shape.lineWidth ?? 1,
2021
+ ...(shape.lineDashType
2022
+ ? { dashType: shape.lineDashType }
2023
+ : {}),
1069
2024
  ...(shape.lineTransparency != null
1070
2025
  ? { transparency: shape.lineTransparency }
1071
2026
  : {}),
@@ -1088,7 +2043,10 @@ export default defineAction({
1088
2043
  }
1089
2044
  }
1090
2045
 
1091
- const buffer = (await pptx.write({ outputType: "nodebuffer" })) as Buffer;
2046
+ const buffer = await applyDeckIdentity(
2047
+ (await pptx.write({ outputType: "nodebuffer" })) as Buffer,
2048
+ { themeColors: deckThemeColors(deckData), slideGradients },
2049
+ );
1092
2050
  const filename = safeGeneratedFilename(row.title, ".pptx");
1093
2051
 
1094
2052
  // Disk write is only useful when the same process can later serve the
@@ -1106,10 +2064,52 @@ export default defineAction({
1106
2064
  fs.writeFileSync(filePath, buffer);
1107
2065
  }
1108
2066
 
1109
- return { buffer, filePath, filename, slideCount: slides.length };
2067
+ return {
2068
+ buffer,
2069
+ filePath,
2070
+ filename,
2071
+ slideCount: slides.length,
2072
+ ...(backgroundGradientsFlattened > 0
2073
+ ? { backgroundGradientsFlattened }
2074
+ : {}),
2075
+ };
1110
2076
  },
1111
2077
  });
1112
2078
 
2079
+ /**
2080
+ * The palette an imported deck kept from its source `<a:clrScheme>`, in the
2081
+ * shape `import-pptx` already returns. Absent on decks imported before the
2082
+ * importer persisted it, and on decks the editor authored — both export with
2083
+ * pptxgenjs's Office default, which is the honest answer for a deck that has
2084
+ * no theme of its own.
2085
+ */
2086
+ function deckThemeColors(
2087
+ deckData: unknown,
2088
+ ): Record<string, string> | undefined {
2089
+ const colorsByName = (
2090
+ deckData as { theme?: { colorsByName?: unknown } } | null
2091
+ )?.theme?.colorsByName;
2092
+ if (!colorsByName || typeof colorsByName !== "object") return undefined;
2093
+ const entries = Object.entries(colorsByName).filter(
2094
+ (entry): entry is [string, string] => typeof entry[1] === "string",
2095
+ );
2096
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
2097
+ }
2098
+
2099
+ /** `[majorFont, minorFont]` from the deck's theme, in `parseTheme`'s own order. */
2100
+ function deckThemeFonts(
2101
+ deckData: unknown,
2102
+ ): [string | undefined, string | undefined] {
2103
+ const fonts = (deckData as { theme?: { fonts?: unknown } } | null)?.theme
2104
+ ?.fonts;
2105
+ if (!Array.isArray(fonts)) return [undefined, undefined];
2106
+ const face = (index: number) =>
2107
+ typeof fonts[index] === "string" && fonts[index]
2108
+ ? (fonts[index] as string)
2109
+ : undefined;
2110
+ return [face(0), face(1) ?? face(0)];
2111
+ }
2112
+
1113
2113
  function isServerless(): boolean {
1114
2114
  return Boolean(
1115
2115
  process.env.NETLIFY ||