@agent-native/core 0.157.9 → 0.157.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +5 -2
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
- package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +18 -6
- package/corpus/templates/analytics/server/plugins/db.ts +139 -0
- package/corpus/templates/slides/AGENTS.md +14 -10
- package/corpus/templates/slides/actions/export-pptx.ts +1215 -94
- package/corpus/templates/slides/actions/import-file.ts +16 -1
- package/corpus/templates/slides/actions/import-pptx.ts +61 -43
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
- package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
- package/corpus/templates/slides/app/global.css +19 -1
- package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
- package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
- package/corpus/templates/slides/app/lib/export-pptx-client.ts +556 -25
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +1176 -26
- package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +13 -0
- package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
- package/corpus/templates/slides/server/routes/api/exports/google-slides.post.ts +12 -4
- package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
- package/dist/agent/harness/migrations.js +24 -3
- package/dist/agent/production-agent.js +11 -0
- package/dist/agent/run-loop-with-resume.js +21 -4
- package/dist/automations/service.d.ts +6 -0
- package/dist/automations/service.js +35 -0
- package/dist/cli/code-agent-connector.d.ts +4 -1
- package/dist/cli/code-agent-connector.js +154 -23
- package/dist/cli/code-agent-executor.js +255 -13
- package/dist/cli/code-agent-runs.d.ts +1 -0
- package/dist/cli/code-agent-runs.js +2 -1
- package/dist/cli/portal-workspace.d.ts +49 -0
- package/dist/cli/portal-workspace.js +320 -0
- package/dist/client/AssistantChat.d.ts +1 -1
- package/dist/client/AssistantChat.js +39 -61
- package/dist/client/ConnectBuilderCard.js +33 -5
- package/dist/client/agent-chat-adapter.js +4 -0
- package/dist/client/analytics-platform.d.ts +2 -0
- package/dist/client/analytics-platform.js +12 -0
- package/dist/client/analytics.d.ts +3 -0
- package/dist/client/analytics.js +10 -1
- package/dist/client/chat/index.d.ts +1 -0
- package/dist/client/chat/index.js +1 -0
- package/dist/client/chat/run-recovery.d.ts +2 -1
- package/dist/client/chat/run-recovery.js +2 -2
- package/dist/client/chat-first/apps-rail.d.ts +1 -1
- package/dist/client/chat-first/apps-rail.js +11 -11
- package/dist/client/chat-first/surface-tabs.d.ts +1 -1
- package/dist/client/chat-first/surface-tabs.js +38 -36
- package/dist/client/chat-first/types.d.ts +5 -0
- package/dist/client/chat-first.d.ts +1 -1
- package/dist/client/chat-first.js +4 -4
- package/dist/client/desktop-local-code-change.d.ts +10 -0
- package/dist/client/desktop-local-code-change.js +16 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/observability/ThumbsFeedback.js +21 -7
- package/dist/client/terminal/AgentTerminal.d.ts +9 -1
- package/dist/client/terminal/AgentTerminal.js +38 -3
- package/dist/client/terminal/index.d.ts +1 -1
- package/dist/client/terminal/index.js +1 -1
- package/dist/client/track.js +9 -1
- package/dist/client/use-action.js +3 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/client.js +1 -0
- package/dist/deploy/build.js +1 -1
- package/dist/ingestion/pptx.d.ts +78 -0
- package/dist/ingestion/pptx.js +991 -107
- package/dist/integrations/index.d.ts +3 -3
- package/dist/integrations/index.js +2 -2
- package/dist/integrations/plugin.js +95 -6
- package/dist/integrations/remote-commands-store.d.ts +5 -0
- package/dist/integrations/remote-commands-store.js +63 -22
- package/dist/integrations/remote-devices-store.d.ts +2 -1
- package/dist/integrations/remote-devices-store.js +46 -0
- package/dist/integrations/remote-types.d.ts +20 -0
- package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
- package/dist/jobs/actions/list-recurring-jobs.js +5 -1
- package/dist/jobs/frontmatter.d.ts +16 -0
- package/dist/jobs/frontmatter.js +53 -0
- package/dist/jobs/remote-execution.d.ts +42 -0
- package/dist/jobs/remote-execution.js +287 -0
- package/dist/jobs/scheduler.js +102 -3
- package/dist/jobs/tools.js +50 -3
- package/dist/notifications/routes.d.ts +4 -4
- package/dist/observability/routes.d.ts +5 -5
- package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
- package/dist/provider-api/actions/provider-api.d.ts +14 -14
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/action-routes.js +4 -2
- package/dist/server/agent-chat-plugin.js +6 -0
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-run-context.d.ts +1 -0
- package/dist/server/agent-run-context.js +9 -0
- package/dist/server/core-routes-plugin.js +8 -1
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/analytics-platform.d.ts +6 -0
- package/dist/shared/analytics-platform.js +14 -0
- package/dist/shared/mcp-embed-headers.d.ts +1 -1
- package/dist/shared/mcp-embed-headers.js +1 -1
- package/dist/styles/agent-native.css +83 -103
- package/dist/terminal/cli-registry.js +5 -0
- package/dist/tracking/registry.js +9 -1
- package/dist/triggers/actions/list-automations.d.ts +3 -0
- package/dist/triggers/actions/list-automations.js +3 -0
- package/dist/triggers/actions/manage-automation.d.ts +9 -0
- package/dist/triggers/actions/manage-automation.js +14 -2
- package/dist/triggers/actions.js +82 -7
- package/docs/content/template-content-developers.mdx +48 -0
- package/docs/content/toolkit-ui.mdx +57 -0
- package/package.json +2 -2
|
@@ -40,6 +40,24 @@ function getStyle(style: string, prop: string): string | null {
|
|
|
40
40
|
*/
|
|
41
41
|
function colorToHex(color: string): { hex: string; transparency?: number } {
|
|
42
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;
|
|
43
61
|
|
|
44
62
|
// Strip quotes / trim
|
|
45
63
|
color = color.replace(/['"]/g, "").trim();
|
|
@@ -99,10 +117,117 @@ function colorToHex(color: string): { hex: string; transparency?: number } {
|
|
|
99
117
|
const hex = named[color.toLowerCase()];
|
|
100
118
|
if (hex) return { hex };
|
|
101
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
|
+
}
|
|
155
|
+
|
|
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;
|
|
102
169
|
console.warn(
|
|
103
|
-
`[export-pptx] unrecognized
|
|
170
|
+
`[export-pptx] unrecognized shape geometry "${shapeType}", defaulting to a rectangle`,
|
|
104
171
|
);
|
|
105
|
-
return
|
|
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
|
+
/**
|
|
198
|
+
* PowerPoint's `a:spcPct val="100000"` is 100% of the font's *natural* line
|
|
199
|
+
* height, which CSS renders as `line-height: 1.2`, so the importer multiplies a
|
|
200
|
+
* declared percentage by that ratio. Writing the CSS value back out as `lnSpc`
|
|
201
|
+
* unchanged applied that correction a second time, and every imported paragraph
|
|
202
|
+
* came back 20% looser than the source. Neither copy of the constant is
|
|
203
|
+
* exported — `SINGLE_LINE_SPACING_RATIO` in packages/core/src/ingestion/pptx.ts
|
|
204
|
+
* and `DEFAULT_LINE_SPACING` in server/handlers/import/html-converter.ts — so
|
|
205
|
+
* all three have to move together.
|
|
206
|
+
*/
|
|
207
|
+
const SINGLE_LINE_SPACING_RATIO = 1.2;
|
|
208
|
+
|
|
209
|
+
/** A CSS line-height ratio as the single-spacing multiple `lnSpc` measures. */
|
|
210
|
+
function cssLineHeightToSpacingMultiple(
|
|
211
|
+
lineHeight: number | undefined,
|
|
212
|
+
): number | undefined {
|
|
213
|
+
return lineHeight !== undefined &&
|
|
214
|
+
Number.isFinite(lineHeight) &&
|
|
215
|
+
lineHeight > 0
|
|
216
|
+
? lineHeight / SINGLE_LINE_SPACING_RATIO
|
|
217
|
+
: undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Parse a CSS `border` shorthand. `none`/`hidden` borders yield `undefined`. */
|
|
221
|
+
function parseCssBorder(
|
|
222
|
+
border: string | null | undefined,
|
|
223
|
+
): ParsedCssBorder | undefined {
|
|
224
|
+
const match = border?.match(CSS_BORDER_STYLE_PATTERN);
|
|
225
|
+
if (!match) return undefined;
|
|
226
|
+
return {
|
|
227
|
+
widthPx: Number.parseFloat(match[1]),
|
|
228
|
+
dashType: CSS_BORDER_DASH_TYPES[match[2].toLowerCase()],
|
|
229
|
+
color: match[3],
|
|
230
|
+
};
|
|
106
231
|
}
|
|
107
232
|
|
|
108
233
|
/**
|
|
@@ -129,10 +254,46 @@ function pxToPt(
|
|
|
129
254
|
return Math.round((px / pxPerInch) * 72);
|
|
130
255
|
}
|
|
131
256
|
|
|
257
|
+
const EMU_PER_INCH = 914400;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The page size an imported slide carries from its source `<p:sldSz>`. The
|
|
261
|
+
* importer lays elements out against the nearest `ASPECT_RATIOS` preset box, so
|
|
262
|
+
* exporting onto that preset's inches instead re-pages the deck: a 10x5.625in
|
|
263
|
+
* source comes back 13.33x7.5in, and a 16:10 source (creandum) comes back 16:9
|
|
264
|
+
* with every element stretched 11% vertically, because the preset it snapped to
|
|
265
|
+
* is not its real shape. Scaling the preset's px box onto the source's own
|
|
266
|
+
* inches makes both round trips exact.
|
|
267
|
+
*/
|
|
268
|
+
export function sourcePageInches(
|
|
269
|
+
html: string,
|
|
270
|
+
): { w: number; h: number } | undefined {
|
|
271
|
+
const widthEmu = Number(html.match(/data-slide-width-emu="(\d+)"/)?.[1]);
|
|
272
|
+
const heightEmu = Number(html.match(/data-slide-height-emu="(\d+)"/)?.[1]);
|
|
273
|
+
if (!widthEmu || !heightEmu) return undefined;
|
|
274
|
+
const w = widthEmu / EMU_PER_INCH;
|
|
275
|
+
const h = heightEmu / EMU_PER_INCH;
|
|
276
|
+
// PowerPoint itself refuses a page outside 1-56in; a value beyond that is a
|
|
277
|
+
// corrupt attribute, not a page size, and must not silently re-page the deck.
|
|
278
|
+
if (w < 1 || w > 56 || h < 1 || h > 56) {
|
|
279
|
+
console.warn(
|
|
280
|
+
`[export-pptx] ignoring out-of-range source page size ${widthEmu}x${heightEmu} EMU`,
|
|
281
|
+
);
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
return { w, h };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `dims` re-based onto the source page size when the slide declares one. */
|
|
288
|
+
function withSourcePageInches(dims: SlideDims, html: string): SlideDims {
|
|
289
|
+
const pptxInches = sourcePageInches(html);
|
|
290
|
+
return pptxInches ? { ...dims, pptxInches } : dims;
|
|
291
|
+
}
|
|
292
|
+
|
|
132
293
|
interface TextElement {
|
|
133
294
|
text: string;
|
|
134
|
-
fontSize
|
|
135
|
-
fontFace
|
|
295
|
+
fontSize?: number; // in pt; omitted when the source declares none
|
|
296
|
+
fontFace?: string; // omitted when the source declares none
|
|
136
297
|
color: string; // 6-char hex
|
|
137
298
|
transparency?: number; // 0-100, percent transparent
|
|
138
299
|
bold: boolean;
|
|
@@ -142,8 +303,9 @@ interface TextElement {
|
|
|
142
303
|
h: number; // inches
|
|
143
304
|
align?: "left" | "center" | "right";
|
|
144
305
|
letterSpacing?: number;
|
|
145
|
-
|
|
306
|
+
lineSpacingMultiple?: number;
|
|
146
307
|
runs?: TextRunElement[];
|
|
308
|
+
rotate?: number; // degrees clockwise
|
|
147
309
|
order?: number;
|
|
148
310
|
}
|
|
149
311
|
|
|
@@ -166,6 +328,7 @@ interface ImageElement {
|
|
|
166
328
|
y: number;
|
|
167
329
|
w: number;
|
|
168
330
|
h: number;
|
|
331
|
+
rotate?: number; // degrees clockwise
|
|
169
332
|
order?: number;
|
|
170
333
|
}
|
|
171
334
|
|
|
@@ -178,8 +341,15 @@ interface ShapeElement {
|
|
|
178
341
|
fillTransparency?: number; // 0-100, percent transparent
|
|
179
342
|
lineColor?: string;
|
|
180
343
|
lineTransparency?: number; // 0-100, percent transparent
|
|
181
|
-
lineWidth?: number;
|
|
182
|
-
|
|
344
|
+
lineWidth?: number; // in pt
|
|
345
|
+
lineDashType?: NonNullable<PptxGenJS.ShapeLineProps["dashType"]>;
|
|
346
|
+
lineHeadType?: NonNullable<PptxGenJS.ShapeLineProps["beginArrowType"]>;
|
|
347
|
+
lineTailType?: NonNullable<PptxGenJS.ShapeLineProps["endArrowType"]>;
|
|
348
|
+
/** A PowerPoint preset geometry name, or `custGeom` when `points` carry a traced outline. */
|
|
349
|
+
shapeType?: string;
|
|
350
|
+
rectRadius?: number; // inches; roundRect corner radius
|
|
351
|
+
points?: PptxGenJS.ShapeProps["points"];
|
|
352
|
+
rotate?: number; // degrees clockwise
|
|
183
353
|
order?: number;
|
|
184
354
|
}
|
|
185
355
|
|
|
@@ -189,6 +359,10 @@ interface TableElement {
|
|
|
189
359
|
y: number;
|
|
190
360
|
w: number;
|
|
191
361
|
h: number;
|
|
362
|
+
/** Per-column widths in inches, from the source `a:tblGrid`; absent when the HTML declares none. */
|
|
363
|
+
colW?: number[];
|
|
364
|
+
/** Per-row heights in inches, from the source `a:tr/@h`; absent when the HTML declares none. */
|
|
365
|
+
rowH?: number[];
|
|
192
366
|
order?: number;
|
|
193
367
|
}
|
|
194
368
|
|
|
@@ -230,6 +404,8 @@ interface ParsedSlide {
|
|
|
230
404
|
grid?: GridElement;
|
|
231
405
|
bgColor: string;
|
|
232
406
|
bgTransparency?: number; // 0-100, percent transparent
|
|
407
|
+
/** The slide background's CSS gradient, when it has one. `bgColor` still holds its flattened stop for the shape-level path. */
|
|
408
|
+
bgGradient?: string;
|
|
233
409
|
}
|
|
234
410
|
|
|
235
411
|
/**
|
|
@@ -242,7 +418,7 @@ export function parseSlideHtml(
|
|
|
242
418
|
slideNumber = 1,
|
|
243
419
|
): ParsedSlide {
|
|
244
420
|
assertServerPptxExportable(html, slideNumber);
|
|
245
|
-
const dims = getAspectRatioDims(aspectRatio);
|
|
421
|
+
const dims = withSourcePageInches(getAspectRatioDims(aspectRatio), html);
|
|
246
422
|
if (html.includes('data-imported-pdf="true"')) {
|
|
247
423
|
return parseImportedPdfSlideHtml(html, dims);
|
|
248
424
|
}
|
|
@@ -261,14 +437,24 @@ export function parseSlideHtml(
|
|
|
261
437
|
|
|
262
438
|
// Check for background color on the outer .fmd-slide div
|
|
263
439
|
const slideStyleMatch = html.match(/class="fmd-slide"[^>]*style="([^"]*)"/);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
440
|
+
const slideBackground = slideStyleMatch
|
|
441
|
+
? getStyle(slideStyleMatch[1], "background(?:-color)?")
|
|
442
|
+
: null;
|
|
443
|
+
const bgGradient =
|
|
444
|
+
slideBackground && /gradient\(/i.test(slideBackground)
|
|
445
|
+
? slideBackground
|
|
446
|
+
: undefined;
|
|
447
|
+
const parsedBg = cssFillToSolid(slideBackground);
|
|
448
|
+
if (parsedBg) {
|
|
449
|
+
bgColor = parsedBg.hex;
|
|
450
|
+
bgTransparency = parsedBg.transparency;
|
|
271
451
|
}
|
|
452
|
+
// Deck templates set the family once on the wrapper; individual headings and
|
|
453
|
+
// paragraphs only override it. Reading it is what keeps a Work Sans or
|
|
454
|
+
// Montserrat design system from exporting as this template's default.
|
|
455
|
+
const slideFontFace = slideStyleMatch
|
|
456
|
+
? cssFontFace(slideStyleMatch[1])
|
|
457
|
+
: undefined;
|
|
272
458
|
|
|
273
459
|
// Extract padding from the .fmd-slide wrapper
|
|
274
460
|
const paddingStr = slideStyleMatch
|
|
@@ -421,7 +607,7 @@ export function parseSlideHtml(
|
|
|
421
607
|
texts.push({
|
|
422
608
|
text,
|
|
423
609
|
fontSize: pxToPt(fontSize, dims),
|
|
424
|
-
fontFace:
|
|
610
|
+
fontFace: cssFontFace(style) ?? slideFontFace ?? DEFAULT_DECK_FONT_FACE,
|
|
425
611
|
color: parsedColor.hex,
|
|
426
612
|
transparency: parsedColor.transparency,
|
|
427
613
|
bold,
|
|
@@ -430,24 +616,52 @@ export function parseSlideHtml(
|
|
|
430
616
|
w: contentW,
|
|
431
617
|
h: elHeight + 0.2,
|
|
432
618
|
letterSpacing: letterSpacing ? parseFloat(letterSpacing) : undefined,
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
619
|
+
// Editor-authored HTML already uses the multiple pptxgenjs expects;
|
|
620
|
+
// the importer-only correction belongs in parseImportedSlideHtml.
|
|
621
|
+
lineSpacingMultiple: lineH,
|
|
436
622
|
});
|
|
437
623
|
|
|
438
624
|
yPos += elHeight + pxToIn(marginBottom, dims) + 0.1;
|
|
439
625
|
}
|
|
440
626
|
}
|
|
441
627
|
|
|
442
|
-
return {
|
|
628
|
+
return {
|
|
629
|
+
texts,
|
|
630
|
+
images,
|
|
631
|
+
shapes,
|
|
632
|
+
tables: [],
|
|
633
|
+
bgColor,
|
|
634
|
+
bgTransparency,
|
|
635
|
+
...(bgGradient ? { bgGradient } : {}),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Structural rather than `ReturnType<typeof getAspectRatioDims>`: the px box
|
|
641
|
+
* stays the aspect-ratio preset the importer laid out against, while
|
|
642
|
+
* `pptxInches` is re-based onto the source page size, so the two halves no
|
|
643
|
+
* longer come from the same preset literal.
|
|
644
|
+
*/
|
|
645
|
+
interface SlideDims {
|
|
646
|
+
width: number;
|
|
647
|
+
height: number;
|
|
648
|
+
pptxInches: { w: number; h: number };
|
|
443
649
|
}
|
|
444
650
|
|
|
445
|
-
|
|
651
|
+
/**
|
|
652
|
+
* The `.fmd-slide` wrapper's inline style. The delimiter has to be captured and
|
|
653
|
+
* back-referenced: the importer writes `font-family: 'Work Sans', sans-serif`
|
|
654
|
+
* into a double-quoted attribute, so a naive `[^"']*` body silently truncates
|
|
655
|
+
* the style at the first font name and hides every declaration after it.
|
|
656
|
+
*/
|
|
657
|
+
function slideWrapperStyle(html: string): string | undefined {
|
|
658
|
+
return html.match(
|
|
659
|
+
/class=(["'])[^"']*\bfmd-slide\b[^"']*\1[^>]*\bstyle=(["'])([\s\S]*?)\2/i,
|
|
660
|
+
)?.[3];
|
|
661
|
+
}
|
|
446
662
|
|
|
447
663
|
function parseImportedPdfSlideHtml(html: string, dims: SlideDims): ParsedSlide {
|
|
448
|
-
const outerStyle = html
|
|
449
|
-
/class=["'][^"']*\bfmd-slide\b[^"']*["'][^>]*style=["']([^"']*)["']/i,
|
|
450
|
-
)?.[1];
|
|
664
|
+
const outerStyle = slideWrapperStyle(html);
|
|
451
665
|
const parsedBg = colorToHex(
|
|
452
666
|
outerStyle
|
|
453
667
|
? (getStyle(outerStyle, "background(?:-color)?") ?? "#000000") // guard:allow-raw-color - imported PDF fallback
|
|
@@ -504,21 +718,29 @@ function parseImportedPdfSlideHtml(html: string, dims: SlideDims): ParsedSlide {
|
|
|
504
718
|
};
|
|
505
719
|
}
|
|
506
720
|
|
|
721
|
+
// Must stay identical to `DEFAULT_PPTX_BACKGROUND` / `DEFAULT_PPTX_FOREGROUND`
|
|
722
|
+
// in server/handlers/import/html-converter.ts. Export previously inverted both
|
|
723
|
+
// (black background, white text), so an undecorated slide came back out of a
|
|
724
|
+
// round trip with its colors flipped rather than unchanged.
|
|
725
|
+
const IMPORTED_PPTX_BACKGROUND_FALLBACK = "#ffffff"; // guard:allow-raw-color - mirrors the importer's PPTX default
|
|
726
|
+
const IMPORTED_PPTX_FOREGROUND_FALLBACK = "111827"; // guard:allow-raw-color - mirrors the importer's PPTX default
|
|
727
|
+
|
|
507
728
|
function parseImportedSlideHtml(html: string, dims: SlideDims): ParsedSlide {
|
|
508
729
|
const texts: TextElement[] = [];
|
|
509
730
|
const images: ImageElement[] = [];
|
|
510
731
|
const shapes: ShapeElement[] = [];
|
|
511
732
|
const tables: TableElement[] = [];
|
|
512
|
-
const outerStyle = html
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
: "#000000", // guard:allow-raw-color - PPTX background fallback
|
|
519
|
-
);
|
|
733
|
+
const outerStyle = slideWrapperStyle(html);
|
|
734
|
+
const background = outerStyle
|
|
735
|
+
? getStyle(outerStyle, "background(?:-color)?")
|
|
736
|
+
: undefined;
|
|
737
|
+
const parsedBg =
|
|
738
|
+
cssFillToSolid(background) ?? colorToHex(IMPORTED_PPTX_BACKGROUND_FALLBACK);
|
|
520
739
|
const bgColor = parsedBg.hex;
|
|
521
740
|
const bgTransparency = parsedBg.transparency;
|
|
741
|
+
const bgGradient =
|
|
742
|
+
background && /gradient\(/i.test(background) ? background : undefined;
|
|
743
|
+
const slideFontFace = outerStyle ? cssFontFace(outerStyle) : undefined;
|
|
522
744
|
const grid = outerStyle ? parseImportedGrid(outerStyle) : undefined;
|
|
523
745
|
const elementRegex =
|
|
524
746
|
/<div\b([^>]*\bdata-pptx-element-kind=["'](text|image|shape|table)["'][^>]*)>([\s\S]*?)<\/div>/gi;
|
|
@@ -530,36 +752,56 @@ function parseImportedSlideHtml(html: string, dims: SlideDims): ParsedSlide {
|
|
|
530
752
|
const style = getAttribute(attrs, "style") ?? "";
|
|
531
753
|
const geometry = importedGeometry(style, dims);
|
|
532
754
|
if (!geometry) continue;
|
|
755
|
+
const rotate = importedRotation(style);
|
|
533
756
|
|
|
534
757
|
if (kind === "image") {
|
|
535
758
|
const imageAttrs = innerHtml.match(/<img\b([^>]*)>/i)?.[1] ?? "";
|
|
536
759
|
const src = getAttribute(imageAttrs, "src");
|
|
537
760
|
if (src) {
|
|
538
|
-
images.push({
|
|
761
|
+
images.push({
|
|
762
|
+
src,
|
|
763
|
+
...geometry,
|
|
764
|
+
...(rotate != null ? { rotate } : {}),
|
|
765
|
+
order: match.index,
|
|
766
|
+
});
|
|
539
767
|
}
|
|
540
768
|
continue;
|
|
541
769
|
}
|
|
542
770
|
|
|
543
771
|
if (kind === "shape") {
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
const
|
|
772
|
+
const parsedFill = cssFillToSolid(
|
|
773
|
+
getStyle(style, "background(?:-color)?"),
|
|
774
|
+
);
|
|
775
|
+
const border = parseCssBorder(getStyle(style, "border"));
|
|
776
|
+
const parsedLine = border ? colorToHex(border.color) : undefined;
|
|
777
|
+
const outline = importedFreeformStroke(innerHtml, dims);
|
|
778
|
+
const line = importedLineStroke(style, innerHtml, dims);
|
|
550
779
|
shapes.push({
|
|
551
780
|
...geometry,
|
|
552
781
|
...(parsedFill
|
|
553
782
|
? { fill: parsedFill.hex, fillTransparency: parsedFill.transparency }
|
|
554
783
|
: {}),
|
|
555
|
-
...(
|
|
784
|
+
...(border && parsedLine
|
|
556
785
|
? {
|
|
557
786
|
lineColor: parsedLine.hex,
|
|
558
787
|
lineTransparency: parsedLine.transparency,
|
|
559
|
-
|
|
788
|
+
// `pxToPt` rounds, so a hairline needs the same 0.5pt floor the
|
|
789
|
+
// table borders use or the outline rounds away to nothing.
|
|
790
|
+
lineWidth: Math.max(0.5, pxToPt(border.widthPx, dims)),
|
|
791
|
+
...(border.dashType ? { lineDashType: border.dashType } : {}),
|
|
560
792
|
}
|
|
561
|
-
:
|
|
562
|
-
|
|
793
|
+
: outline
|
|
794
|
+
? {
|
|
795
|
+
lineColor: outline.color.hex,
|
|
796
|
+
lineTransparency: outline.color.transparency,
|
|
797
|
+
lineWidth: outline.width,
|
|
798
|
+
}
|
|
799
|
+
: {}),
|
|
800
|
+
...importedShapeGeometry(attrs, style, outline?.path, geometry, dims),
|
|
801
|
+
// A single-edge border is the importer's own verdict that this box is a
|
|
802
|
+
// line rather than an outline, so it settles the geometry too.
|
|
803
|
+
...line,
|
|
804
|
+
...(rotate != null ? { rotate } : {}),
|
|
563
805
|
order: match.index,
|
|
564
806
|
});
|
|
565
807
|
continue;
|
|
@@ -568,7 +810,12 @@ function parseImportedSlideHtml(html: string, dims: SlideDims): ParsedSlide {
|
|
|
568
810
|
if (kind === "table") {
|
|
569
811
|
const rows = importedTableRows(innerHtml, dims);
|
|
570
812
|
if (rows.length > 0) {
|
|
571
|
-
tables.push({
|
|
813
|
+
tables.push({
|
|
814
|
+
...geometry,
|
|
815
|
+
rows,
|
|
816
|
+
...importedTableTracks(innerHtml, rows, geometry),
|
|
817
|
+
order: match.index,
|
|
818
|
+
});
|
|
572
819
|
}
|
|
573
820
|
continue;
|
|
574
821
|
}
|
|
@@ -581,28 +828,50 @@ function parseImportedSlideHtml(html: string, dims: SlideDims): ParsedSlide {
|
|
|
581
828
|
const lineHeight = firstParagraph
|
|
582
829
|
? getStyle(firstParagraph, "line-height")
|
|
583
830
|
: null;
|
|
584
|
-
const fontSize = firstRun?.options.fontSize ?? 18;
|
|
585
831
|
const alignValue = getStyle(style, "text-align");
|
|
832
|
+
// Per-run faces still win inside `addText`; this only supplies the
|
|
833
|
+
// box-level default for runs that declare none, so it must be the source
|
|
834
|
+
// deck's own theme font rather than this template's.
|
|
835
|
+
const boxFontFace = firstRun?.options.fontFace ?? slideFontFace;
|
|
586
836
|
texts.push({
|
|
587
837
|
text: runs.map((run) => run.text).join(""),
|
|
588
|
-
fontSize
|
|
589
|
-
|
|
590
|
-
|
|
838
|
+
...(firstRun?.options.fontSize != null
|
|
839
|
+
? { fontSize: firstRun.options.fontSize }
|
|
840
|
+
: {}),
|
|
841
|
+
...(boxFontFace ? { fontFace: boxFontFace } : {}),
|
|
842
|
+
color: firstRun?.options.color ?? IMPORTED_PPTX_FOREGROUND_FALLBACK,
|
|
591
843
|
transparency: firstRun?.options.transparency,
|
|
592
|
-
|
|
844
|
+
// pptxgenjs copies a box-level option onto any run whose own value is
|
|
845
|
+
// falsy (`!textObj.options[key]`), so a box default of `bold: true` taken
|
|
846
|
+
// from the first run overwrites every later run that said `bold: false` —
|
|
847
|
+
// a bold heading made its whole text box bold. The box may only default
|
|
848
|
+
// to bold when the runs already agree.
|
|
849
|
+
bold: runs.length > 0 && runs.every((run) => run.options.bold === true),
|
|
593
850
|
align:
|
|
594
851
|
alignValue === "center" || alignValue === "right" ? alignValue : "left",
|
|
595
|
-
|
|
852
|
+
lineSpacingMultiple: cssLineHeightToSpacingMultiple(
|
|
853
|
+
lineHeight ? Number(lineHeight) : undefined,
|
|
854
|
+
),
|
|
596
855
|
x: geometry.x,
|
|
597
856
|
y: geometry.y,
|
|
598
857
|
w: geometry.w,
|
|
599
858
|
h: geometry.h,
|
|
600
859
|
runs,
|
|
860
|
+
...(rotate != null ? { rotate } : {}),
|
|
601
861
|
order: match.index,
|
|
602
862
|
});
|
|
603
863
|
}
|
|
604
864
|
|
|
605
|
-
return {
|
|
865
|
+
return {
|
|
866
|
+
texts,
|
|
867
|
+
images,
|
|
868
|
+
shapes,
|
|
869
|
+
tables,
|
|
870
|
+
grid,
|
|
871
|
+
bgColor,
|
|
872
|
+
bgTransparency,
|
|
873
|
+
...(bgGradient ? { bgGradient } : {}),
|
|
874
|
+
};
|
|
606
875
|
}
|
|
607
876
|
|
|
608
877
|
function parseImportedGrid(style: string): GridElement | undefined {
|
|
@@ -665,6 +934,417 @@ function pxToInY(px: number, dims: SlideDims): number {
|
|
|
665
934
|
return (px / dims.height) * dims.pptxInches.h;
|
|
666
935
|
}
|
|
667
936
|
|
|
937
|
+
/**
|
|
938
|
+
* The `<a:xfrm rot="...">` the importer rendered as `transform: rotate(Ndeg)`.
|
|
939
|
+
* Dropping it exports a rotated object square to the slide, which on a ring of
|
|
940
|
+
* six curved arrows leaves six copies of the same arrow stacked at the top
|
|
941
|
+
* rather than a ring.
|
|
942
|
+
*/
|
|
943
|
+
function importedRotation(style: string): number | undefined {
|
|
944
|
+
const degrees = Number.parseFloat(
|
|
945
|
+
getStyle(style, "transform")?.match(
|
|
946
|
+
/rotate\(\s*(-?[\d.]+)deg\s*\)/i,
|
|
947
|
+
)?.[1] ?? "",
|
|
948
|
+
);
|
|
949
|
+
return Number.isFinite(degrees) && degrees !== 0 ? degrees : undefined;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Recover the shape's PowerPoint geometry. `<a:prstGeom prst="...">` is the
|
|
954
|
+
* only lossless carrier — CSS cannot distinguish a trapezoid from a hexagon
|
|
955
|
+
* once both are `clip-path: polygon(...)` — so the importer's
|
|
956
|
+
* `data-pptx-shape-type` attribute wins when present. Otherwise trace what CSS
|
|
957
|
+
* can prove: a freeform outline and a polygon become custom outlines, and a
|
|
958
|
+
* border radius becomes an ellipse or a real corner radius instead of
|
|
959
|
+
* pptxgen's default roundRect.
|
|
960
|
+
*/
|
|
961
|
+
function importedShapeGeometry(
|
|
962
|
+
attrs: string,
|
|
963
|
+
style: string,
|
|
964
|
+
strokeOutline: string | undefined,
|
|
965
|
+
size: { w: number; h: number },
|
|
966
|
+
dims: SlideDims,
|
|
967
|
+
): Pick<ShapeElement, "shapeType" | "rectRadius" | "points"> {
|
|
968
|
+
const preset = getAttribute(attrs, "data-pptx-shape-type");
|
|
969
|
+
if (preset) return { shapeType: preset };
|
|
970
|
+
|
|
971
|
+
const clipPath = getStyle(style, "clip-path");
|
|
972
|
+
// A stroke-only freeform carries no clip (clipping an unfilled box would eat
|
|
973
|
+
// half the stroke), so its overlay path is the only outline it has.
|
|
974
|
+
const outline =
|
|
975
|
+
clipPath?.match(/path\(\s*(["']?)([^"')]*)\1\s*\)/i)?.[2] ?? strokeOutline;
|
|
976
|
+
if (outline) {
|
|
977
|
+
const points = svgPathPoints(outline, dims);
|
|
978
|
+
if (points) return { shapeType: "custGeom", points };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const polygon = clipPath?.match(/polygon\(([^)]*)\)/i)?.[1];
|
|
982
|
+
if (polygon) {
|
|
983
|
+
const points = clipPathPolygonPoints(polygon, size, dims);
|
|
984
|
+
if (points) return { shapeType: "custGeom", points };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const radius = getStyle(style, "border-radius")?.split(/\s+/)[0];
|
|
988
|
+
if (!radius) return {};
|
|
989
|
+
const shortSide = Math.min(size.w, size.h);
|
|
990
|
+
const value = Number.parseFloat(radius);
|
|
991
|
+
if (!Number.isFinite(value) || value <= 0) return {};
|
|
992
|
+
if (radius.endsWith("%")) {
|
|
993
|
+
return value >= 50
|
|
994
|
+
? { shapeType: "ellipse" }
|
|
995
|
+
: { shapeType: "roundRect", rectRadius: (value / 100) * shortSide };
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
shapeType: "roundRect",
|
|
999
|
+
// A pill (`border-radius: 9999px`) clamps to the half-side PowerPoint caps
|
|
1000
|
+
// its `adj` value at, rather than overflowing the shape.
|
|
1001
|
+
rectRadius: Math.min(pxToIn(value, dims), shortSide / 2),
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* A freeform outline's stroke follows the path, not the box, so the importer
|
|
1007
|
+
* draws it as an SVG overlay instead of a `border`. Read that overlay back:
|
|
1008
|
+
* it carries the shape's line color and width, and for a line-art pictogram —
|
|
1009
|
+
* no fill, so nothing to clip — it is the only copy of the outline itself.
|
|
1010
|
+
*/
|
|
1011
|
+
function importedFreeformStroke(
|
|
1012
|
+
innerHtml: string,
|
|
1013
|
+
dims: SlideDims,
|
|
1014
|
+
):
|
|
1015
|
+
| { path: string; color: ReturnType<typeof colorToHex>; width: number }
|
|
1016
|
+
| undefined {
|
|
1017
|
+
const pathAttrs = innerHtml.match(/<svg\b[^>]*>\s*<path\b([^>]*)>/i)?.[1];
|
|
1018
|
+
if (!pathAttrs) return undefined;
|
|
1019
|
+
const path = getAttribute(pathAttrs, "d");
|
|
1020
|
+
const stroke = getAttribute(pathAttrs, "stroke");
|
|
1021
|
+
if (!path || !stroke) return undefined;
|
|
1022
|
+
const widthPx = Number.parseFloat(
|
|
1023
|
+
getAttribute(pathAttrs, "stroke-width") ?? "1",
|
|
1024
|
+
);
|
|
1025
|
+
return {
|
|
1026
|
+
path: decodeHtmlText(path),
|
|
1027
|
+
color: colorToHex(stroke),
|
|
1028
|
+
// The same 0.5pt floor the CSS-border path uses: `pxToPt` rounds, and a
|
|
1029
|
+
// hairline outline that rounds to 0pt is an invisible shape.
|
|
1030
|
+
width: Math.max(0.5, pxToPt(Number.isFinite(widthPx) ? widthPx : 1, dims)),
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** The line axis each single-edge border the importer writes draws along. */
|
|
1035
|
+
const SINGLE_EDGE_BORDER_AXES = {
|
|
1036
|
+
"border-top": "x",
|
|
1037
|
+
"border-left": "y",
|
|
1038
|
+
} as const;
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* The stroke of a `<p:cxnSp>` or a degenerate `<p:sp>` — a box with one
|
|
1042
|
+
* dimension of zero. The importer draws those with a single `border-left` or
|
|
1043
|
+
* `border-top` rather than the `border` shorthand (`strokeDecoration` in
|
|
1044
|
+
* server/handlers/import/html-converter.ts), which `parseCssBorder` above
|
|
1045
|
+
* cannot see, so every connector used to export as a `rect` of `cx="0"` with
|
|
1046
|
+
* `<a:noFill/>` and an empty `<a:ln/>`: no width, no color, no ends, nothing
|
|
1047
|
+
* drawn. Reading the edge the importer actually wrote gives back the real line.
|
|
1048
|
+
*/
|
|
1049
|
+
function importedLineStroke(
|
|
1050
|
+
style: string,
|
|
1051
|
+
innerHtml: string,
|
|
1052
|
+
dims: SlideDims,
|
|
1053
|
+
):
|
|
1054
|
+
| Pick<
|
|
1055
|
+
ShapeElement,
|
|
1056
|
+
| "shapeType"
|
|
1057
|
+
| "lineColor"
|
|
1058
|
+
| "lineTransparency"
|
|
1059
|
+
| "lineWidth"
|
|
1060
|
+
| "lineDashType"
|
|
1061
|
+
| "lineHeadType"
|
|
1062
|
+
| "lineTailType"
|
|
1063
|
+
>
|
|
1064
|
+
| undefined {
|
|
1065
|
+
for (const [property, axis] of Object.entries(SINGLE_EDGE_BORDER_AXES)) {
|
|
1066
|
+
const border = parseCssBorder(getStyle(style, property));
|
|
1067
|
+
if (!border) continue;
|
|
1068
|
+
const color = colorToHex(border.color);
|
|
1069
|
+
return {
|
|
1070
|
+
shapeType: "line",
|
|
1071
|
+
lineColor: color.hex,
|
|
1072
|
+
lineTransparency: color.transparency,
|
|
1073
|
+
// The same 0.5pt floor the outline paths use, for the same reason:
|
|
1074
|
+
// `pxToPt` rounds, and a hairline that rounds to 0pt draws nothing.
|
|
1075
|
+
lineWidth: Math.max(0.5, pxToPt(border.widthPx, dims)),
|
|
1076
|
+
...(border.dashType ? { lineDashType: border.dashType } : {}),
|
|
1077
|
+
...importedLineEndTypes(innerHtml, axis),
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
return undefined;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The `<a:headEnd>`/`<a:tailEnd>` the importer redraws as absolutely
|
|
1085
|
+
* positioned `<circle>` overlays (`lineEndCaps` in html-converter.ts). It only
|
|
1086
|
+
* ever reproduces `oval`, and it centers the overlay on the line, so a circle
|
|
1087
|
+
* before the viewBox midpoint caps the head and one after it caps the tail —
|
|
1088
|
+
* still true for a flipped source, because the importer swapped the two before
|
|
1089
|
+
* drawing them.
|
|
1090
|
+
*/
|
|
1091
|
+
function importedLineEndTypes(
|
|
1092
|
+
innerHtml: string,
|
|
1093
|
+
axis: "x" | "y",
|
|
1094
|
+
): Pick<ShapeElement, "lineHeadType" | "lineTailType"> {
|
|
1095
|
+
const svg = innerHtml.match(/<svg\b([^>]*)>([\s\S]*?)<\/svg>/i);
|
|
1096
|
+
const viewBox = getAttribute(svg?.[1] ?? "", "viewBox")
|
|
1097
|
+
?.trim()
|
|
1098
|
+
.split(/[\s,]+/)
|
|
1099
|
+
.map(Number);
|
|
1100
|
+
if (!svg || viewBox?.length !== 4 || !viewBox.every(Number.isFinite)) {
|
|
1101
|
+
return {};
|
|
1102
|
+
}
|
|
1103
|
+
const middle = (axis === "x" ? viewBox[2] : viewBox[3]) / 2;
|
|
1104
|
+
const ends: Pick<ShapeElement, "lineHeadType" | "lineTailType"> = {};
|
|
1105
|
+
for (const circle of svg[2].matchAll(/<circle\b([^>]*)>/gi)) {
|
|
1106
|
+
const alongAttribute = getAttribute(circle[1], axis === "x" ? "cx" : "cy");
|
|
1107
|
+
if (alongAttribute == null) continue;
|
|
1108
|
+
const along = Number(alongAttribute);
|
|
1109
|
+
if (!Number.isFinite(along)) continue;
|
|
1110
|
+
if (along < middle) ends.lineHeadType = "oval";
|
|
1111
|
+
else ends.lineTailType = "oval";
|
|
1112
|
+
}
|
|
1113
|
+
return ends;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
type CustomGeometryPoint = NonNullable<ShapeElement["points"]>[number];
|
|
1117
|
+
|
|
1118
|
+
/** Numbers each SVG path command consumes. The importer emits only these. */
|
|
1119
|
+
const SVG_PATH_ARITY: Record<string, number> = {
|
|
1120
|
+
M: 2,
|
|
1121
|
+
L: 2,
|
|
1122
|
+
Q: 4,
|
|
1123
|
+
C: 6,
|
|
1124
|
+
A: 7,
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* One SVG number. The importer writes path data in its shortest legal
|
|
1129
|
+
* spelling — `-1.7 0-1.2-.2` is four numbers, not one token per space — so
|
|
1130
|
+
* splitting on whitespace reads a 200-country map as a single unreadable
|
|
1131
|
+
* command and exports the whole thing as rectangles.
|
|
1132
|
+
*/
|
|
1133
|
+
const SVG_PATH_NUMBER = /[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi;
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Trace the importer's freeform outline — `clip-path: path('...')`, or the
|
|
1137
|
+
* stroke overlay it draws for an unfilled one — as pptxgenjs custom geometry.
|
|
1138
|
+
* Every command `customGeometryPath`/`blockArcPath` emit has an exact
|
|
1139
|
+
* counterpart in `ShapeProps.points`: `moveTo`/`lnTo` are bare points and
|
|
1140
|
+
* `cubicBezTo`/`quadBezTo`/`arcTo` are its three curve types, so nothing is
|
|
1141
|
+
* flattened. Coordinates are the shape's own pixel box, which converts to the
|
|
1142
|
+
* inches `addShape` writes into `<a:path>` the same way a polygon's do.
|
|
1143
|
+
*
|
|
1144
|
+
* Returns `undefined` rather than a partial outline when a command cannot be
|
|
1145
|
+
* read, so the caller falls back to a whole rectangle instead of exporting a
|
|
1146
|
+
* fragment of the shape.
|
|
1147
|
+
*/
|
|
1148
|
+
function svgPathPoints(
|
|
1149
|
+
path: string,
|
|
1150
|
+
dims: SlideDims,
|
|
1151
|
+
): NonNullable<ShapeElement["points"]> | undefined {
|
|
1152
|
+
const points: CustomGeometryPoint[] = [];
|
|
1153
|
+
const toX = (px: number) => pxToIn(px, dims);
|
|
1154
|
+
const toY = (px: number) => pxToInY(px, dims);
|
|
1155
|
+
let cursorX = 0;
|
|
1156
|
+
let cursorY = 0;
|
|
1157
|
+
let subpathX = 0;
|
|
1158
|
+
let subpathY = 0;
|
|
1159
|
+
let seenCommand = false;
|
|
1160
|
+
|
|
1161
|
+
for (const match of path.matchAll(/([A-Za-z])([^A-Za-z]*)/g)) {
|
|
1162
|
+
const command = match[1].toUpperCase();
|
|
1163
|
+
const relative = match[1] !== command;
|
|
1164
|
+
seenCommand = true;
|
|
1165
|
+
const args = (match[2].match(SVG_PATH_NUMBER) ?? []).map(Number);
|
|
1166
|
+
if (command === "Z") {
|
|
1167
|
+
if (args.length > 0) return undefined;
|
|
1168
|
+
points.push({ close: true });
|
|
1169
|
+
// `z` returns the pen to where the subpath started; the next relative
|
|
1170
|
+
// command steps from there, not from the last point drawn.
|
|
1171
|
+
cursorX = subpathX;
|
|
1172
|
+
cursorY = subpathY;
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
const arity = SVG_PATH_ARITY[command];
|
|
1176
|
+
if (
|
|
1177
|
+
!arity ||
|
|
1178
|
+
args.length === 0 ||
|
|
1179
|
+
args.length % arity !== 0 ||
|
|
1180
|
+
args.some((value) => !Number.isFinite(value))
|
|
1181
|
+
) {
|
|
1182
|
+
console.warn(
|
|
1183
|
+
`[export-pptx] unreadable SVG path command "${match[0].trim().slice(0, 40)}" in a shape outline; exported as a rectangle`,
|
|
1184
|
+
);
|
|
1185
|
+
return undefined;
|
|
1186
|
+
}
|
|
1187
|
+
for (let i = 0; i < args.length; i += arity) {
|
|
1188
|
+
const chunk = args.slice(i, i + arity);
|
|
1189
|
+
// A relative command steps from the point its own segment starts at —
|
|
1190
|
+
// including a curve's control points, which are offsets from that same
|
|
1191
|
+
// point rather than from each other.
|
|
1192
|
+
const originX = relative ? cursorX : 0;
|
|
1193
|
+
const originY = relative ? cursorY : 0;
|
|
1194
|
+
const atX = (index: number) => originX + chunk[index];
|
|
1195
|
+
const atY = (index: number) => originY + chunk[index];
|
|
1196
|
+
const endX = atX(arity - 2);
|
|
1197
|
+
const endY = atY(arity - 1);
|
|
1198
|
+
if (command === "M") {
|
|
1199
|
+
// A repeated pair after `M` is an implicit lineto, and every `M` past
|
|
1200
|
+
// the first opens a subpath pptxgenjs only reopens for `moveTo`.
|
|
1201
|
+
points.push(
|
|
1202
|
+
i === 0
|
|
1203
|
+
? { x: toX(endX), y: toY(endY), moveTo: true }
|
|
1204
|
+
: { x: toX(endX), y: toY(endY) },
|
|
1205
|
+
);
|
|
1206
|
+
if (i === 0) {
|
|
1207
|
+
subpathX = endX;
|
|
1208
|
+
subpathY = endY;
|
|
1209
|
+
}
|
|
1210
|
+
} else if (command === "L") {
|
|
1211
|
+
points.push({ x: toX(endX), y: toY(endY) });
|
|
1212
|
+
} else if (command === "C") {
|
|
1213
|
+
points.push({
|
|
1214
|
+
x: toX(endX),
|
|
1215
|
+
y: toY(endY),
|
|
1216
|
+
curve: {
|
|
1217
|
+
type: "cubic",
|
|
1218
|
+
x1: toX(atX(0)),
|
|
1219
|
+
y1: toY(atY(1)),
|
|
1220
|
+
x2: toX(atX(2)),
|
|
1221
|
+
y2: toY(atY(3)),
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
} else if (command === "Q") {
|
|
1225
|
+
points.push({
|
|
1226
|
+
x: toX(endX),
|
|
1227
|
+
y: toY(endY),
|
|
1228
|
+
curve: { type: "quadratic", x1: toX(atX(0)), y1: toY(atY(1)) },
|
|
1229
|
+
});
|
|
1230
|
+
} else {
|
|
1231
|
+
// Only an arc's endpoint is relative: its radii and flags are not
|
|
1232
|
+
// coordinates.
|
|
1233
|
+
const arc = svgArcToPptxCurve(
|
|
1234
|
+
cursorX,
|
|
1235
|
+
cursorY,
|
|
1236
|
+
[...chunk.slice(0, 5), endX, endY],
|
|
1237
|
+
dims,
|
|
1238
|
+
);
|
|
1239
|
+
if (!arc) return undefined;
|
|
1240
|
+
points.push(arc);
|
|
1241
|
+
}
|
|
1242
|
+
cursorX = endX;
|
|
1243
|
+
cursorY = endY;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (!seenCommand) return undefined;
|
|
1248
|
+
return points.length > 0 ? points : undefined;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* `<a:arcTo>` names an arc by its radii and its start/swing angles around a
|
|
1253
|
+
* center it derives from the current point; SVG's `A` names the same arc by
|
|
1254
|
+
* its endpoint plus two flags. This is the SVG spec's endpoint-to-center
|
|
1255
|
+
* conversion (F.6.5) with the x-axis rotation the importer never emits left
|
|
1256
|
+
* out, so the ring segments and rounded freeforms come back as true arcs
|
|
1257
|
+
* rather than as chords across them.
|
|
1258
|
+
*/
|
|
1259
|
+
function svgArcToPptxCurve(
|
|
1260
|
+
startX: number,
|
|
1261
|
+
startY: number,
|
|
1262
|
+
[radiusX, radiusY, rotation, largeArc, sweep, endX, endY]: number[],
|
|
1263
|
+
dims: SlideDims,
|
|
1264
|
+
): CustomGeometryPoint | undefined {
|
|
1265
|
+
if (rotation !== 0) {
|
|
1266
|
+
console.warn(
|
|
1267
|
+
`[export-pptx] rotated elliptical arc (${rotation}deg) in a shape outline; exported as a rectangle`,
|
|
1268
|
+
);
|
|
1269
|
+
return undefined;
|
|
1270
|
+
}
|
|
1271
|
+
const point = { x: pxToIn(endX, dims), y: pxToInY(endY, dims) };
|
|
1272
|
+
let rx = Math.abs(radiusX);
|
|
1273
|
+
let ry = Math.abs(radiusY);
|
|
1274
|
+
// A zero radius is a straight line in SVG, and PowerPoint draws nothing at
|
|
1275
|
+
// all for one.
|
|
1276
|
+
if (!(rx > 0) || !(ry > 0)) return point;
|
|
1277
|
+
|
|
1278
|
+
const midX = (startX - endX) / 2;
|
|
1279
|
+
const midY = (startY - endY) / 2;
|
|
1280
|
+
const scale = (midX * midX) / (rx * rx) + (midY * midY) / (ry * ry);
|
|
1281
|
+
if (scale > 1) {
|
|
1282
|
+
rx *= Math.sqrt(scale);
|
|
1283
|
+
ry *= Math.sqrt(scale);
|
|
1284
|
+
}
|
|
1285
|
+
const denominator = rx * rx * midY * midY + ry * ry * midX * midX;
|
|
1286
|
+
const numerator = rx * rx * ry * ry - denominator;
|
|
1287
|
+
const factor =
|
|
1288
|
+
(largeArc !== sweep ? 1 : -1) *
|
|
1289
|
+
Math.sqrt(Math.max(0, numerator / denominator));
|
|
1290
|
+
const centerX = (factor * (rx * midY)) / ry + (startX + endX) / 2;
|
|
1291
|
+
const centerY = (-factor * (ry * midX)) / rx + (startY + endY) / 2;
|
|
1292
|
+
const startAngle = Math.atan2(
|
|
1293
|
+
(startY - centerY) / ry,
|
|
1294
|
+
(startX - centerX) / rx,
|
|
1295
|
+
);
|
|
1296
|
+
const endAngle = Math.atan2((endY - centerY) / ry, (endX - centerX) / rx);
|
|
1297
|
+
let swingAngle = endAngle - startAngle;
|
|
1298
|
+
if (sweep && swingAngle < 0) swingAngle += 2 * Math.PI;
|
|
1299
|
+
if (!sweep && swingAngle > 0) swingAngle -= 2 * Math.PI;
|
|
1300
|
+
|
|
1301
|
+
const toDegrees = (radians: number) => (radians * 180) / Math.PI;
|
|
1302
|
+
return {
|
|
1303
|
+
...point,
|
|
1304
|
+
curve: {
|
|
1305
|
+
type: "arc",
|
|
1306
|
+
wR: pxToIn(rx, dims),
|
|
1307
|
+
hR: pxToInY(ry, dims),
|
|
1308
|
+
// pptxgenjs subtracts a full turn from anything over 360 before scaling
|
|
1309
|
+
// to 60000ths of a degree, so the start angle has to arrive inside one.
|
|
1310
|
+
stAng: (toDegrees(startAngle) + 360) % 360,
|
|
1311
|
+
swAng: toDegrees(swingAngle),
|
|
1312
|
+
},
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* `clip-path: polygon(x y, ...)` traced as pptxgenjs custom-geometry points,
|
|
1318
|
+
* in inches relative to the shape's own box.
|
|
1319
|
+
*/
|
|
1320
|
+
function clipPathPolygonPoints(
|
|
1321
|
+
polygon: string,
|
|
1322
|
+
size: { w: number; h: number },
|
|
1323
|
+
dims: SlideDims,
|
|
1324
|
+
): NonNullable<ShapeElement["points"]> | undefined {
|
|
1325
|
+
const points: Array<{ x: number; y: number }> = [];
|
|
1326
|
+
for (const pair of polygon.split(",")) {
|
|
1327
|
+
const [rawX, rawY] = pair.trim().split(/\s+/);
|
|
1328
|
+
const x = clipPathCoord(rawX, size.w, dims, pxToIn);
|
|
1329
|
+
const y = clipPathCoord(rawY, size.h, dims, pxToInY);
|
|
1330
|
+
if (x == null || y == null) return undefined;
|
|
1331
|
+
points.push({ x, y });
|
|
1332
|
+
}
|
|
1333
|
+
return points.length >= 3 ? [...points, { close: true }] : undefined;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function clipPathCoord(
|
|
1337
|
+
raw: string | undefined,
|
|
1338
|
+
side: number,
|
|
1339
|
+
dims: SlideDims,
|
|
1340
|
+
toInches: (px: number, dims: SlideDims) => number,
|
|
1341
|
+
): number | undefined {
|
|
1342
|
+
if (!raw) return undefined;
|
|
1343
|
+
const value = Number.parseFloat(raw);
|
|
1344
|
+
if (!Number.isFinite(value)) return undefined;
|
|
1345
|
+
return raw.endsWith("%") ? (value / 100) * side : toInches(value, dims);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
668
1348
|
function importedTextRuns(html: string, dims: SlideDims): TextRunElement[] {
|
|
669
1349
|
const paragraphs = [...html.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)].map(
|
|
670
1350
|
(match) => match[1],
|
|
@@ -690,6 +1370,101 @@ function importedTextRuns(html: string, dims: SlideDims): TextRunElement[] {
|
|
|
690
1370
|
return runs;
|
|
691
1371
|
}
|
|
692
1372
|
|
|
1373
|
+
/**
|
|
1374
|
+
* No table-level `border` or `margin`: pptxgenjs cascades every table option
|
|
1375
|
+
* onto each cell that declares none of its own, so the white rule and the flat
|
|
1376
|
+
* 0.04in padding this used to pass were stamped on every cell of every
|
|
1377
|
+
* imported table — a grid the source never drew, over the cell margins it did.
|
|
1378
|
+
* Both now travel per cell, from that cell's own CSS.
|
|
1379
|
+
*/
|
|
1380
|
+
export function tableOptions(table: TableElement): PptxGenJS.TableProps {
|
|
1381
|
+
return {
|
|
1382
|
+
x: table.x,
|
|
1383
|
+
y: table.y,
|
|
1384
|
+
w: table.w,
|
|
1385
|
+
h: table.h,
|
|
1386
|
+
...(table.colW ? { colW: table.colW } : {}),
|
|
1387
|
+
...(table.rowH ? { rowH: table.rowH } : {}),
|
|
1388
|
+
autoPage: false,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** Inline style of the first `<p>` in a fragment, which is where the importer puts paragraph-level alignment. */
|
|
1393
|
+
function firstParagraphStyle(html: string): string | undefined {
|
|
1394
|
+
return html.match(/<p\b[^>]*style=["']([^"']*)["']/i)?.[1];
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* The `<colgroup>` and `<tr>` percentages the importer derives from
|
|
1399
|
+
* `a:tblGrid/a:gridCol` and `a:tr/@h`, back as the inch sizes `addTable`
|
|
1400
|
+
* needs. Without them pptxgenjs divides the box evenly, so a table exports
|
|
1401
|
+
* with columns and rows the source never had — nine equal columns in place of
|
|
1402
|
+
* gamesfund's 1001175/836125/862000/899775... grid.
|
|
1403
|
+
*
|
|
1404
|
+
* A track list that does not cover every column (pptxgenjs counts colspans) or
|
|
1405
|
+
* every row is dropped whole: a partial `colW` makes pptxgenjs warn and fall
|
|
1406
|
+
* back anyway, and a partial `rowH` would size some rows from the source and
|
|
1407
|
+
* the rest from the box.
|
|
1408
|
+
*/
|
|
1409
|
+
function importedTableTracks(
|
|
1410
|
+
html: string,
|
|
1411
|
+
rows: TableRow[],
|
|
1412
|
+
box: { w: number; h: number },
|
|
1413
|
+
): { colW?: number[]; rowH?: number[] } {
|
|
1414
|
+
const percentages = (pattern: RegExp) => {
|
|
1415
|
+
const values = [...html.matchAll(pattern)].map((match) =>
|
|
1416
|
+
Number.parseFloat(match[1]),
|
|
1417
|
+
);
|
|
1418
|
+
return values.every((value) => Number.isFinite(value) && value > 0)
|
|
1419
|
+
? values
|
|
1420
|
+
: [];
|
|
1421
|
+
};
|
|
1422
|
+
const columnCount = (rows[0] ?? []).reduce(
|
|
1423
|
+
(total, cell) => total + (Number(cell.options?.colspan) || 1),
|
|
1424
|
+
0,
|
|
1425
|
+
);
|
|
1426
|
+
const colW = percentages(
|
|
1427
|
+
/<col\b[^>]*\bstyle=["'][^"']*\bwidth\s*:\s*([\d.]+)%/gi,
|
|
1428
|
+
);
|
|
1429
|
+
const rowH = percentages(
|
|
1430
|
+
/<tr\b[^>]*\bstyle=["'][^"']*\bheight\s*:\s*([\d.]+)%/gi,
|
|
1431
|
+
);
|
|
1432
|
+
return {
|
|
1433
|
+
...(colW.length === columnCount
|
|
1434
|
+
? { colW: colW.map((percent) => (percent / 100) * box.w) }
|
|
1435
|
+
: {}),
|
|
1436
|
+
...(rowH.length === rows.length
|
|
1437
|
+
? { rowH: rowH.map((percent) => (percent / 100) * box.h) }
|
|
1438
|
+
: {}),
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* A cell's CSS `padding` as pptxgenjs's `[top, right, bottom, left]` margin.
|
|
1444
|
+
* The library reads any side >= 1 as points rather than inches, so a padding
|
|
1445
|
+
* that large is left to its default instead of exporting an inch as a point.
|
|
1446
|
+
*/
|
|
1447
|
+
function importedCellMargin(
|
|
1448
|
+
style: string,
|
|
1449
|
+
dims: SlideDims,
|
|
1450
|
+
): [number, number, number, number] | undefined {
|
|
1451
|
+
const parts = getStyle(style, "padding")
|
|
1452
|
+
?.trim()
|
|
1453
|
+
.split(/\s+/)
|
|
1454
|
+
.map((value) => Number.parseFloat(value));
|
|
1455
|
+
if (!parts?.length || parts.length > 4) return undefined;
|
|
1456
|
+
if (parts.some((value) => !Number.isFinite(value) || value < 0))
|
|
1457
|
+
return undefined;
|
|
1458
|
+
const [top, right = top, bottom = top, left = right] = parts;
|
|
1459
|
+
const margin: [number, number, number, number] = [
|
|
1460
|
+
pxToInY(top, dims),
|
|
1461
|
+
pxToIn(right, dims),
|
|
1462
|
+
pxToInY(bottom, dims),
|
|
1463
|
+
pxToIn(left, dims),
|
|
1464
|
+
];
|
|
1465
|
+
return margin.some((inches) => inches >= 1) ? undefined : margin;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
693
1468
|
function importedTableRows(html: string, dims: SlideDims): TableRow[] {
|
|
694
1469
|
return [...html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map(
|
|
695
1470
|
(rowMatch): TableRow =>
|
|
@@ -699,13 +1474,11 @@ function importedTableRows(html: string, dims: SlideDims): TableRow[] {
|
|
|
699
1474
|
const cellHtml = cellMatch[2];
|
|
700
1475
|
const style = getAttribute(attrs, "style") ?? "";
|
|
701
1476
|
const runs = importedTextRuns(cellHtml, dims);
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const
|
|
706
|
-
const parsedBorder =
|
|
707
|
-
? colorToHex(borderMatch[2])
|
|
708
|
-
: undefined;
|
|
1477
|
+
const parsedFill = cssFillToSolid(
|
|
1478
|
+
getStyle(style, "background(?:-color)?"),
|
|
1479
|
+
);
|
|
1480
|
+
const border = parseCssBorder(getStyle(style, "border"));
|
|
1481
|
+
const parsedBorder = border ? colorToHex(border.color) : undefined;
|
|
709
1482
|
const colSpan = Number.parseInt(
|
|
710
1483
|
getAttribute(attrs, "colspan") ?? "",
|
|
711
1484
|
10,
|
|
@@ -714,9 +1487,16 @@ function importedTableRows(html: string, dims: SlideDims): TableRow[] {
|
|
|
714
1487
|
getAttribute(attrs, "rowspan") ?? "",
|
|
715
1488
|
10,
|
|
716
1489
|
);
|
|
717
|
-
|
|
1490
|
+
// The importer writes the cell's alignment on the paragraph, not the
|
|
1491
|
+
// `<td>`; reading only the cell style exported every imported table
|
|
1492
|
+
// left-aligned regardless of what the source `a:pPr algn` said.
|
|
1493
|
+
const align =
|
|
1494
|
+
getStyle(style, "text-align") ??
|
|
1495
|
+
getStyle(firstParagraphStyle(cellHtml) ?? "", "text-align");
|
|
718
1496
|
const verticalAlign = getStyle(style, "vertical-align");
|
|
1497
|
+
const margin = importedCellMargin(style, dims);
|
|
719
1498
|
const options: NonNullable<TableCell["options"]> = {
|
|
1499
|
+
...(margin ? { margin } : {}),
|
|
720
1500
|
...(align === "center" || align === "right" || align === "justify"
|
|
721
1501
|
? { align }
|
|
722
1502
|
: {}),
|
|
@@ -735,15 +1515,16 @@ function importedTableRows(html: string, dims: SlideDims): TableRow[] {
|
|
|
735
1515
|
},
|
|
736
1516
|
}
|
|
737
1517
|
: {}),
|
|
738
|
-
...(parsedBorder &&
|
|
1518
|
+
...(parsedBorder && border
|
|
739
1519
|
? {
|
|
740
1520
|
border: {
|
|
741
|
-
|
|
1521
|
+
// pptxgenjs table borders only offer solid or dash, so a
|
|
1522
|
+
// dotted rule maps to the nearest broken stroke.
|
|
1523
|
+
type: border.dashType
|
|
1524
|
+
? ("dash" as const)
|
|
1525
|
+
: ("solid" as const),
|
|
742
1526
|
color: parsedBorder.hex,
|
|
743
|
-
pt: Math.max(
|
|
744
|
-
0.5,
|
|
745
|
-
pxToPt(Number.parseFloat(borderMatch[1]), dims),
|
|
746
|
-
),
|
|
1527
|
+
pt: Math.max(0.5, pxToPt(border.widthPx, dims)),
|
|
747
1528
|
},
|
|
748
1529
|
}
|
|
749
1530
|
: {}),
|
|
@@ -769,10 +1550,7 @@ function importedRunOptions(
|
|
|
769
1550
|
dims: SlideDims,
|
|
770
1551
|
): TextRunElement["options"] {
|
|
771
1552
|
const fontSizePx = cssPx(style, "font-size");
|
|
772
|
-
const fontFamily =
|
|
773
|
-
?.replace(/["']/g, "")
|
|
774
|
-
.split(",")[0]
|
|
775
|
-
?.trim();
|
|
1553
|
+
const fontFamily = cssFontFace(style);
|
|
776
1554
|
const fontWeight = getStyle(style, "font-weight");
|
|
777
1555
|
const colorValue = getStyle(style, "color");
|
|
778
1556
|
const parsedColor = colorValue
|
|
@@ -826,6 +1604,255 @@ function decodeHtmlText(value: string): string {
|
|
|
826
1604
|
.replace(/●/gi, "●");
|
|
827
1605
|
}
|
|
828
1606
|
|
|
1607
|
+
/** Split a CSS function argument list on top-level commas — an rgb/rgba stop carries its own. */
|
|
1608
|
+
function splitTopLevel(value: string): string[] {
|
|
1609
|
+
const parts: string[] = [];
|
|
1610
|
+
let depth = 0;
|
|
1611
|
+
let start = 0;
|
|
1612
|
+
for (let i = 0; i < value.length; i++) {
|
|
1613
|
+
const char = value[i];
|
|
1614
|
+
if (char === "(") depth++;
|
|
1615
|
+
else if (char === ")") depth--;
|
|
1616
|
+
else if (char === "," && depth === 0) {
|
|
1617
|
+
parts.push(value.slice(start, i));
|
|
1618
|
+
start = i + 1;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
parts.push(value.slice(start));
|
|
1622
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/** `<a:srgbClr>` carrying the alpha an rgba or 8-digit-hex value declares. */
|
|
1626
|
+
function drawingMlColor(parsed: {
|
|
1627
|
+
hex: string;
|
|
1628
|
+
transparency?: number;
|
|
1629
|
+
}): string {
|
|
1630
|
+
return parsed.transparency
|
|
1631
|
+
? `<a:srgbClr val="${parsed.hex}"><a:alpha val="${Math.round((100 - parsed.transparency) * 1000)}"/></a:srgbClr>`
|
|
1632
|
+
: `<a:srgbClr val="${parsed.hex}"/>`;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* The inverse of the importer's `parseGradientFill` (packages/core
|
|
1637
|
+
* `ingestion/pptx.ts`): CSS measures a linear angle clockwise from "up",
|
|
1638
|
+
* OOXML's `<a:lin ang>` clockwise from the positive x-axis, so the two differ
|
|
1639
|
+
* by 90°, and `<a:fillToRect>`'s edge insets collapse to the radial focus.
|
|
1640
|
+
*
|
|
1641
|
+
* Returns `undefined` for a gradient it cannot express (`conic`, an angle unit
|
|
1642
|
+
* other than `deg`, a single stop) so the caller keeps the flattened solid fill
|
|
1643
|
+
* instead of writing a background PowerPoint rejects.
|
|
1644
|
+
*/
|
|
1645
|
+
export function cssGradientToDrawingMl(css: string): string | undefined {
|
|
1646
|
+
const match = css.trim().match(/^(linear|radial)-gradient\(([\s\S]*)\)$/i);
|
|
1647
|
+
if (!match) return undefined;
|
|
1648
|
+
const kind = match[1].toLowerCase();
|
|
1649
|
+
const args = splitTopLevel(match[2]);
|
|
1650
|
+
|
|
1651
|
+
// A gradient's first argument is either its configuration (`45deg`,
|
|
1652
|
+
// `to right`, `circle at 20% 30%`) or already the first color stop, and one
|
|
1653
|
+
// thing tells them apart: a stop starts with a color. Recognizing only the
|
|
1654
|
+
// configuration spellings we had handled let every other one — `to right`, a
|
|
1655
|
+
// bare `circle` — fall through to `colorToHex`, which turned a direction
|
|
1656
|
+
// keyword into a white stop the source never had.
|
|
1657
|
+
const config =
|
|
1658
|
+
args.length > 0 && !parseCssColor(splitGradientStop(args[0]).color)
|
|
1659
|
+
? args[0]
|
|
1660
|
+
: undefined;
|
|
1661
|
+
const stopArgs = config === undefined ? args : args.slice(1);
|
|
1662
|
+
if (stopArgs.length < 2) return undefined;
|
|
1663
|
+
|
|
1664
|
+
let geometry: string;
|
|
1665
|
+
if (kind === "linear") {
|
|
1666
|
+
const cssAngle = linearGradientAngle(config);
|
|
1667
|
+
if (cssAngle === undefined) return undefined;
|
|
1668
|
+
const ang = Math.round(((((cssAngle - 90) % 360) + 360) % 360) * 60000);
|
|
1669
|
+
geometry = `<a:lin ang="${ang}" scaled="0"/>`;
|
|
1670
|
+
} else {
|
|
1671
|
+
const center = radialGradientCenter(config);
|
|
1672
|
+
if (!center) return undefined;
|
|
1673
|
+
const pct = (value: number) => Math.round(value * 1000);
|
|
1674
|
+
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>`;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
const stops: string[] = [];
|
|
1678
|
+
for (const [index, stopArg] of stopArgs.entries()) {
|
|
1679
|
+
const { color, position } = splitGradientStop(stopArg);
|
|
1680
|
+
const parsed = parseCssColor(color);
|
|
1681
|
+
if (!parsed) return undefined;
|
|
1682
|
+
const resolved = position ?? (index / (stopArgs.length - 1)) * 100;
|
|
1683
|
+
const clamped = Math.min(100, Math.max(0, resolved));
|
|
1684
|
+
stops.push(
|
|
1685
|
+
`<a:gs pos="${Math.round(clamped * 1000)}">${drawingMlColor(parsed)}</a:gs>`,
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
return `<a:gradFill rotWithShape="1"><a:gsLst>${stops.join("")}</a:gsLst>${geometry}</a:gradFill>`;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/** Split a gradient stop into its color token and optional position. */
|
|
1692
|
+
function splitGradientStop(arg: string): { color: string; position?: number } {
|
|
1693
|
+
const match = arg.match(/\s([\d.]+)%$/);
|
|
1694
|
+
return match
|
|
1695
|
+
? { color: arg.slice(0, match.index).trim(), position: Number(match[1]) }
|
|
1696
|
+
: { color: arg };
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* CSS `to <side-or-corner>` as its angle. A corner's true angle depends on the
|
|
1701
|
+
* box's aspect ratio, but `<a:lin scaled="0">` takes an absolute one, so the
|
|
1702
|
+
* 45° diagonals are the closest fixed stand-in.
|
|
1703
|
+
*/
|
|
1704
|
+
const LINEAR_GRADIENT_SIDE_ANGLES: Record<string, number> = {
|
|
1705
|
+
top: 0,
|
|
1706
|
+
"top right": 45,
|
|
1707
|
+
"right top": 45,
|
|
1708
|
+
right: 90,
|
|
1709
|
+
"bottom right": 135,
|
|
1710
|
+
"right bottom": 135,
|
|
1711
|
+
bottom: 180,
|
|
1712
|
+
"bottom left": 225,
|
|
1713
|
+
"left bottom": 225,
|
|
1714
|
+
left: 270,
|
|
1715
|
+
"top left": 315,
|
|
1716
|
+
"left top": 315,
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
/** The CSS angle a `linear-gradient` configuration argument names, or `undefined` when it names one we cannot express. */
|
|
1720
|
+
function linearGradientAngle(config: string | undefined): number | undefined {
|
|
1721
|
+
if (config === undefined) return 180; // CSS defaults to `to bottom`
|
|
1722
|
+
const degrees = config.match(/^(-?[\d.]+)deg$/i)?.[1];
|
|
1723
|
+
if (degrees !== undefined) return Number(degrees);
|
|
1724
|
+
const side = config.match(/^to\s+([a-z]+(?:\s+[a-z]+)?)$/i)?.[1];
|
|
1725
|
+
return side === undefined
|
|
1726
|
+
? undefined
|
|
1727
|
+
: LINEAR_GRADIENT_SIDE_ANGLES[side.toLowerCase().replace(/\s+/g, " ")];
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/** CSS position keywords as a percentage along their own axis. */
|
|
1731
|
+
const RADIAL_POSITION_PERCENTS: Record<string, number> = {
|
|
1732
|
+
left: 0,
|
|
1733
|
+
top: 0,
|
|
1734
|
+
center: 50,
|
|
1735
|
+
right: 100,
|
|
1736
|
+
bottom: 100,
|
|
1737
|
+
};
|
|
1738
|
+
|
|
1739
|
+
/** The focus a `radial-gradient` configuration argument names, or `undefined` when it names one we cannot express. */
|
|
1740
|
+
function radialGradientCenter(
|
|
1741
|
+
config: string | undefined,
|
|
1742
|
+
): { x: number; y: number } | undefined {
|
|
1743
|
+
if (config === undefined) return { x: 50, y: 50 };
|
|
1744
|
+
const at = config.match(/\bat\s+(.+)$/i)?.[1].trim();
|
|
1745
|
+
if (at === undefined) return { x: 50, y: 50 };
|
|
1746
|
+
const axes = at.split(/\s+/).map((axis) => {
|
|
1747
|
+
const percent = axis.match(/^([\d.]+)%$/)?.[1];
|
|
1748
|
+
return percent !== undefined
|
|
1749
|
+
? Number(percent)
|
|
1750
|
+
: RADIAL_POSITION_PERCENTS[axis.toLowerCase()];
|
|
1751
|
+
});
|
|
1752
|
+
// A lone value positions the x axis and centers the y axis.
|
|
1753
|
+
const [x, y = 50] = axes;
|
|
1754
|
+
if (axes.length > 2 || x === undefined || y === undefined) return undefined;
|
|
1755
|
+
return { x, y };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/** `<a:clrScheme>` requires every slot, in this order. */
|
|
1759
|
+
const THEME_COLOR_SLOTS = [
|
|
1760
|
+
"dk1",
|
|
1761
|
+
"lt1",
|
|
1762
|
+
"dk2",
|
|
1763
|
+
"lt2",
|
|
1764
|
+
"accent1",
|
|
1765
|
+
"accent2",
|
|
1766
|
+
"accent3",
|
|
1767
|
+
"accent4",
|
|
1768
|
+
"accent5",
|
|
1769
|
+
"accent6",
|
|
1770
|
+
"hlink",
|
|
1771
|
+
"folHlink",
|
|
1772
|
+
] as const;
|
|
1773
|
+
|
|
1774
|
+
/** The deck's own palette as a `<a:clrScheme>`, or `undefined` when a slot is missing. */
|
|
1775
|
+
export function themeClrSchemeXml(
|
|
1776
|
+
colorsByName: Record<string, string>,
|
|
1777
|
+
): string | undefined {
|
|
1778
|
+
const slots: string[] = [];
|
|
1779
|
+
for (const slot of THEME_COLOR_SLOTS) {
|
|
1780
|
+
const color = colorsByName[slot];
|
|
1781
|
+
if (!color) return undefined;
|
|
1782
|
+
slots.push(`<a:${slot}>${drawingMlColor(colorToHex(color))}</a:${slot}>`);
|
|
1783
|
+
}
|
|
1784
|
+
return `<a:clrScheme name="Deck">${slots.join("")}</a:clrScheme>`;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function replaceOnce(
|
|
1788
|
+
xml: string,
|
|
1789
|
+
pattern: RegExp,
|
|
1790
|
+
replacement: string,
|
|
1791
|
+
what: string,
|
|
1792
|
+
): string {
|
|
1793
|
+
if (!pattern.test(xml)) {
|
|
1794
|
+
throw new Error(
|
|
1795
|
+
`[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.`,
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
return xml.replace(pattern, () => replacement);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* Rewrite the two parts pptxgenjs hardcodes. `ShapeFillProps.type` is only
|
|
1803
|
+
* `'none' | 'solid'` and `makeXmlTheme` templates a fixed Office `clrScheme`,
|
|
1804
|
+
* so a deck's theme palette and its gradient slide backgrounds cannot be
|
|
1805
|
+
* expressed through the API at all; they have to be patched into the finished
|
|
1806
|
+
* package. Shape-level gradients stay flattened — unlike `<p:bg>` there is no
|
|
1807
|
+
* stable node to address per shape, so that one really is the library ceiling.
|
|
1808
|
+
*/
|
|
1809
|
+
export async function applyDeckIdentity(
|
|
1810
|
+
buffer: Buffer,
|
|
1811
|
+
args: {
|
|
1812
|
+
themeColors?: Record<string, string>;
|
|
1813
|
+
/** 0-based slide index -> `<a:gradFill>` XML. */
|
|
1814
|
+
slideGradients: Map<number, string>;
|
|
1815
|
+
},
|
|
1816
|
+
): Promise<Buffer> {
|
|
1817
|
+
const clrScheme = args.themeColors
|
|
1818
|
+
? themeClrSchemeXml(args.themeColors)
|
|
1819
|
+
: undefined;
|
|
1820
|
+
if (!clrScheme && args.slideGradients.size === 0) return buffer;
|
|
1821
|
+
|
|
1822
|
+
const JSZip = (await import("jszip")).default;
|
|
1823
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
1824
|
+
const rewrite = async (
|
|
1825
|
+
partPath: string,
|
|
1826
|
+
pattern: RegExp,
|
|
1827
|
+
replacement: string,
|
|
1828
|
+
what: string,
|
|
1829
|
+
) => {
|
|
1830
|
+
const xml = await zip.file(partPath)?.async("string");
|
|
1831
|
+
if (xml === undefined) {
|
|
1832
|
+
throw new Error(`[export-pptx] generated package is missing ${partPath}`);
|
|
1833
|
+
}
|
|
1834
|
+
zip.file(partPath, replaceOnce(xml, pattern, replacement, what));
|
|
1835
|
+
};
|
|
1836
|
+
|
|
1837
|
+
if (clrScheme) {
|
|
1838
|
+
await rewrite(
|
|
1839
|
+
"ppt/theme/theme1.xml",
|
|
1840
|
+
/<a:clrScheme name="[^"]*">[\s\S]*?<\/a:clrScheme>/,
|
|
1841
|
+
clrScheme,
|
|
1842
|
+
"the theme color scheme",
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
for (const [slideIndex, gradFill] of args.slideGradients) {
|
|
1846
|
+
await rewrite(
|
|
1847
|
+
`ppt/slides/slide${slideIndex + 1}.xml`,
|
|
1848
|
+
/<p:bg>[\s\S]*?<\/p:bg>/,
|
|
1849
|
+
`<p:bg><p:bgPr>${gradFill}<a:effectLst/></p:bgPr></p:bg>`,
|
|
1850
|
+
`slide ${slideIndex + 1}'s gradient background`,
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
|
1854
|
+
}
|
|
1855
|
+
|
|
829
1856
|
/**
|
|
830
1857
|
* Fetch a URL and return it as a base64 data URI.
|
|
831
1858
|
*
|
|
@@ -899,7 +1926,23 @@ export default defineAction({
|
|
|
899
1926
|
)
|
|
900
1927
|
? rawAspectRatio
|
|
901
1928
|
: undefined;
|
|
902
|
-
|
|
1929
|
+
// `<p:sldSz>` is per presentation, so the first slide that declares a
|
|
1930
|
+
// source page size settles it for the whole export; `parseSlideHtml`
|
|
1931
|
+
// re-derives the same value per slide.
|
|
1932
|
+
const slideContents: string[] = slides.map((slide: unknown) =>
|
|
1933
|
+
slide &&
|
|
1934
|
+
typeof slide === "object" &&
|
|
1935
|
+
typeof (slide as { content?: unknown }).content === "string"
|
|
1936
|
+
? (slide as { content: string }).content
|
|
1937
|
+
: "",
|
|
1938
|
+
);
|
|
1939
|
+
const sourcePage = slideContents
|
|
1940
|
+
.map(sourcePageInches)
|
|
1941
|
+
.find((page) => page !== undefined);
|
|
1942
|
+
const dims: SlideDims = {
|
|
1943
|
+
...getAspectRatioDims(aspectRatio),
|
|
1944
|
+
...(sourcePage ? { pptxInches: sourcePage } : {}),
|
|
1945
|
+
};
|
|
903
1946
|
|
|
904
1947
|
const PptxGenJS = (await import("pptxgenjs")).default;
|
|
905
1948
|
const pptx = new PptxGenJS();
|
|
@@ -919,20 +1962,46 @@ export default defineAction({
|
|
|
919
1962
|
}
|
|
920
1963
|
pptx.author = "Agent Native Slides";
|
|
921
1964
|
pptx.title = row.title;
|
|
1965
|
+
// The font scheme is the one half of the theme pptxgenjs does expose;
|
|
1966
|
+
// without it a themed deck re-imports as Calibri Light / Calibri.
|
|
1967
|
+
const [headFontFace, bodyFontFace] = deckThemeFonts(deckData);
|
|
1968
|
+
if (headFontFace) pptx.theme = { headFontFace, bodyFontFace };
|
|
1969
|
+
|
|
1970
|
+
const slideGradients = new Map<number, string>();
|
|
1971
|
+
let backgroundGradientsFlattened = 0;
|
|
922
1972
|
|
|
923
1973
|
for (const [slideIndex, slide] of slides.entries()) {
|
|
924
1974
|
const pptxSlide = pptx.addSlide();
|
|
925
|
-
const slideContent =
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1975
|
+
const slideContent = slideContents[slideIndex];
|
|
1976
|
+
const {
|
|
1977
|
+
texts,
|
|
1978
|
+
images,
|
|
1979
|
+
shapes,
|
|
1980
|
+
tables,
|
|
1981
|
+
grid,
|
|
1982
|
+
bgColor,
|
|
1983
|
+
bgTransparency,
|
|
1984
|
+
bgGradient,
|
|
1985
|
+
} = parseSlideHtml(slideContent, aspectRatio, slideIndex + 1);
|
|
1986
|
+
|
|
1987
|
+
// The solid fill stays the pre-rewrite value: `applyDeckIdentity`
|
|
1988
|
+
// replaces the whole `<p:bg>` when the gradient survives, and this is
|
|
1989
|
+
// what the deck falls back to when it does not.
|
|
932
1990
|
pptxSlide.background = {
|
|
933
1991
|
color: bgColor,
|
|
934
1992
|
...(bgTransparency != null ? { transparency: bgTransparency } : {}),
|
|
935
1993
|
};
|
|
1994
|
+
if (bgGradient) {
|
|
1995
|
+
const gradFill = cssGradientToDrawingMl(bgGradient);
|
|
1996
|
+
if (gradFill) {
|
|
1997
|
+
slideGradients.set(slideIndex, gradFill);
|
|
1998
|
+
} else {
|
|
1999
|
+
backgroundGradientsFlattened++;
|
|
2000
|
+
console.warn(
|
|
2001
|
+
`[export-pptx] slide ${slideIndex + 1} background "${bgGradient}" has no DrawingML equivalent; exported as flat #${bgColor}`,
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
936
2005
|
|
|
937
2006
|
if (grid) {
|
|
938
2007
|
const gridWidth = pxToIn(grid.stepX, dims);
|
|
@@ -999,20 +2068,21 @@ export default defineAction({
|
|
|
999
2068
|
y: t.y,
|
|
1000
2069
|
w: t.w,
|
|
1001
2070
|
h: t.h,
|
|
1002
|
-
fontSize: t.fontSize,
|
|
1003
|
-
fontFace: t.fontFace,
|
|
1004
2071
|
color: t.color,
|
|
1005
2072
|
bold: t.bold,
|
|
1006
2073
|
align: t.align || "left",
|
|
1007
2074
|
valign: "top" as const,
|
|
1008
2075
|
wrap: true,
|
|
2076
|
+
...(t.fontSize != null ? { fontSize: t.fontSize } : {}),
|
|
2077
|
+
...(t.fontFace != null ? { fontFace: t.fontFace } : {}),
|
|
1009
2078
|
...(t.transparency != null ? { transparency: t.transparency } : {}),
|
|
1010
2079
|
...(t.letterSpacing != null
|
|
1011
2080
|
? { charSpacing: t.letterSpacing }
|
|
1012
2081
|
: {}),
|
|
1013
|
-
...(t.
|
|
1014
|
-
? { lineSpacingMultiple: t.
|
|
2082
|
+
...(t.lineSpacingMultiple != null
|
|
2083
|
+
? { lineSpacingMultiple: t.lineSpacingMultiple }
|
|
1015
2084
|
: {}),
|
|
2085
|
+
...(t.rotate != null ? { rotate: t.rotate } : {}),
|
|
1016
2086
|
};
|
|
1017
2087
|
if (t.runs?.length) {
|
|
1018
2088
|
pptxSlide.addText(t.runs, options);
|
|
@@ -1029,28 +2099,25 @@ export default defineAction({
|
|
|
1029
2099
|
y: img.y,
|
|
1030
2100
|
w: img.w,
|
|
1031
2101
|
h: img.h,
|
|
2102
|
+
...(img.rotate != null ? { rotate: img.rotate } : {}),
|
|
1032
2103
|
});
|
|
1033
2104
|
}
|
|
1034
2105
|
} else if (object.kind === "table") {
|
|
1035
|
-
|
|
1036
|
-
pptxSlide.addTable(table.rows, {
|
|
1037
|
-
x: table.x,
|
|
1038
|
-
y: table.y,
|
|
1039
|
-
w: table.w,
|
|
1040
|
-
h: table.h,
|
|
1041
|
-
autoPage: false,
|
|
1042
|
-
border: { type: "solid", color: "FFFFFF", pt: 0.5 },
|
|
1043
|
-
margin: 0.04,
|
|
1044
|
-
});
|
|
2106
|
+
pptxSlide.addTable(object.value.rows, tableOptions(object.value));
|
|
1045
2107
|
} else {
|
|
1046
2108
|
const shape = object.value;
|
|
1047
2109
|
pptxSlide.addShape(
|
|
1048
|
-
|
|
2110
|
+
resolveShapeType(pptx.ShapeType, shape.shapeType),
|
|
1049
2111
|
{
|
|
1050
2112
|
x: shape.x,
|
|
1051
2113
|
y: shape.y,
|
|
1052
2114
|
w: shape.w,
|
|
1053
2115
|
h: shape.h,
|
|
2116
|
+
...(shape.rectRadius != null
|
|
2117
|
+
? { rectRadius: shape.rectRadius }
|
|
2118
|
+
: {}),
|
|
2119
|
+
...(shape.rotate != null ? { rotate: shape.rotate } : {}),
|
|
2120
|
+
...(shape.points ? { points: shape.points } : {}),
|
|
1054
2121
|
...(shape.fill
|
|
1055
2122
|
? {
|
|
1056
2123
|
fill: {
|
|
@@ -1066,6 +2133,15 @@ export default defineAction({
|
|
|
1066
2133
|
line: {
|
|
1067
2134
|
color: shape.lineColor,
|
|
1068
2135
|
width: shape.lineWidth ?? 1,
|
|
2136
|
+
...(shape.lineDashType
|
|
2137
|
+
? { dashType: shape.lineDashType }
|
|
2138
|
+
: {}),
|
|
2139
|
+
...(shape.lineHeadType
|
|
2140
|
+
? { beginArrowType: shape.lineHeadType }
|
|
2141
|
+
: {}),
|
|
2142
|
+
...(shape.lineTailType
|
|
2143
|
+
? { endArrowType: shape.lineTailType }
|
|
2144
|
+
: {}),
|
|
1069
2145
|
...(shape.lineTransparency != null
|
|
1070
2146
|
? { transparency: shape.lineTransparency }
|
|
1071
2147
|
: {}),
|
|
@@ -1088,7 +2164,10 @@ export default defineAction({
|
|
|
1088
2164
|
}
|
|
1089
2165
|
}
|
|
1090
2166
|
|
|
1091
|
-
const buffer =
|
|
2167
|
+
const buffer = await applyDeckIdentity(
|
|
2168
|
+
(await pptx.write({ outputType: "nodebuffer" })) as Buffer,
|
|
2169
|
+
{ themeColors: deckThemeColors(deckData), slideGradients },
|
|
2170
|
+
);
|
|
1092
2171
|
const filename = safeGeneratedFilename(row.title, ".pptx");
|
|
1093
2172
|
|
|
1094
2173
|
// Disk write is only useful when the same process can later serve the
|
|
@@ -1106,10 +2185,52 @@ export default defineAction({
|
|
|
1106
2185
|
fs.writeFileSync(filePath, buffer);
|
|
1107
2186
|
}
|
|
1108
2187
|
|
|
1109
|
-
return {
|
|
2188
|
+
return {
|
|
2189
|
+
buffer,
|
|
2190
|
+
filePath,
|
|
2191
|
+
filename,
|
|
2192
|
+
slideCount: slides.length,
|
|
2193
|
+
...(backgroundGradientsFlattened > 0
|
|
2194
|
+
? { backgroundGradientsFlattened }
|
|
2195
|
+
: {}),
|
|
2196
|
+
};
|
|
1110
2197
|
},
|
|
1111
2198
|
});
|
|
1112
2199
|
|
|
2200
|
+
/**
|
|
2201
|
+
* The palette an imported deck kept from its source `<a:clrScheme>`, in the
|
|
2202
|
+
* shape `import-pptx` already returns. Absent on decks imported before the
|
|
2203
|
+
* importer persisted it, and on decks the editor authored — both export with
|
|
2204
|
+
* pptxgenjs's Office default, which is the honest answer for a deck that has
|
|
2205
|
+
* no theme of its own.
|
|
2206
|
+
*/
|
|
2207
|
+
function deckThemeColors(
|
|
2208
|
+
deckData: unknown,
|
|
2209
|
+
): Record<string, string> | undefined {
|
|
2210
|
+
const colorsByName = (
|
|
2211
|
+
deckData as { theme?: { colorsByName?: unknown } } | null
|
|
2212
|
+
)?.theme?.colorsByName;
|
|
2213
|
+
if (!colorsByName || typeof colorsByName !== "object") return undefined;
|
|
2214
|
+
const entries = Object.entries(colorsByName).filter(
|
|
2215
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
2216
|
+
);
|
|
2217
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
/** `[majorFont, minorFont]` from the deck's theme, in `parseTheme`'s own order. */
|
|
2221
|
+
function deckThemeFonts(
|
|
2222
|
+
deckData: unknown,
|
|
2223
|
+
): [string | undefined, string | undefined] {
|
|
2224
|
+
const fonts = (deckData as { theme?: { fonts?: unknown } } | null)?.theme
|
|
2225
|
+
?.fonts;
|
|
2226
|
+
if (!Array.isArray(fonts)) return [undefined, undefined];
|
|
2227
|
+
const face = (index: number) =>
|
|
2228
|
+
typeof fonts[index] === "string" && fonts[index]
|
|
2229
|
+
? (fonts[index] as string)
|
|
2230
|
+
: undefined;
|
|
2231
|
+
return [face(0), face(1) ?? face(0)];
|
|
2232
|
+
}
|
|
2233
|
+
|
|
1113
2234
|
function isServerless(): boolean {
|
|
1114
2235
|
return Boolean(
|
|
1115
2236
|
process.env.NETLIFY ||
|