@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
@@ -528,6 +528,20 @@ function resetAutofitTransforms(root: HTMLElement) {
528
528
  }
529
529
  }
530
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
+
531
545
  function restoreTextGeometry(
532
546
  clone: HTMLElement,
533
547
  sourceRect: DOMRect,
@@ -561,7 +575,9 @@ function restoreTextGeometry(
561
575
 
562
576
  if (record.position === "static" || record.position === "relative") {
563
577
  element.dataset.exportTextGeometry = "true";
564
- if (record.singleLine) element.style.whiteSpace = "nowrap";
578
+ if (record.singleLine) {
579
+ element.style.whiteSpace = noWrapWhiteSpace(element);
580
+ }
565
581
  element.style.left = `${translateX.toFixed(3)}px`;
566
582
  element.style.position = "relative";
567
583
  element.style.top = `${translateY.toFixed(3)}px`;
@@ -577,7 +593,9 @@ function restoreTextGeometry(
577
593
  }
578
594
 
579
595
  element.dataset.exportTextGeometry = "true";
580
- if (record.singleLine) element.style.whiteSpace = "nowrap";
596
+ if (record.singleLine) {
597
+ element.style.whiteSpace = noWrapWhiteSpace(element);
598
+ }
581
599
  element.style.boxSizing = "border-box";
582
600
  element.style.bottom = "auto";
583
601
  element.style.display = "block";
@@ -629,20 +647,37 @@ function restoreImageGeometry(
629
647
  element.style.maxWidth = "none";
630
648
  element.style.width = `${Math.max(1, desiredWidth)}px`;
631
649
 
650
+ const currentRect = element.getBoundingClientRect();
651
+ const deltaX = desiredLeft - (currentRect.left - cloneRect.left);
652
+ const deltaY = desiredTop - (currentRect.top - cloneRect.top);
653
+
632
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);
633
665
  element.style.bottom = "auto";
634
- element.style.left = `${desiredLeft.toFixed(3)}px`;
666
+ element.style.left = `${(Number.isFinite(cssLeft)
667
+ ? cssLeft + deltaX
668
+ : desiredLeft
669
+ ).toFixed(3)}px`;
635
670
  element.style.position = "absolute";
636
671
  element.style.right = "auto";
637
- element.style.top = `${desiredTop.toFixed(3)}px`;
672
+ element.style.top = `${(Number.isFinite(cssTop)
673
+ ? cssTop + deltaY
674
+ : desiredTop
675
+ ).toFixed(3)}px`;
638
676
  element.style.transform = "none";
639
677
  continue;
640
678
  }
641
679
 
642
- const currentRect = element.getBoundingClientRect();
643
- const currentLeft = currentRect.left - cloneRect.left;
644
- const currentTop = currentRect.top - cloneRect.top;
645
- 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)`;
646
681
  }
647
682
  }
648
683
 
@@ -662,7 +697,7 @@ function normalizeSingleLineText(
662
697
  element.dataset.exportSingleLineText = "true";
663
698
  if (element.dataset.exportTextGeometry === "true") continue;
664
699
  element.style.boxSizing = "border-box";
665
- element.style.whiteSpace = "nowrap";
700
+ element.style.whiteSpace = noWrapWhiteSpace(element);
666
701
  if (record.heading) {
667
702
  element.style.maxWidth = "none";
668
703
  element.style.width = `${Math.max(1, Math.ceil(cloneRect.right - rect.left))}px`;
@@ -753,6 +788,29 @@ function normalizeListsForPptx(
753
788
  return bulletIndents;
754
789
  }
755
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
+
756
814
  const EMU_PER_POINT = 12_700;
757
815
 
758
816
  /**
@@ -861,24 +919,61 @@ export async function patchBulletIndentsInPptxBlob(
861
919
  });
862
920
  }
863
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
+
864
932
  function svgDataUrl(svg: SVGSVGElement) {
865
933
  const copy = svg.cloneNode(true) as SVGSVGElement;
866
934
  if (!copy.getAttribute("xmlns")) {
867
935
  copy.setAttribute("xmlns", "http://www.w3.org/2000/svg");
868
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
+ }
869
944
  const serialized = new XMLSerializer().serializeToString(copy);
870
945
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`;
871
946
  }
872
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
+
873
966
  async function rasterizeSvgElement(
874
967
  svg: SVGSVGElement,
875
968
  width: number,
876
969
  height: number,
877
- ) {
970
+ ): Promise<{ dataUrl: string; blank: boolean | undefined }> {
878
971
  const fallback = svgDataUrl(svg);
879
972
  const canvas = document.createElement("canvas");
880
973
  const ctx = canvas.getContext("2d");
881
- if (!ctx || typeof Image === "undefined") return fallback;
974
+ if (!ctx || typeof Image === "undefined") {
975
+ return { dataUrl: fallback, blank: undefined };
976
+ }
882
977
 
883
978
  const scale = Math.max(2, window.devicePixelRatio || 1);
884
979
  canvas.width = Math.max(1, Math.ceil(width * scale));
@@ -894,24 +989,328 @@ async function rasterizeSvgElement(
894
989
  try {
895
990
  await loaded;
896
991
  ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
897
- 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
+ };
898
1007
  } catch {
899
- return fallback;
1008
+ return { dataUrl: fallback, blank: undefined };
900
1009
  }
901
1010
  }
