@agent-native/core 0.157.8 → 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 (154) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/purge-first-party-analytics-postgres.ts +26 -8
  3. package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +261 -0
  4. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
  5. package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +75 -31
  6. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +4 -1
  7. package/corpus/templates/analytics/server/plugins/db.ts +139 -0
  8. package/corpus/templates/slides/AGENTS.md +14 -10
  9. package/corpus/templates/slides/actions/export-pptx.ts +1311 -121
  10. package/corpus/templates/slides/actions/import-file.ts +28 -1
  11. package/corpus/templates/slides/actions/import-pptx.ts +69 -42
  12. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
  13. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
  14. package/corpus/templates/slides/app/global.css +19 -1
  15. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
  16. package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
  17. package/corpus/templates/slides/app/lib/export-pptx-client.ts +601 -30
  18. package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
  19. package/corpus/templates/slides/server/handlers/import/html-converter.ts +1240 -22
  20. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +329 -64
  21. package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
  22. package/corpus/templates/slides/server/lib/source-import.ts +10 -1
  23. package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
  24. package/dist/agent/harness/migrations.d.ts +4 -0
  25. package/dist/agent/harness/migrations.js +60 -0
  26. package/dist/agent/production-agent.d.ts +2 -0
  27. package/dist/agent/production-agent.js +15 -0
  28. package/dist/agent/run-loop-with-resume.d.ts +1 -0
  29. package/dist/agent/run-loop-with-resume.js +44 -12
  30. package/dist/agent/run-migrations.d.ts +8 -0
  31. package/dist/agent/run-migrations.js +61 -0
  32. package/dist/automations/service.d.ts +6 -0
  33. package/dist/automations/service.js +35 -0
  34. package/dist/chat-threads/migrations.d.ts +1 -0
  35. package/dist/chat-threads/migrations.js +1 -0
  36. package/dist/chat-threads/schema-migrations.d.ts +8 -0
  37. package/dist/chat-threads/schema-migrations.js +60 -0
  38. package/dist/cli/code-agent-connector.d.ts +4 -1
  39. package/dist/cli/code-agent-connector.js +154 -23
  40. package/dist/cli/code-agent-executor.js +255 -13
  41. package/dist/cli/code-agent-runs.d.ts +1 -0
  42. package/dist/cli/code-agent-runs.js +2 -1
  43. package/dist/cli/portal-workspace.d.ts +49 -0
  44. package/dist/cli/portal-workspace.js +320 -0
  45. package/dist/client/AssistantChat.d.ts +1 -1
  46. package/dist/client/AssistantChat.js +43 -61
  47. package/dist/client/ConnectBuilderCard.js +33 -5
  48. package/dist/client/agent-chat-adapter.js +4 -0
  49. package/dist/client/analytics-platform.d.ts +2 -0
  50. package/dist/client/analytics-platform.js +12 -0
  51. package/dist/client/analytics.d.ts +3 -0
  52. package/dist/client/analytics.js +10 -1
  53. package/dist/client/chat/index.d.ts +1 -0
  54. package/dist/client/chat/index.js +1 -0
  55. package/dist/client/chat/message-components.d.ts +2 -0
  56. package/dist/client/chat/message-components.js +11 -1
  57. package/dist/client/chat/run-recovery.d.ts +2 -1
  58. package/dist/client/chat/run-recovery.js +2 -2
  59. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  60. package/dist/client/chat-first/apps-rail.js +11 -11
  61. package/dist/client/chat-first/surface-tabs.d.ts +1 -1
  62. package/dist/client/chat-first/surface-tabs.js +38 -36
  63. package/dist/client/chat-first/types.d.ts +5 -0
  64. package/dist/client/chat-first.d.ts +1 -1
  65. package/dist/client/chat-first.js +4 -4
  66. package/dist/client/desktop-local-code-change.d.ts +10 -0
  67. package/dist/client/desktop-local-code-change.js +16 -0
  68. package/dist/client/index.d.ts +1 -0
  69. package/dist/client/index.js +1 -0
  70. package/dist/client/observability/ThumbsFeedback.js +53 -26
  71. package/dist/client/observability/useObservability.d.ts +1 -1
  72. package/dist/client/observability/useObservability.js +6 -3
  73. package/dist/client/terminal/AgentTerminal.d.ts +9 -1
  74. package/dist/client/terminal/AgentTerminal.js +38 -3
  75. package/dist/client/terminal/index.d.ts +1 -1
  76. package/dist/client/terminal/index.js +1 -1
  77. package/dist/client/track.js +9 -1
  78. package/dist/client/use-action.js +3 -0
  79. package/dist/collab/awareness.d.ts +2 -2
  80. package/dist/deploy/build.js +1 -1
  81. package/dist/feature-flags/a2a-action-route.js +4 -2
  82. package/dist/feature-flags/permissions.d.ts +2 -0
  83. package/dist/feature-flags/permissions.js +6 -1
  84. package/dist/ingestion/pptx.d.ts +92 -1
  85. package/dist/ingestion/pptx.js +1180 -115
  86. package/dist/integrations/index.d.ts +3 -3
  87. package/dist/integrations/index.js +2 -2
  88. package/dist/integrations/plugin.js +95 -6
  89. package/dist/integrations/remote-commands-store.d.ts +5 -0
  90. package/dist/integrations/remote-commands-store.js +63 -22
  91. package/dist/integrations/remote-devices-store.d.ts +2 -1
  92. package/dist/integrations/remote-devices-store.js +46 -0
  93. package/dist/integrations/remote-types.d.ts +20 -0
  94. package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
  95. package/dist/jobs/actions/list-recurring-jobs.js +5 -1
  96. package/dist/jobs/frontmatter.d.ts +16 -0
  97. package/dist/jobs/frontmatter.js +53 -0
  98. package/dist/jobs/remote-execution.d.ts +42 -0
  99. package/dist/jobs/remote-execution.js +287 -0
  100. package/dist/jobs/scheduler.js +102 -3
  101. package/dist/jobs/tools.js +50 -3
  102. package/dist/localization/core-messages/ar-SA.js +4 -0
  103. package/dist/localization/core-messages/de-DE.js +4 -0
  104. package/dist/localization/core-messages/en-US.d.ts +4 -0
  105. package/dist/localization/core-messages/en-US.js +4 -0
  106. package/dist/localization/core-messages/es-ES.js +4 -0
  107. package/dist/localization/core-messages/fr-FR.js +4 -0
  108. package/dist/localization/core-messages/hi-IN.js +4 -0
  109. package/dist/localization/core-messages/ja-JP.js +4 -0
  110. package/dist/localization/core-messages/ko-KR.js +4 -0
  111. package/dist/localization/core-messages/pt-BR.js +4 -0
  112. package/dist/localization/core-messages/zh-CN.js +4 -0
  113. package/dist/localization/core-messages/zh-TW.js +4 -0
  114. package/dist/localization/core-messages.d.ts +4 -0
  115. package/dist/notifications/routes.d.ts +3 -3
  116. package/dist/observability/feedback.js +40 -16
  117. package/dist/observability/routes.d.ts +6 -6
  118. package/dist/observability/routes.js +15 -3
  119. package/dist/observability/store.js +2 -0
  120. package/dist/observability/traces.js +5 -1
  121. package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
  122. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  123. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  124. package/dist/resources/handlers.d.ts +1 -1
  125. package/dist/secrets/routes.d.ts +9 -9
  126. package/dist/server/action-routes.d.ts +7 -5
  127. package/dist/server/action-routes.js +13 -6
  128. package/dist/server/agent-chat-plugin.js +6 -0
  129. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  130. package/dist/server/agent-run-context.d.ts +1 -0
  131. package/dist/server/agent-run-context.js +9 -0
  132. package/dist/server/better-auth-instance.js +4 -0
  133. package/dist/server/core-routes-plugin.js +8 -1
  134. package/dist/server/realtime-voice.js +2 -1
  135. package/dist/server/release-migrations.js +16 -0
  136. package/dist/server/request-context.d.ts +2 -0
  137. package/dist/server/transcribe-voice.d.ts +1 -1
  138. package/dist/shared/analytics-platform.d.ts +6 -0
  139. package/dist/shared/analytics-platform.js +14 -0
  140. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  141. package/dist/shared/mcp-embed-headers.js +1 -1
  142. package/dist/styles/agent-native.css +83 -103
  143. package/dist/terminal/cli-registry.js +5 -0
  144. package/dist/tracking/registry.js +9 -1
  145. package/dist/triggers/actions/list-automations.d.ts +3 -0
  146. package/dist/triggers/actions/list-automations.js +3 -0
  147. package/dist/triggers/actions/manage-automation.d.ts +9 -0
  148. package/dist/triggers/actions/manage-automation.js +14 -2
  149. package/dist/triggers/actions.js +82 -7
  150. package/dist/usage/migrations.d.ts +4 -0
  151. package/dist/usage/migrations.js +45 -0
  152. package/docs/content/template-content-developers.mdx +48 -0
  153. package/docs/content/toolkit-ui.mdx +57 -0
  154. package/package.json +3 -3
