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