902
1011
 
903
- 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> {
904
1285
  const svgs = Array.from(root.querySelectorAll<SVGSVGElement>("svg"));
905
- for (const svg of svgs) {
1286
+ let blankCount = 0;
1287
+ for (const [index, svg] of svgs.entries()) {
906
1288
  const rect = svg.getBoundingClientRect();
907
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.
908
1294
  const width =
909
- 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;
910
1299
  const height =
911
- rect.height || Number(svg.getAttribute("height")) || viewBox?.height || 1;
912
- 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
+ }
913
1313
  const img = document.createElement("img");
914
- const style = window.getComputedStyle(svg);
915
1314
  img.src = dataUrl;
916
1315
  img.alt = svg.getAttribute("aria-label") ?? "";
917
1316
  Object.assign(img.style, {
@@ -936,6 +1335,123 @@ async function replaceInlineSvgsWithImages(root: HTMLElement) {
936
1335
  });
937
1336
  svg.replaceWith(img);
938
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
+ }
939
1455
  }
940
1456
 
941
1457
  function widenNoWrapTextElements(root: HTMLElement) {
@@ -1049,7 +1565,7 @@ export async function buildDeckPptxBlob(
1049
1565
  deckTitle: string,
1050
1566
  slides: PptxExportSlide[],
1051
1567
  aspectRatio?: AspectRatio,
1052
- ): Promise<{ blob: Blob; filename: string }> {
1568
+ ): Promise<{ blob: Blob; filename: string; blankShapes: number }> {
1053
1569
  const { exportToPptx } = await importExportModule(
1054
1570
  () => import("dom-to-pptx"),
1055
1571
  );
@@ -1064,6 +1580,7 @@ export async function buildDeckPptxBlob(
1064
1580
  cleanup: () => void;
1065
1581
  }> = [];
1066
1582
  const slideBulletIndents: number[][] = [];
1583
+ let blankShapes = 0;
1067
1584
 
1068
1585
  try {
1069
1586
  for (let i = 0; i < slides.length; i++) {
@@ -1078,6 +1595,7 @@ export async function buildDeckPptxBlob(
1078
1595
  await preloadImagesWithCors(clone.element);
1079
1596
  resetAutofitTransforms(clone.element);
1080
1597
  slideBulletIndents.push(normalizeListsForPptx(clone.element, dims));
1598
+ ensureBulletMarkerSpacing(clone.element);
1081
1599
  restorePositionedGeometry(
1082
1600
  clone.element,
1083
1601
  clone.sourceRect,
@@ -1091,9 +1609,9 @@ export async function buildDeckPptxBlob(
1091
1609
  dims,
1092
1610
  );
1093
1611
  normalizeSingleLineText(clone.element, clone.textGeometry);
1094
- materializeImportedBackgroundGrid(clone.element);
1095
1612
  widenNoWrapTextElements(clone.element);
1096
- await replaceInlineSvgsWithImages(clone.element);
1613
+ materializeClipPathShapes(clone.element);
1614
+ blankShapes += await replaceInlineSvgsWithImages(clone.element, i + 1);
1097
1615
  await preloadImagesWithCors(clone.element);
1098
1616
  restoreImageGeometry(
1099
1617
  clone.element,
@@ -1101,6 +1619,13 @@ export async function buildDeckPptxBlob(
1101
1619
  clone.imageGeometry,
1102
1620
  dims,
1103
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);
1104
1629
  }
1105
1630
 
1106
1631
  const initialBlob = await exportToPptx(
@@ -1124,7 +1649,12 @@ export async function buildDeckPptxBlob(
1124
1649
  slides,
1125
1650
  dims.pptxInches,
1126
1651
  );
1127
- return { blob, filename: safePptxName(deckTitle) };
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) };
1128
1658
  } finally {
1129
1659
  for (const clone of exportClones) {
1130
1660
  clone.cleanup();
@@ -1136,11 +1666,12 @@ export async function exportDeckAsPptx(
1136
1666
  deckTitle: string,
1137
1667
  slides: PptxExportSlide[],
1138
1668
  aspectRatio?: AspectRatio,
1139
- ): Promise<void> {
1140
- const { blob, filename } = await buildDeckPptxBlob(
1669
+ ): Promise<{ blankShapes: number }> {
1670
+ const { blankShapes, blob, filename } = await buildDeckPptxBlob(
1141
1671
  deckTitle,
1142
1672
  slides,
1143
1673
  aspectRatio,
1144
1674
  );
1145
1675
  triggerBlobDownload(blob, filename);
1676
+ return { blankShapes };
1146
1677
  }