@@ -56,6 +56,9 @@ function waitForImageToSettle(image: HTMLImageElement): Promise<void> {
56
56
  if (decodeStarted) return;
57
57
  decodeStarted = true;
58
58
  if (!image.complete || image.naturalWidth <= 0) {
59
+ console.warn(
60
+ `[export-pptx] image could not be loaded for export: ${image.src}`,
61
+ );
59
62
  finish();
60
63
  return;
61
64
  }
@@ -141,9 +144,12 @@ const NOTES_MASTER_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?
141
144
  const NOTES_MASTER_RELS_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
142
145
  <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/></Relationships>`;
143
146
 
147
+ const EMU_PER_INCH = 914_400;
148
+
144
149
  export async function addSpeakerNotesToPptxBlob(
145
150
  blob: Blob,
146
151
  slides: PptxExportSlide[],
152
+ pptxInches: { w: number; h: number },
147
153
  ): Promise<Blob> {
148
154
  const hasNotes = slides.some((slide) => slide.notes?.trim());
149
155
  if (!hasNotes) return blob;
@@ -194,9 +200,14 @@ export async function addSpeakerNotesToPptxBlob(
194
200
  }
195
201
 
196
202
  if (!presentationXml.includes("<p:notesSz")) {
203
+ // The notes page is a portrait rotation of the slide, so its cx/cy swap
204
+ // the slide's own width/height (matches PowerPoint's own notesMaster
205
+ // output, e.g. a 13.33x7.5in 16:9 slide gets a 7.5x13.33in notes page).
206
+ const notesCx = Math.round(pptxInches.h * EMU_PER_INCH);
207
+ const notesCy = Math.round(pptxInches.w * EMU_PER_INCH);
197
208
  presentationXml = presentationXml.replace(
198
209
  "<p:defaultTextStyle>",
199
- '<p:notesSz cx="6858000" cy="12192000"/><p:defaultTextStyle>',
210
+ `<p:notesSz cx="${notesCx}" cy="${notesCy}"/><p:defaultTextStyle>`,
200
211
  );
201
212
  }
202
213
 
@@ -517,6 +528,20 @@ function resetAutofitTransforms(root: HTMLElement) {
517
528
  }
518
529
  }
519
530
 
531
+ /**
532
+ * `white-space: nowrap` does not only stop wrapping — it also switches the
533
+ * element from preserving whitespace to collapsing it. dom-to-pptx trims each
534
+ * inline run it extracts under a collapsing mode, so an imported PPTX run
535
+ * boundary that carries the only space between two words ("IMAGE " +
536
+ * "COMPOSITION") exports as one word, and a `<a:br/>`'s newline-only run
537
+ * disappears entirely. `pre` suppresses wrapping without giving that up.
538
+ */
539
+ function noWrapWhiteSpace(element: HTMLElement) {
540
+ return window.getComputedStyle(element).whiteSpace.startsWith("pre")
541
+ ? "pre"
542
+ : "nowrap";
543
+ }
544
+
520
545
  function restoreTextGeometry(
521
546
  clone: HTMLElement,
522
547
  sourceRect: DOMRect,
@@ -550,7 +575,9 @@ function restoreTextGeometry(
550
575
 
551
576
  if (record.position === "static" || record.position === "relative") {
552
577
  element.dataset.exportTextGeometry = "true";
553
- if (record.singleLine) element.style.whiteSpace = "nowrap";
578
+ if (record.singleLine) {
579
+ element.style.whiteSpace = noWrapWhiteSpace(element);
580
+ }
554
581
  element.style.left = `${translateX.toFixed(3)}px`;
555
582
  element.style.position = "relative";
556
583
  element.style.top = `${translateY.toFixed(3)}px`;
@@ -566,7 +593,9 @@ function restoreTextGeometry(
566
593
  }
567
594
 
568
595
  element.dataset.exportTextGeometry = "true";
569
- if (record.singleLine) element.style.whiteSpace = "nowrap";
596
+ if (record.singleLine) {
597
+ element.style.whiteSpace = noWrapWhiteSpace(element);
598
+ }
570
599
  element.style.boxSizing = "border-box";
571
600
  element.style.bottom = "auto";
572
601
  element.style.display = "block";
@@ -618,20 +647,37 @@ function restoreImageGeometry(
618
647
  element.style.maxWidth = "none";
619
648
  element.style.width = `${Math.max(1, desiredWidth)}px`;
620
649
 
650
+ const currentRect = element.getBoundingClientRect();
651
+ const deltaX = desiredLeft - (currentRect.left - cloneRect.left);
652
+ const deltaY = desiredTop - (currentRect.top - cloneRect.top);
653
+
621
654
  if (record.position === "absolute" || record.position === "fixed") {
655
+ // A cropped imported image is absolutely positioned inside its own
656
+ // absolutely positioned wrapper, so its containing block is that
657
+ // wrapper — not the slide root these coordinates are measured against.
658
+ // Assigning slide-space left/top there adds the wrapper's offset a
659
+ // second time and doubles the position (a tile at x=313.8px exported at
660
+ // 627.6px, off the canvas). Shift the element's own offsets by the
661
+ // measured delta instead, which holds for any containing block.
662
+ const style = window.getComputedStyle(element);
663
+ const cssLeft = Number.parseFloat(style.left);
664
+ const cssTop = Number.parseFloat(style.top);
622
665
  element.style.bottom = "auto";
623
- element.style.left = `${desiredLeft.toFixed(3)}px`;
666
+ element.style.left = `${(Number.isFinite(cssLeft)
667
+ ? cssLeft + deltaX
668
+ : desiredLeft
669
+ ).toFixed(3)}px`;
624
670
  element.style.position = "absolute";
625
671
  element.style.right = "auto";
626
- element.style.top = `${desiredTop.toFixed(3)}px`;
672
+ element.style.top = `${(Number.isFinite(cssTop)
673
+ ? cssTop + deltaY
674
+ : desiredTop
675
+ ).toFixed(3)}px`;
627
676
  element.style.transform = "none";
628
677
  continue;
629
678
  }
630
679
 
631
- const currentRect = element.getBoundingClientRect();
632
- const currentLeft = currentRect.left - cloneRect.left;
633
- const currentTop = currentRect.top - cloneRect.top;
634
- element.style.transform = `translate(${(desiredLeft - currentLeft).toFixed(3)}px, ${(desiredTop - currentTop).toFixed(3)}px)`;
680
+ element.style.transform = `translate(${deltaX.toFixed(3)}px, ${deltaY.toFixed(3)}px)`;
635
681
  }
636
682
  }
637
683
 
@@ -651,7 +697,7 @@ function normalizeSingleLineText(
651
697
  element.dataset.exportSingleLineText = "true";
652
698
  if (element.dataset.exportTextGeometry === "true") continue;
653
699
  element.style.boxSizing = "border-box";
654
- element.style.whiteSpace = "nowrap";
700
+ element.style.whiteSpace = noWrapWhiteSpace(element);
655
701
  if (record.heading) {
656
702
  element.style.maxWidth = "none";
657
703
  element.style.width = `${Math.max(1, Math.ceil(cloneRect.right - rect.left))}px`;
@@ -667,13 +713,36 @@ function normalizeSingleLineText(
667
713
  }
668
714
  }
669
715
 
716
+ const CSS_PX_PER_INCH = 96;
717
+
718
+ /**
719
+ * dom-to-pptx fits the rendered clone into the requested slide size and
720
+ * scales every measurement it takes by this same factor (dist/dom-to-pptx.mjs
721
+ * `processSlide`). The bullet indent patched in afterward must match that
722
+ * scale or it drifts off the deck's actual aspect ratio.
723
+ */
724
+ export function pptxExportScale(dims: {
725
+ width: number;
726
+ height: number;
727
+ pptxInches: { w: number; h: number };
728
+ }) {
729
+ return Math.min(
730
+ dims.pptxInches.w / (dims.width / CSS_PX_PER_INCH),
731
+ dims.pptxInches.h / (dims.height / CSS_PX_PER_INCH),
732
+ );
733
+ }
734
+
670
735
  /**
671
736
  * CSS markers live outside the LI box, so dom-to-pptx cannot infer the gap
672
737
  * between a bullet and its text from getBoundingClientRect alone. Add that
673
738
  * source-visible gap only on the export clone; the source DOM stays editable.
674
739
  */
675
- function normalizeListsForPptx(root: HTMLElement) {
740
+ function normalizeListsForPptx(
741
+ root: HTMLElement,
742
+ dims: { width: number; height: number; pptxInches: { w: number; h: number } },
743
+ ) {
676
744
  const bulletIndents: number[] = [];
745
+ const scale = pptxExportScale(dims);
677
746
 
678
747
  for (const list of root.querySelectorAll<HTMLElement>("ul,ol")) {
679
748
  const listStyle = window.getComputedStyle(list);
@@ -711,12 +780,37 @@ function normalizeListsForPptx(root: HTMLElement) {
711
780
  if (!listRect.width || !itemRect.width) continue;
712
781
 
713
782
  const visualIndentPx = itemRect.left - listRect.left;
714
- bulletIndents.push(Math.max(0, (visualIndentPx - paddingLeft) * 0.75));
783
+ bulletIndents.push(
784
+ Math.max(0, (visualIndentPx - paddingLeft) * 0.75 * scale),
785
+ );
715
786
  }
716
787
 
717
788
  return bulletIndents;
718
789
  }
719
790
 
791
+ /**
792
+ * Imported PPTX paragraphs render their bullet as a decorative
793
+ * `aria-hidden` marker span separated from the text only by CSS
794
+ * margin-right (see server html-converter). dom-to-pptx extracts plain text
795
+ * per DOM node and has no notion of margin, so without a real space
796
+ * character the marker glyph and the first word run together in the
797
+ * exported file (e.g. "•PLG-first approach"). Insert one.
798
+ */
799
+ function ensureBulletMarkerSpacing(root: HTMLElement) {
800
+ for (const marker of root.querySelectorAll<HTMLElement>(
801
+ 'p[data-pptx-paragraph] > span[aria-hidden="true"]:first-child',
802
+ )) {
803
+ const next = marker.nextSibling;
804
+ if (
805
+ next?.nodeType === Node.TEXT_NODE &&
806
+ /^\s/.test(next.textContent ?? "")
807
+ ) {
808
+ continue;
809
+ }
810
+ marker.after(document.createTextNode(" "));
811
+ }
812
+ }
813
+
720
814
  const EMU_PER_POINT = 12_700;
721
815
 
722
816
  /**
@@ -825,24 +919,61 @@ export async function patchBulletIndentsInPptxBlob(
825
919
  });
826
920
  }
827
921
 
922
+ /** Placement on the page; inside a standalone raster it is drawn a second time. */
923
+ const SVG_PLACEMENT_PROPERTIES = [
924
+ "bottom",
925
+ "left",
926
+ "position",
927
+ "right",
928
+ "top",
929
+ "transform",
930
+ ];
931
+
828
932
  function svgDataUrl(svg: SVGSVGElement) {
829
933
  const copy = svg.cloneNode(true) as SVGSVGElement;
830
934
  if (!copy.getAttribute("xmlns")) {
831
935
  copy.setAttribute("xmlns", "http://www.w3.org/2000/svg");
832
936
  }
937
+ // The rotation belongs to the shape's slot on the slide and is re-applied to
938
+ // the <img> that replaces it. Serialized into the SVG's own viewport it
939
+ // rotates the drawing a second time and pushes it off frame: infog1's arrows
940
+ // came back as slivers in the corner of an otherwise empty 810px bitmap.
941
+ for (const property of SVG_PLACEMENT_PROPERTIES) {
942
+ copy.style.removeProperty(property);
943
+ }
833
944
  const serialized = new XMLSerializer().serializeToString(copy);
834
945
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`;
835
946
  }
