@agent-native/core 0.157.9 → 0.157.11
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.
- package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +5 -2
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
- package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +18 -6
- package/corpus/templates/analytics/server/plugins/db.ts +139 -0
- package/corpus/templates/slides/AGENTS.md +14 -10
- package/corpus/templates/slides/actions/export-pptx.ts +1215 -94
- package/corpus/templates/slides/actions/import-file.ts +16 -1
- package/corpus/templates/slides/actions/import-pptx.ts +61 -43
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
- package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
- package/corpus/templates/slides/app/global.css +19 -1
- package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
- package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
- package/corpus/templates/slides/app/lib/export-pptx-client.ts +556 -25
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +1176 -26
- package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +13 -0
- package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
- package/corpus/templates/slides/server/routes/api/exports/google-slides.post.ts +12 -4
- package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
- package/dist/agent/harness/migrations.js +24 -3
- package/dist/agent/production-agent.js +11 -0
- package/dist/agent/run-loop-with-resume.js +21 -4
- package/dist/automations/service.d.ts +6 -0
- package/dist/automations/service.js +35 -0
- package/dist/cli/code-agent-connector.d.ts +4 -1
- package/dist/cli/code-agent-connector.js +154 -23
- package/dist/cli/code-agent-executor.js +255 -13
- package/dist/cli/code-agent-runs.d.ts +1 -0
- package/dist/cli/code-agent-runs.js +2 -1
- package/dist/cli/portal-workspace.d.ts +49 -0
- package/dist/cli/portal-workspace.js +320 -0
- package/dist/client/AssistantChat.d.ts +1 -1
- package/dist/client/AssistantChat.js +39 -61
- package/dist/client/ConnectBuilderCard.js +33 -5
- package/dist/client/agent-chat-adapter.js +4 -0
- package/dist/client/analytics-platform.d.ts +2 -0
- package/dist/client/analytics-platform.js +12 -0
- package/dist/client/analytics.d.ts +3 -0
- package/dist/client/analytics.js +10 -1
- package/dist/client/chat/index.d.ts +1 -0
- package/dist/client/chat/index.js +1 -0
- package/dist/client/chat/run-recovery.d.ts +2 -1
- package/dist/client/chat/run-recovery.js +2 -2
- package/dist/client/chat-first/apps-rail.d.ts +1 -1
- package/dist/client/chat-first/apps-rail.js +11 -11
- package/dist/client/chat-first/surface-tabs.d.ts +1 -1
- package/dist/client/chat-first/surface-tabs.js +38 -36
- package/dist/client/chat-first/types.d.ts +5 -0
- package/dist/client/chat-first.d.ts +1 -1
- package/dist/client/chat-first.js +4 -4
- package/dist/client/desktop-local-code-change.d.ts +10 -0
- package/dist/client/desktop-local-code-change.js +16 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/observability/ThumbsFeedback.js +21 -7
- package/dist/client/terminal/AgentTerminal.d.ts +9 -1
- package/dist/client/terminal/AgentTerminal.js +38 -3
- package/dist/client/terminal/index.d.ts +1 -1
- package/dist/client/terminal/index.js +1 -1
- package/dist/client/track.js +9 -1
- package/dist/client/use-action.js +3 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/client.js +1 -0
- package/dist/deploy/build.js +1 -1
- package/dist/ingestion/pptx.d.ts +78 -0
- package/dist/ingestion/pptx.js +991 -107
- package/dist/integrations/index.d.ts +3 -3
- package/dist/integrations/index.js +2 -2
- package/dist/integrations/plugin.js +95 -6
- package/dist/integrations/remote-commands-store.d.ts +5 -0
- package/dist/integrations/remote-commands-store.js +63 -22
- package/dist/integrations/remote-devices-store.d.ts +2 -1
- package/dist/integrations/remote-devices-store.js +46 -0
- package/dist/integrations/remote-types.d.ts +20 -0
- package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
- package/dist/jobs/actions/list-recurring-jobs.js +5 -1
- package/dist/jobs/frontmatter.d.ts +16 -0
- package/dist/jobs/frontmatter.js +53 -0
- package/dist/jobs/remote-execution.d.ts +42 -0
- package/dist/jobs/remote-execution.js +287 -0
- package/dist/jobs/scheduler.js +102 -3
- package/dist/jobs/tools.js +50 -3
- package/dist/notifications/routes.d.ts +4 -4
- package/dist/observability/routes.d.ts +5 -5
- package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
- package/dist/provider-api/actions/provider-api.d.ts +14 -14
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/action-routes.js +4 -2
- package/dist/server/agent-chat-plugin.js +6 -0
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-run-context.d.ts +1 -0
- package/dist/server/agent-run-context.js +9 -0
- package/dist/server/core-routes-plugin.js +8 -1
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/analytics-platform.d.ts +6 -0
- package/dist/shared/analytics-platform.js +14 -0
- package/dist/shared/mcp-embed-headers.d.ts +1 -1
- package/dist/shared/mcp-embed-headers.js +1 -1
- package/dist/styles/agent-native.css +83 -103
- package/dist/terminal/cli-registry.js +5 -0
- package/dist/tracking/registry.js +9 -1
- package/dist/triggers/actions/list-automations.d.ts +3 -0
- package/dist/triggers/actions/list-automations.js +3 -0
- package/dist/triggers/actions/manage-automation.d.ts +9 -0
- package/dist/triggers/actions/manage-automation.js +14 -2
- package/dist/triggers/actions.js +82 -7
- package/docs/content/template-content-developers.mdx +48 -0
- package/docs/content/toolkit-ui.mdx +57 -0
- 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)
|
|
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)
|
|
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 = `${
|
|
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 = `${
|
|
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
|
-
|
|
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 =
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1295
|
+
computedLength(style.width, rect.width) ||
|
|
1296
|
+
Number(svg.getAttribute("width")) ||
|
|
1297
|
+
viewBox?.width ||
|
|
1298
|
+
1;
|
|
910
1299
|
const height =
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
}
|