@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/actions/purge-first-party-analytics-postgres.ts +26 -8
- package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +261 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
- package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +75 -31
- package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +4 -1
- 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 +1311 -121
- package/corpus/templates/slides/actions/import-file.ts +28 -1
- package/corpus/templates/slides/actions/import-pptx.ts +69 -42
- 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 +601 -30
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +1240 -22
- package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +329 -64
- package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
- package/corpus/templates/slides/server/lib/source-import.ts +10 -1
- package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
- package/dist/agent/harness/migrations.d.ts +4 -0
- package/dist/agent/harness/migrations.js +60 -0
- package/dist/agent/production-agent.d.ts +2 -0
- package/dist/agent/production-agent.js +15 -0
- package/dist/agent/run-loop-with-resume.d.ts +1 -0
- package/dist/agent/run-loop-with-resume.js +44 -12
- package/dist/agent/run-migrations.d.ts +8 -0
- package/dist/agent/run-migrations.js +61 -0
- package/dist/automations/service.d.ts +6 -0
- package/dist/automations/service.js +35 -0
- package/dist/chat-threads/migrations.d.ts +1 -0
- package/dist/chat-threads/migrations.js +1 -0
- package/dist/chat-threads/schema-migrations.d.ts +8 -0
- package/dist/chat-threads/schema-migrations.js +60 -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 +43 -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/message-components.d.ts +2 -0
- package/dist/client/chat/message-components.js +11 -1
- 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 +53 -26
- package/dist/client/observability/useObservability.d.ts +1 -1
- package/dist/client/observability/useObservability.js +6 -3
- 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/deploy/build.js +1 -1
- package/dist/feature-flags/a2a-action-route.js +4 -2
- package/dist/feature-flags/permissions.d.ts +2 -0
- package/dist/feature-flags/permissions.js +6 -1
- package/dist/ingestion/pptx.d.ts +92 -1
- package/dist/ingestion/pptx.js +1180 -115
- 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/localization/core-messages/ar-SA.js +4 -0
- package/dist/localization/core-messages/de-DE.js +4 -0
- package/dist/localization/core-messages/en-US.d.ts +4 -0
- package/dist/localization/core-messages/en-US.js +4 -0
- package/dist/localization/core-messages/es-ES.js +4 -0
- package/dist/localization/core-messages/fr-FR.js +4 -0
- package/dist/localization/core-messages/hi-IN.js +4 -0
- package/dist/localization/core-messages/ja-JP.js +4 -0
- package/dist/localization/core-messages/ko-KR.js +4 -0
- package/dist/localization/core-messages/pt-BR.js +4 -0
- package/dist/localization/core-messages/zh-CN.js +4 -0
- package/dist/localization/core-messages/zh-TW.js +4 -0
- package/dist/localization/core-messages.d.ts +4 -0
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/feedback.js +40 -16
- package/dist/observability/routes.d.ts +6 -6
- package/dist/observability/routes.js +15 -3
- package/dist/observability/store.js +2 -0
- package/dist/observability/traces.js +5 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
- package/dist/provider-api/actions/provider-api.d.ts +13 -13
- 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.d.ts +7 -5
- package/dist/server/action-routes.js +13 -6
- 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/better-auth-instance.js +4 -0
- package/dist/server/core-routes-plugin.js +8 -1
- package/dist/server/realtime-voice.js +2 -1
- package/dist/server/release-migrations.js +16 -0
- 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/dist/usage/migrations.d.ts +4 -0
- package/dist/usage/migrations.js +45 -0
- package/docs/content/template-content-developers.mdx +48 -0
- package/docs/content/toolkit-ui.mdx +57 -0
- 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
|
-
|
|
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)
|
|
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)
|
|
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 = `${
|
|
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 = `${
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1295
|
+
computedLength(style.width, rect.width) ||
|
|
1296
|
+
Number(svg.getAttribute("width")) ||
|
|
1297
|
+
viewBox?.width ||
|
|
1298
|
+
1;
|
|
874
1299
|
const height =
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
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(
|
|
1087
|
-
|
|
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<
|
|
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
|
}
|