836
947
 
948
+ /**
949
+ * Whether a rasterized shape came back with nothing painted in it —
950
+ * `undefined` when the canvas cannot be read at all, which is a third outcome
951
+ * and not the same as "it has content". A fully transparent result is the
952
+ * shape silently disappearing from the deck, so it is reported, not returned
953
+ * as a successful render. Fully *opaque* pixels are left alone: a flat white
954
+ * rectangle is a legitimate shape.
955
+ */
956
+ export function blankRasterResult(
957
+ data: Uint8ClampedArray | undefined,
958
+ ): boolean | undefined {
959
+ if (!data || data.length === 0) return undefined;
960
+ for (let i = 3; i < data.length; i += 4) {
961
+ if (data[i] !== 0) return false;
962
+ }
963
+ return true;
964
+ }
965
+
837
966
  async function rasterizeSvgElement(
838
967
  svg: SVGSVGElement,
839
968
  width: number,
840
969
  height: number,
841
- ) {
970
+ ): Promise<{ dataUrl: string; blank: boolean | undefined }> {
842
971
  const fallback = svgDataUrl(svg);
843
972
  const canvas = document.createElement("canvas");
844
973
  const ctx = canvas.getContext("2d");
845
- if (!ctx || typeof Image === "undefined") return fallback;
974
+ if (!ctx || typeof Image === "undefined") {
975
+ return { dataUrl: fallback, blank: undefined };
976
+ }
846
977
 
847
978
  const scale = Math.max(2, window.devicePixelRatio || 1);
848
979
  canvas.width = Math.max(1, Math.ceil(width * scale));
@@ -858,24 +989,328 @@ async function rasterizeSvgElement(
858
989
  try {
859
990
  await loaded;
860
991
  ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
861
- return canvas.toDataURL("image/png");
992
+ let pixels: Uint8ClampedArray | undefined;
993
+ try {
994
+ pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
995
+ } catch {
996
+ // A tainted canvas cannot be inspected. "Unreadable" is not "rendered",
997
+ // so say so rather than letting it pass as a verified shape.
998
+ console.warn(
999
+ "[export-pptx] rasterized shape could not be inspected; it may be missing from the export",
1000
+ );
1001
+ pixels = undefined;
1002
+ }
1003
+ return {
1004
+ dataUrl: canvas.toDataURL("image/png"),
1005
+ blank: blankRasterResult(pixels),
1006
+ };
862
1007
  } catch {
863
- return fallback;
1008
+ return { dataUrl: fallback, blank: undefined };
864
1009
  }
865
1010
  }
866
1011
 
867
- async function replaceInlineSvgsWithImages(root: HTMLElement) {
1012
+ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
1013
+
1014
+ /**
1015
+ * The `d` of a computed `clip-path`, in the element's own pixels. `path()` is
1016
+ * already one; `polygon()` — what the importer draws every preset geometry
1017
+ * with — becomes the equivalent closed path so both take the same route out.
1018
+ */
1019
+ function clipPathOutline(
1020
+ clipPath: string,
1021
+ width: number,
1022
+ height: number,
1023
+ ): string | undefined {
1024
+ const path = clipPath.match(/^path\(\s*["']?([^"')]*)["']?\s*\)$/i)?.[1];
1025
+ if (path) return path;
1026
+ const polygon = clipPath.match(/^polygon\(([^)]*)\)$/i)?.[1];
1027
+ if (!polygon) return undefined;
1028
+ const points = polygon.split(",").map((pair) => {
1029
+ const [rawX, rawY] = pair.trim().split(/\s+/);
1030
+ const x = clipPathPixels(rawX, width);
1031
+ const y = clipPathPixels(rawY, height);
1032
+ return x == null || y == null ? null : `${x} ${y}`;
1033
+ });
1034
+ if (points.length < 3 || points.some((point) => point == null)) {
1035
+ return undefined;
1036
+ }
1037
+ return `M${points.join(" L")} Z`;
1038
+ }
1039
+
1040
+ let gradientId = 0;
1041
+
1042
+ const GRADIENT_SIDE_ANGLES: Record<string, number> = {
1043
+ "to top": 0,
1044
+ "to right": 90,
1045
+ "to bottom": 180,
1046
+ "to left": 270,
1047
+ };
1048
+
1049
+ /** Split a CSS argument list on top-level commas so functional color values stay one stop. */
1050
+ function splitTopLevel(list: string): string[] | undefined {
1051
+ const parts: string[] = [];
1052
+ let depth = 0;
1053
+ let current = "";
1054
+ for (const character of list) {
1055
+ if (character === "(") depth++;
1056
+ else if (character === ")") depth--;
1057
+ if (depth < 0) return undefined;
1058
+ if (character === "," && depth === 0) {
1059
+ parts.push(current.trim());
1060
+ current = "";
1061
+ continue;
1062
+ }
1063
+ current += character;
1064
+ }
1065
+ if (depth !== 0) return undefined;
1066
+ parts.push(current.trim());
1067
+ return parts;
1068
+ }
1069
+
1070
+ const round3 = (value: number) => `${Math.round(value * 1000) / 1000}`;
1071
+
1072
+ /** `#RRGGBBAA` split into the two attributes SVG stops take; anything else is already a colour a stop accepts. */
1073
+ function stopPaint(color: string): { color: string; opacity?: string } {
1074
+ const hex8 = color.match(/^#([\da-f]{6})([\da-f]{2})$/i);
1075
+ if (!hex8) return { color };
1076
+ return {
1077
+ color: `#${hex8[1]}`,
1078
+ opacity: round3(Number.parseInt(hex8[2], 16) / 255),
1079
+ };
1080
+ }
1081
+
1082
+ function appendGradientStops(
1083
+ gradient: SVGElement,
1084
+ stopParts: string[],
1085
+ ): SVGElement {
1086
+ stopParts.forEach((part, index) => {
1087
+ const position = part.match(/\s(-?[\d.]+)%$/)?.[1];
1088
+ const raw = position == null ? part : part.slice(0, -position.length - 1);
1089
+ const paint = stopPaint(raw.trim());
1090
+ const stop = document.createElementNS(SVG_NAMESPACE, "stop");
1091
+ stop.setAttribute(
1092
+ "offset",
1093
+ position != null
1094
+ ? `${position}%`
1095
+ : `${(index / (stopParts.length - 1)) * 100}%`,
1096
+ );
1097
+ stop.setAttribute("stop-color", paint.color);
1098
+ if (paint.opacity) stop.setAttribute("stop-opacity", paint.opacity);
1099
+ gradient.appendChild(stop);
1100
+ });
1101
+ return gradient;
1102
+ }
1103
+
1104
+ /**
1105
+ * A CSS gradient as an SVG paint server for the traced outline. Without this
1106
+ * the outline is filled with `background-color`, which a gradient-filled shape
1107
+ * leaves the paint fully transparent: canyon's layout draws 20 `gradFill` freeforms
1108
+ * behind every slide, and each one rasterized to a fully transparent PNG that
1109
+ * the export then shipped as a successful render.
1110
+ *
1111
+ * `userSpaceOnUse` rather than the default bounding box because both the
1112
+ * gradient line's length and the radial's farthest-corner radius depend on the
1113
+ * box's real proportions, which normalized coordinates have thrown away.
1114
+ */
1115
+ export function gradientPaint(
1116
+ backgroundImage: string,
1117
+ width: number,
1118
+ height: number,
1119
+ id: string,
1120
+ ): SVGElement | undefined {
1121
+ const declaration = backgroundImage
1122
+ .trim()
1123
+ .match(/^(linear|radial)-gradient\(([\s\S]*)\)$/i);
1124
+ if (!declaration) return undefined;
1125
+ const parts = splitTopLevel(declaration[2]);
1126
+ if (!parts || parts.length < 2) return undefined;
1127
+ const head = parts[0].toLowerCase();
1128
+
1129
+ if (declaration[1].toLowerCase() === "radial") {
1130
+ const focus = head.match(
1131
+ /^(?:circle|ellipse)?\s*at\s+(-?[\d.]+)%\s+(-?[\d.]+)%$/,
1132
+ );
1133
+ // A size keyword, a length focus, or an ellipse's two radii are all shapes
1134
+ // this does not place. Reporting them blank beats inventing a circle.
1135
+ if (!focus && head !== "circle" && head !== "ellipse") return undefined;
1136
+ const stopParts = parts.slice(1);
1137
+ if (stopParts.length < 2) return undefined;
1138
+ const cx = focus ? (Number.parseFloat(focus[1]) / 100) * width : width / 2;
1139
+ const cy = focus
1140
+ ? (Number.parseFloat(focus[2]) / 100) * height
1141
+ : height / 2;
1142
+ const gradient = document.createElementNS(SVG_NAMESPACE, "radialGradient");
1143
+ gradient.setAttribute("id", id);
1144
+ gradient.setAttribute("gradientUnits", "userSpaceOnUse");
1145
+ gradient.setAttribute("cx", round3(cx));
1146
+ gradient.setAttribute("cy", round3(cy));
1147
+ // CSS sizes an unqualified radial to its farthest corner.
1148
+ gradient.setAttribute(
1149
+ "r",
1150
+ round3(
1151
+ Math.max(
1152
+ Math.hypot(cx, cy),
1153
+ Math.hypot(width - cx, cy),
1154
+ Math.hypot(cx, height - cy),
1155
+ Math.hypot(width - cx, height - cy),
1156
+ ),
1157
+ ),
1158
+ );
1159
+ return appendGradientStops(gradient, stopParts);
1160
+ }
1161
+
1162
+ const declaredAngle = head.match(/^(-?[\d.]+)deg$/)?.[1];
1163
+ const sideAngle = GRADIENT_SIDE_ANGLES[head];
1164
+ const hasDirection = declaredAngle != null || sideAngle !== undefined;
1165
+ // A corner keyword or a unit this does not read is still a direction, so it
1166
+ // is not a colour stop either. Reporting it blank beats inventing an angle.
1167
+ if (
1168
+ !hasDirection &&
1169
+ /^(to\b|calc\(|-?[\d.]+(deg|rad|grad|turn))/.test(head)
1170
+ ) {
1171
+ return undefined;
1172
+ }
1173
+ const angle =
1174
+ declaredAngle != null
1175
+ ? Number.parseFloat(declaredAngle)
1176
+ : (sideAngle ?? 180);
1177
+ const stopParts = hasDirection ? parts.slice(1) : parts;
1178
+ if (stopParts.length < 2) return undefined;
1179
+
1180
+ const radians = ((angle - 90) * Math.PI) / 180;
1181
+ const dx = Math.cos(radians);
1182
+ const dy = Math.sin(radians);
1183
+ const lineLength = Math.abs(width * dx) + Math.abs(height * dy);
1184
+ const gradient = document.createElementNS(SVG_NAMESPACE, "linearGradient");
1185
+ gradient.setAttribute("id", id);
1186
+ gradient.setAttribute("gradientUnits", "userSpaceOnUse");
1187
+ gradient.setAttribute("x1", round3(width / 2 - (dx * lineLength) / 2));
1188
+ gradient.setAttribute("y1", round3(height / 2 - (dy * lineLength) / 2));
1189
+ gradient.setAttribute("x2", round3(width / 2 + (dx * lineLength) / 2));
1190
+ gradient.setAttribute("y2", round3(height / 2 + (dy * lineLength) / 2));
1191
+ return appendGradientStops(gradient, stopParts);
1192
+ }
1193
+
1194
+ function clipPathPixels(raw: string | undefined, side: number) {
1195
+ if (!raw) return undefined;
1196
+ const value = Number.parseFloat(raw);
1197
+ if (!Number.isFinite(value)) return undefined;
1198
+ return raw.endsWith("%") ? (value / 100) * side : value;
1199
+ }
1200
+
1201
+ /**
1202
+ * dom-to-pptx has no custom-geometry surface — it never emits pptxgenjs
1203
+ * `points`, so a clipped element reaches PowerPoint as the filled rectangle of
1204
+ * its bounding box: infographics slide 5 shipped each 405px ring-segment arrow
1205
+ * as a 5.62in solid square, one of them covering the title. Redraw the clip as
1206
+ * a real path in an <svg> that takes the element's place, so
1207
+ * `replaceInlineSvgsWithImages` below carries the outline across.
1208
+ *
1209
+ * The replacement has to *be* the element rather than sit inside it: rotation
1210
+ * is read per node, so an <svg> child of a rotated shape would export
1211
+ * unrotated at the size of its rotated bounding box — bigger than the square
1212
+ * this replaces. Run it after the geometry passes that resolve recorded child
1213
+ * indexes; swapping a node keeps its index, adding one does not.
1214
+ */
1215
+ export function materializeClipPathShapes(root: HTMLElement) {
1216
+ for (const element of Array.from(root.querySelectorAll<HTMLElement>("*"))) {
1217
+ const style = window.getComputedStyle(element);
1218
+ const width = computedLength(style.width, 0);
1219
+ const height = computedLength(style.height, 0);
1220
+ if (!(width > 0) || !(height > 0)) continue;
1221
+ const outline = clipPathOutline(style.clipPath, width, height);
1222
+ if (!outline) continue;
1223
+ const overlay = element.firstElementChild;
1224
+ // Anything else inside the clip is its own exported object and would be
1225
+ // flattened into the bitmap, so leave those shapes to the exporter.
1226
+ if (
1227
+ element.childElementCount > (overlay instanceof SVGSVGElement ? 1 : 0)
1228
+ ) {
1229
+ continue;
1230
+ }
1231
+
1232
+ const svg = document.createElementNS(SVG_NAMESPACE, "svg");
1233
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
1234
+ svg.setAttribute("preserveAspectRatio", "none");
1235
+ svg.style.cssText = element.style.cssText;
1236
+ Object.assign(svg.style, {
1237
+ background: "none",
1238
+ border: "0",
1239
+ clipPath: "none",
1240
+ height: `${height}px`,
1241
+ overflow: "visible",
1242
+ width: `${width}px`,
1243
+ });
1244
+
1245
+ const path = document.createElementNS(SVG_NAMESPACE, "path");
1246
+ path.setAttribute("d", outline);
1247
+ const gradient = gradientPaint(
1248
+ style.backgroundImage,
1249
+ width,
1250
+ height,
1251
+ `fmd-grad-${gradientId++}`,
1252
+ );
1253
+ if (gradient) {
1254
+ const defs = document.createElementNS(SVG_NAMESPACE, "defs");
1255
+ defs.appendChild(gradient);
1256
+ svg.appendChild(defs);
1257
+ path.setAttribute("fill", `url(#${gradient.id})`);
1258
+ } else {
1259
+ path.setAttribute("fill", style.backgroundColor);
1260
+ }
1261
+ const strokeWidth = computedLength(style.borderTopWidth, 0);
1262
+ if (strokeWidth > 0) {
1263
+ path.setAttribute("stroke", style.borderTopColor);
1264
+ path.setAttribute("stroke-width", `${strokeWidth}`);
1265
+ }
1266
+ svg.appendChild(path);
1267
+ // The importer's stroke overlay is already in this element's pixel box, so
1268
+ // its strokes belong over the fill rather than in a second image.
1269
+ if (overlay instanceof SVGSVGElement) {
1270
+ svg.append(...Array.from(overlay.childNodes));
1271
+ }
1272
+ element.replaceWith(svg);
1273
+ }
1274
+ }
1275
+
1276
+ /**
1277
+ * Rasterize every inline `<svg>` in place, and return how many came back with
1278
+ * nothing painted. A blank bitmap is a shape that vanished from the deck, so
1279
+ * it is counted and named rather than passed on as a rendered image.
1280
+ */
1281
+ export async function replaceInlineSvgsWithImages(
1282
+ root: HTMLElement,
1283
+ slideNumber = 1,
1284
+ ): Promise<number> {
868
1285
  const svgs = Array.from(root.querySelectorAll<SVGSVGElement>("svg"));
869
- for (const svg of svgs) {
1286
+ let blankCount = 0;
1287
+ for (const [index, svg] of svgs.entries()) {
870
1288
  const rect = svg.getBoundingClientRect();
871
1289
  const viewBox = svg.viewBox?.baseVal;
1290
+ const style = window.getComputedStyle(svg);
1291
+ // getBoundingClientRect on a rotated node is the axis-aligned box around
1292
+ // it — 1.41x the real edge on a 45deg shape — and the rotation is carried
1293
+ // onto the <img> below, so measuring it here would apply the angle twice.
872
1294
  const width =
873
- rect.width || Number(svg.getAttribute("width")) || viewBox?.width || 1;
1295
+ computedLength(style.width, rect.width) ||
1296
+ Number(svg.getAttribute("width")) ||
1297
+ viewBox?.width ||
1298
+ 1;
874
1299
  const height =
875
- rect.height || Number(svg.getAttribute("height")) || viewBox?.height || 1;
876
- const dataUrl = await rasterizeSvgElement(svg, width, height);
1300
+ computedLength(style.height, rect.height) ||
1301
+ Number(svg.getAttribute("height")) ||
1302
+ viewBox?.height ||
1303
+ 1;
1304
+ const { dataUrl, blank } = await rasterizeSvgElement(svg, width, height);
1305
+ if (blank) {
1306
+ blankCount++;
1307
+ const outline =
1308
+ svg.querySelector("path")?.getAttribute("d")?.slice(0, 48) ?? "no path";
1309
+ console.warn(
1310
+ `[export-pptx] slide ${slideNumber} shape ${index + 1} rasterized empty and will be missing from the export (${Math.round(width)}x${Math.round(height)}, ${outline})`,
1311
+ );
1312
+ }
877
1313
  const img = document.createElement("img");
878
- const style = window.getComputedStyle(svg);
879
1314
  img.src = dataUrl;
880
1315
  img.alt = svg.getAttribute("aria-label") ?? "";
881
1316
  Object.assign(img.style, {
@@ -900,6 +1335,123 @@ async function replaceInlineSvgsWithImages(root: HTMLElement) {
900
1335
  });
901
1336
  svg.replaceWith(img);
902
1337
  }
1338
+ return blankCount;
1339
+ }
1340
+
1341
+ /**
1342
+ * A PPTX `srcRect` crop imports as an oversized <img> inside an
1343
+ * overflow-hidden wrapper. dom-to-pptx exports the image's own box and has no
1344
+ * notion of the clip, so soze slide 2's 193px portrait shipped 522px wide
1345
+ * across the body text. Bake the visible window into the bitmap and shrink the
1346
+ * element onto it.
1347
+ */
1348
+ async function flattenCroppedImages(root: HTMLElement) {
1349
+ for (const image of Array.from(root.querySelectorAll("img"))) {
1350
+ const clip = image.parentElement;
1351
+ if (!clip) continue;
1352
+ const clipStyle = window.getComputedStyle(clip);
1353
+ if (
1354
+ clipStyle.overflowX === "visible" ||
1355
+ clipStyle.overflowY === "visible"
1356
+ ) {
1357
+ continue;
1358
+ }
1359
+
1360
+ const imageRect = image.getBoundingClientRect();
1361
+ const clipRect = clip.getBoundingClientRect();
1362
+ const left = Math.max(imageRect.left, clipRect.left);
1363
+ const top = Math.max(imageRect.top, clipRect.top);
1364
+ const width = Math.min(imageRect.right, clipRect.right) - left;
1365
+ const height = Math.min(imageRect.bottom, clipRect.bottom) - top;
1366
+ if (width <= 0 || height <= 0) continue;
1367
+ if (
1368
+ width >= imageRect.width - 0.5 &&
1369
+ height >= imageRect.height - 0.5 &&
1370
+ left <= imageRect.left + 0.5 &&
1371
+ top <= imageRect.top + 0.5
1372
+ ) {
1373
+ continue;
1374
+ }
1375
+
1376
+ // Only `fill` maps the displayed box linearly onto the bitmap; any other
1377
+ // object-fit needs its own letterbox math, which dom-to-pptx already does
1378
+ // for the uncropped box.
1379
+ if ((window.getComputedStyle(image).objectFit || "fill") !== "fill") {
1380
+ console.warn(
1381
+ `[export-pptx] cropped image uses object-fit and exports uncropped: ${image.src}`,
1382
+ );
1383
+ continue;
1384
+ }
1385
+ // The clone's own <img> can still be in flight even though the source it
1386
+ // was copied from had settled.
1387
+ await waitForImageToSettle(image);
1388
+ if (!image.complete || image.naturalWidth <= 0) {
1389
+ console.warn(
1390
+ `[export-pptx] cropped image is not decoded and exports uncropped: ${image.src}`,
1391
+ );
1392
+ continue;
1393
+ }
1394
+
1395
+ const scaleX = image.naturalWidth / imageRect.width;
1396
+ const scaleY = image.naturalHeight / imageRect.height;
1397
+ const canvas = document.createElement("canvas");
1398
+ canvas.width = Math.max(1, Math.round(width * scaleX));
1399
+ canvas.height = Math.max(1, Math.round(height * scaleY));
1400
+ const context = canvas.getContext("2d");
1401
+ if (!context) {
1402
+ console.warn(
1403
+ `[export-pptx] no 2d canvas to crop with; image exports uncropped: ${image.src}`,
1404
+ );
1405
+ continue;
1406
+ }
1407
+ context.drawImage(
1408
+ image,
1409
+ (left - imageRect.left) * scaleX,
1410
+ (top - imageRect.top) * scaleY,
1411
+ canvas.width,
1412
+ canvas.height,
1413
+ 0,
1414
+ 0,
1415
+ canvas.width,
1416
+ canvas.height,
1417
+ );
1418
+
1419
+ let cropped: string;
1420
+ try {
1421
+ cropped = canvas.toDataURL("image/png");
1422
+ } catch (error) {
1423
+ console.warn(
1424
+ `[export-pptx] cropped image could not be rasterized and exports uncropped: ${image.src}`,
1425
+ error,
1426
+ );
1427
+ continue;
1428
+ }
1429
+
1430
+ image.src = cropped;
1431
+ image.style.height = `${height}px`;
1432
+ image.style.maxHeight = "none";
1433
+ image.style.maxWidth = "none";
1434
+ image.style.width = `${width}px`;
1435
+
1436
+ const shrunkRect = image.getBoundingClientRect();
1437
+ const deltaX = left - shrunkRect.left;
1438
+ const deltaY = top - shrunkRect.top;
1439
+ const imageStyle = window.getComputedStyle(image);
1440
+ if (imageStyle.position === "absolute" || imageStyle.position === "fixed") {
1441
+ const cssLeft = Number.parseFloat(imageStyle.left);
1442
+ const cssTop = Number.parseFloat(imageStyle.top);
1443
+ image.style.bottom = "auto";
1444
+ image.style.right = "auto";
1445
+ if (Number.isFinite(cssLeft)) {
1446
+ image.style.left = `${(cssLeft + deltaX).toFixed(3)}px`;
1447
+ }
1448
+ if (Number.isFinite(cssTop)) {
1449
+ image.style.top = `${(cssTop + deltaY).toFixed(3)}px`;
1450
+ }
1451
+ continue;
1452
+ }
1453
+ image.style.transform = `translate(${deltaX.toFixed(3)}px, ${deltaY.toFixed(3)}px)`;
1454
+ }
903
1455
  }
904
1456
 
905
1457
  function widenNoWrapTextElements(root: HTMLElement) {
@@ -1013,7 +1565,7 @@ export async function buildDeckPptxBlob(
1013
1565
  deckTitle: string,
1014
1566
  slides: PptxExportSlide[],
1015
1567
  aspectRatio?: AspectRatio,
1016
- ): Promise<{ blob: Blob; filename: string }> {
1568
+ ): Promise<{ blob: Blob; filename: string; blankShapes: number }> {
1017
1569
  const { exportToPptx } = await importExportModule(
1018
1570
  () => import("dom-to-pptx"),
1019
1571
  );
@@ -1028,6 +1580,7 @@ export async function buildDeckPptxBlob(
1028
1580
  cleanup: () => void;
1029
1581
  }> = [];
1030
1582
  const slideBulletIndents: number[][] = [];
1583
+ let blankShapes = 0;
1031
1584
 
1032
1585
  try {
1033
1586
  for (let i = 0; i < slides.length; i++) {
@@ -1041,7 +1594,8 @@ export async function buildDeckPptxBlob(
1041
1594
  exportClones.push(clone);
1042
1595
  await preloadImagesWithCors(clone.element);
1043
1596
  resetAutofitTransforms(clone.element);
1044
- slideBulletIndents.push(normalizeListsForPptx(clone.element));
1597
+ slideBulletIndents.push(normalizeListsForPptx(clone.element, dims));
1598
+ ensureBulletMarkerSpacing(clone.element);
1045
1599
  restorePositionedGeometry(
1046
1600
  clone.element,
1047
1601
  clone.sourceRect,
@@ -1055,9 +1609,9 @@ export async function buildDeckPptxBlob(
1055
1609
  dims,
1056
1610
  );
1057
1611
  normalizeSingleLineText(clone.element, clone.textGeometry);
1058
- materializeImportedBackgroundGrid(clone.element);
1059
1612
  widenNoWrapTextElements(clone.element);
1060
- await replaceInlineSvgsWithImages(clone.element);
1613
+ materializeClipPathShapes(clone.element);
1614
+ blankShapes += await replaceInlineSvgsWithImages(clone.element, i + 1);
1061
1615
  await preloadImagesWithCors(clone.element);
1062
1616
  restoreImageGeometry(
1063
1617
  clone.element,
@@ -1065,6 +1619,13 @@ export async function buildDeckPptxBlob(
1065
1619
  clone.imageGeometry,
1066
1620
  dims,
1067
1621
  );
1622
+ // Runs after that restore, which re-applies each image's own measured
1623
+ // box — the uncropped one — and would undo the crop.
1624
+ await flattenCroppedImages(clone.element);
1625
+ // Runs last: it prepends a child to the slide root, which shifts every
1626
+ // child index the geometry passes above resolve their recorded paths
1627
+ // through.
1628
+ materializeImportedBackgroundGrid(clone.element);
1068
1629
  }
1069
1630
 
1070
1631
  const initialBlob = await exportToPptx(
@@ -1083,8 +1644,17 @@ export async function buildDeckPptxBlob(
1083
1644
  initialBlob,
1084
1645
  slideBulletIndents,
1085
1646
  );
1086
- const blob = await addSpeakerNotesToPptxBlob(bulletPatchedBlob, slides);
1087
- return { blob, filename: safePptxName(deckTitle) };
1647
+ const blob = await addSpeakerNotesToPptxBlob(
1648
+ bulletPatchedBlob,
1649
+ slides,
1650
+ dims.pptxInches,
1651
+ );
1652
+ if (blankShapes > 0) {
1653
+ console.warn(
1654
+ `[export-pptx] ${blankShapes} shape(s) rendered empty and are missing from ${deckTitle}`,
1655
+ );
1656
+ }
1657
+ return { blankShapes, blob, filename: safePptxName(deckTitle) };
1088
1658
  } finally {
1089
1659
  for (const clone of exportClones) {
1090
1660
  clone.cleanup();
@@ -1096,11 +1666,12 @@ export async function exportDeckAsPptx(
1096
1666
  deckTitle: string,
1097
1667
  slides: PptxExportSlide[],
1098
1668
  aspectRatio?: AspectRatio,
1099
- ): Promise<void> {
1100
- const { blob, filename } = await buildDeckPptxBlob(
1669
+ ): Promise<{ blankShapes: number }> {
1670
+ const { blankShapes, blob, filename } = await buildDeckPptxBlob(
1101
1671
  deckTitle,
1102
1672
  slides,
1103
1673
  aspectRatio,
1104
1674
  );
1105
1675
  triggerBlobDownload(blob, filename);
1676
+ return { blankShapes };
1106
1677
  }