@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
|
@@ -84,15 +84,34 @@ function formatRun(run: ParsedTextRun): string {
|
|
|
84
84
|
|
|
85
85
|
const DEFAULT_IMPORT_FONT = "'Poppins', sans-serif";
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* PowerPoint records a weight variant as part of the typeface name
|
|
89
|
+
* ("Work Sans Medium", "Open Sans SemiBold", "Roboto Black"), which no
|
|
90
|
+
* webfont registers as a CSS family — `font-family: 'Work Sans Medium'`
|
|
91
|
+
* always falls back, even when Work Sans itself is loaded. The same token is
|
|
92
|
+
* read as a numeric weight by `fontWeightForFamily`, so stripping it here
|
|
93
|
+
* loses nothing.
|
|
94
|
+
*/
|
|
95
|
+
const FONT_WEIGHT_SUFFIX =
|
|
96
|
+
/[ _-](?:ultra|extra|semi|demi)?[ _-]?(?:black|heavy|bold|medium|regular|normal|roman|book|light|thin|italic|oblique)$/i;
|
|
97
|
+
|
|
87
98
|
/** Turn an extracted PPTX theme font name into a safe CSS font-family value, falling back to the default when absent. */
|
|
88
99
|
function cssFontFamily(themeFont: string | undefined): string {
|
|
89
100
|
if (!themeFont) return DEFAULT_IMPORT_FONT;
|
|
90
101
|
const safeName = themeFont.replace(/["']/g, "").trim();
|
|
91
102
|
if (!safeName) return DEFAULT_IMPORT_FONT;
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
let base = safeName;
|
|
104
|
+
while (FONT_WEIGHT_SUFFIX.test(base)) {
|
|
105
|
+
const stripped = base.replace(FONT_WEIGHT_SUFFIX, "").trim();
|
|
106
|
+
if (!stripped) break;
|
|
107
|
+
base = stripped;
|
|
94
108
|
}
|
|
95
|
-
|
|
109
|
+
// The authored name stays first: a deck whose exact variant family *is*
|
|
110
|
+
// installed still gets it, and a family whose real name ends in a weight
|
|
111
|
+
// word ("Archivo Black") is not broken by the strip.
|
|
112
|
+
return base === safeName
|
|
113
|
+
? `'${safeName}', sans-serif`
|
|
114
|
+
: `'${safeName}', '${base}', sans-serif`;
|
|
96
115
|
}
|
|
97
116
|
|
|
98
117
|
/**
|
|
@@ -139,7 +158,12 @@ export function convertToSlideHtml(
|
|
|
139
158
|
imageUrls?: string | Record<string, string>,
|
|
140
159
|
themeFont?: string,
|
|
141
160
|
): string {
|
|
142
|
-
|
|
161
|
+
// A slide parsed with real geometry goes through the fidelity renderer even
|
|
162
|
+
// when it has zero elements: a deliberately empty divider slide (a
|
|
163
|
+
// full-bleed background and nothing else) is a real state the source
|
|
164
|
+
// states, and the templates below would replace it with an invented
|
|
165
|
+
// "Untitled Slide" heading on a background they never apply.
|
|
166
|
+
if (slide.elements) {
|
|
143
167
|
return buildFidelitySlide(slide, imageUrls, themeFont);
|
|
144
168
|
}
|
|
145
169
|
|
|
@@ -167,8 +191,27 @@ export function convertToSlideHtml(
|
|
|
167
191
|
|
|
168
192
|
const DEFAULT_SLIDE_WIDTH_EMU = 9144000;
|
|
169
193
|
const DEFAULT_SLIDE_HEIGHT_EMU = 5143500;
|
|
170
|
-
|
|
171
|
-
|
|
194
|
+
// PowerPoint's own default slide background (no `<p:bg>` declared) is white,
|
|
195
|
+
// not black — defaulting to black here made an undecorated slide's own
|
|
196
|
+
// (often dark, theme-default) text unreadable or fully invisible against a
|
|
197
|
+
// background the source file never actually specified.
|
|
198
|
+
const DEFAULT_PPTX_BACKGROUND = "#ffffff"; // guard:allow-raw-color - PPTX's own white default when no background is declared
|
|
199
|
+
// OOXML's own default run color when nothing in the run, the placeholder
|
|
200
|
+
// chain, or `<p:txStyles>` declares one. It has to be the value the file
|
|
201
|
+
// format states, not a readable-looking approximation: an invented near-black
|
|
202
|
+
// renders beside the deck's real black inside a single text box, which is
|
|
203
|
+
// visible as two different blacks in one paragraph.
|
|
204
|
+
const DEFAULT_PPTX_FOREGROUND = "#000000"; // guard:allow-raw-color - OOXML's declared default text color
|
|
205
|
+
/**
|
|
206
|
+
* OOXML's own default run size, used only when the run, its placeholder
|
|
207
|
+
* chain, and the deck's `<p:defaultTextStyle>` all fail to state one.
|
|
208
|
+
* KNOWN GAP: the parser does not read `<p:defaultTextStyle>` or the master's
|
|
209
|
+
* `<p:otherStyle>`, and real decks routinely declare 14pt there — an unsized
|
|
210
|
+
* run in one of those decks renders 28% oversized and overflows its authored
|
|
211
|
+
* box. Fixing that needs the parser to surface the deck's declared default,
|
|
212
|
+
* not a different constant here.
|
|
213
|
+
*/
|
|
214
|
+
const DEFAULT_PPTX_FONT_SIZE_PT = 18;
|
|
172
215
|
|
|
173
216
|
/**
|
|
174
217
|
* The absolute px box `toSlidePxX`/`toSlidePxY` scale positions and sizes
|
|
@@ -238,7 +281,9 @@ function buildFidelityElement(
|
|
|
238
281
|
imageUrls: string | Record<string, string> | undefined,
|
|
239
282
|
themeFont: string | undefined,
|
|
240
283
|
): string {
|
|
241
|
-
const
|
|
284
|
+
const widthPx = toSlidePxX(element.width, widthEmu, refBox.width);
|
|
285
|
+
const heightPx = toSlidePxY(element.height, heightEmu, refBox.height);
|
|
286
|
+
const position = `position: absolute; left: ${toSlidePxX(element.x, widthEmu, refBox.width)}px; top: ${toSlidePxY(element.y, heightEmu, refBox.height)}px; width: ${widthPx}px; height: ${heightPx}px; z-index: ${index}; box-sizing: border-box;`;
|
|
242
287
|
const rotation = element.rotation
|
|
243
288
|
? ` transform: rotate(${element.rotation}deg); transform-origin: center center;`
|
|
244
289
|
: "";
|
|
@@ -247,12 +292,67 @@ function buildFidelityElement(
|
|
|
247
292
|
if (element.kind === "image") {
|
|
248
293
|
const url = imageUrlForElement(element, imageUrls);
|
|
249
294
|
const imageStyle = imageRenderStyle(element);
|
|
250
|
-
|
|
295
|
+
// PowerPoint paints a picture inside its shape, so a portrait in an
|
|
296
|
+
// `ellipse` frame is a circle, not the square its bounding box is. Text
|
|
297
|
+
// is the one kind that must keep its box — clipping it would eat the
|
|
298
|
+
// text with the outline.
|
|
299
|
+
const imagePath = customGeometryPath(element, widthPx, heightPx);
|
|
300
|
+
const clip = imagePath
|
|
301
|
+
? `clip-path: path('${imagePath}');`
|
|
302
|
+
: geometryCss(element, widthPx, heightPx);
|
|
303
|
+
return `<div class="fmd-pptx-image" data-pptx-element-kind="image" data-pptx-image-name="${esc(element.image?.name ?? "image")}"${objectId} style="${position}${rotation} overflow: hidden;${clip}">${url ? `<img src="${esc(url)}" alt="" style="${imageStyle}" />` : `<div class="fmd-img-placeholder" style="width:100%;height:100%;">Imported image: ${esc(element.image?.name ?? "image")}</div>`}</div>`;
|
|
251
304
|
}
|
|
252
305
|
|
|
253
|
-
|
|
306
|
+
if (element.kind === "table") {
|
|
307
|
+
return buildFidelityTable(
|
|
308
|
+
element,
|
|
309
|
+
widthEmu,
|
|
310
|
+
refBox.width,
|
|
311
|
+
themeFont,
|
|
312
|
+
position,
|
|
313
|
+
rotation,
|
|
314
|
+
objectId,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Text keeps its box: clipping a text element to its outline would eat the
|
|
319
|
+
// text with it, which PowerPoint does not do either.
|
|
320
|
+
const customPath =
|
|
321
|
+
element.kind === "shape"
|
|
322
|
+
? customGeometryPath(element, widthPx, heightPx)
|
|
323
|
+
: undefined;
|
|
324
|
+
const outlinePath =
|
|
325
|
+
element.kind === "shape"
|
|
326
|
+
? (customPath ?? clippedPresetPath(element, widthPx, heightPx))
|
|
327
|
+
: undefined;
|
|
328
|
+
const decoration = shapeDecoration(
|
|
329
|
+
element,
|
|
330
|
+
widthEmu,
|
|
331
|
+
refBox.width,
|
|
332
|
+
widthPx,
|
|
333
|
+
heightPx,
|
|
334
|
+
customPath,
|
|
335
|
+
outlinePath,
|
|
336
|
+
);
|
|
254
337
|
if (element.kind === "shape") {
|
|
255
|
-
|
|
338
|
+
const stroke = outlinePath
|
|
339
|
+
? customGeometryStroke(
|
|
340
|
+
element,
|
|
341
|
+
outlinePath,
|
|
342
|
+
widthEmu,
|
|
343
|
+
refBox.width,
|
|
344
|
+
widthPx,
|
|
345
|
+
heightPx,
|
|
346
|
+
)
|
|
347
|
+
: "";
|
|
348
|
+
const caps = lineEndCaps(
|
|
349
|
+
element,
|
|
350
|
+
widthEmu,
|
|
351
|
+
refBox.width,
|
|
352
|
+
widthPx,
|
|
353
|
+
heightPx,
|
|
354
|
+
);
|
|
355
|
+
return `<div class="fmd-pptx-shape" data-pptx-element-kind="shape"${objectId} style="${position}${rotation}${decoration}">${stroke}${caps}</div>`;
|
|
256
356
|
}
|
|
257
357
|
|
|
258
358
|
const textStyle = textBoxStyle(
|
|
@@ -263,6 +363,7 @@ function buildFidelityElement(
|
|
|
263
363
|
themeFont,
|
|
264
364
|
);
|
|
265
365
|
const defaultFontWeight = element.placeholderType === "title" ? 700 : 400;
|
|
366
|
+
const boxFontSizePt = firstDeclaredFontSizePt(element.paragraphs);
|
|
266
367
|
const paragraphs = (element.paragraphs ?? [])
|
|
267
368
|
.map((paragraph, paragraphIndex) =>
|
|
268
369
|
buildFidelityParagraph(
|
|
@@ -272,6 +373,7 @@ function buildFidelityElement(
|
|
|
272
373
|
refBox.width,
|
|
273
374
|
themeFont,
|
|
274
375
|
defaultFontWeight,
|
|
376
|
+
boxFontSizePt,
|
|
275
377
|
),
|
|
276
378
|
)
|
|
277
379
|
.join("\n");
|
|
@@ -331,17 +433,1085 @@ function imageRenderStyle(element: ParsedElement): string {
|
|
|
331
433
|
return `display:block;position:absolute;left:${(-crop.left / visibleWidth) * 100}%;top:${(-crop.top / visibleHeight) * 100}%;width:${(1 / visibleWidth) * 100}%;height:${(1 / visibleHeight) * 100}%;object-fit:fill;`;
|
|
332
434
|
}
|
|
333
435
|
|
|
436
|
+
type ParsedTableCell = NonNullable<
|
|
437
|
+
ParsedElement["table"]
|
|
438
|
+
>["rows"][number][number];
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* ECMA-376's own default `a:tcPr` cell margins, in EMU (0.1in left/right,
|
|
442
|
+
* 0.05in top/bottom). They run through the same slide scale as every other
|
|
443
|
+
* measurement, so a portrait or otherwise non-16:9 deck gets margins
|
|
444
|
+
* proportional to its own canvas instead of a fixed px pair sized for one
|
|
445
|
+
* slide shape.
|
|
446
|
+
*/
|
|
447
|
+
const DEFAULT_TABLE_CELL_MARGIN_X_EMU = 91440;
|
|
448
|
+
const DEFAULT_TABLE_CELL_MARGIN_Y_EMU = 45720;
|
|
449
|
+
|
|
450
|
+
/** Render a parsed `"table"` element (a PPTX `graphicFrame`'s `a:tbl`) as a real HTML `<table>`, sized/positioned the same way every other fidelity element is. */
|
|
451
|
+
function buildFidelityTable(
|
|
452
|
+
element: ParsedElement,
|
|
453
|
+
widthEmu: number,
|
|
454
|
+
refWidthPx: number,
|
|
455
|
+
themeFont: string | undefined,
|
|
456
|
+
position: string,
|
|
457
|
+
rotation: string,
|
|
458
|
+
objectId: string,
|
|
459
|
+
): string {
|
|
460
|
+
const rows = element.table?.rows ?? [];
|
|
461
|
+
const rowsHtml = rows
|
|
462
|
+
.map(
|
|
463
|
+
(row, rowIndex) =>
|
|
464
|
+
`<tr${rowHeightStyle(element, rowIndex)}>${row
|
|
465
|
+
.map((cell) =>
|
|
466
|
+
buildFidelityTableCell(cell, widthEmu, refWidthPx, themeFont),
|
|
467
|
+
)
|
|
468
|
+
.join("")}</tr>`,
|
|
469
|
+
)
|
|
470
|
+
.join("");
|
|
471
|
+
const columnWidths = element.table?.columnWidthsEmu ?? [];
|
|
472
|
+
const totalColumnWidth = columnWidths.reduce(
|
|
473
|
+
(total, width) => total + width,
|
|
474
|
+
0,
|
|
475
|
+
);
|
|
476
|
+
const colgroup =
|
|
477
|
+
totalColumnWidth > 0
|
|
478
|
+
? `<colgroup>${columnWidths
|
|
479
|
+
.map(
|
|
480
|
+
(width) =>
|
|
481
|
+
`<col style="width:${(width / totalColumnWidth) * 100}%" />`,
|
|
482
|
+
)
|
|
483
|
+
.join("")}</colgroup>`
|
|
484
|
+
: "";
|
|
485
|
+
return `<div class="fmd-pptx-table" data-pptx-element-kind="table"${objectId} style="${position}${rotation} overflow: hidden;"><table style="width:100%;height:100%;border-collapse:collapse;table-layout:fixed;font-family:${cssFontFamily(themeFont)};">${colgroup}${rowsHtml}</table></div>`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function rowHeightStyle(element: ParsedElement, rowIndex: number): string {
|
|
489
|
+
const rowHeight = element.table?.rowHeightsEmu?.[rowIndex];
|
|
490
|
+
if (!rowHeight || element.height <= 0) return "";
|
|
491
|
+
return ` style="height:${(rowHeight / element.height) * 100}%"`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function buildFidelityTableCell(
|
|
495
|
+
cell: ParsedTableCell,
|
|
496
|
+
widthEmu: number,
|
|
497
|
+
refWidthPx: number,
|
|
498
|
+
themeFont: string | undefined,
|
|
499
|
+
): string {
|
|
500
|
+
const fill = cell.fill ? `background:${esc(cell.fill)};` : "";
|
|
501
|
+
const paragraphsHtml = cell.paragraphs
|
|
502
|
+
.map((paragraph, paragraphIndex) =>
|
|
503
|
+
buildFidelityParagraph(
|
|
504
|
+
paragraph,
|
|
505
|
+
paragraphIndex,
|
|
506
|
+
widthEmu,
|
|
507
|
+
refWidthPx,
|
|
508
|
+
themeFont,
|
|
509
|
+
400,
|
|
510
|
+
),
|
|
511
|
+
)
|
|
512
|
+
.join("");
|
|
513
|
+
const paddingY = toSlidePxX(
|
|
514
|
+
DEFAULT_TABLE_CELL_MARGIN_Y_EMU,
|
|
515
|
+
widthEmu,
|
|
516
|
+
refWidthPx,
|
|
517
|
+
);
|
|
518
|
+
const paddingX = toSlidePxX(
|
|
519
|
+
DEFAULT_TABLE_CELL_MARGIN_X_EMU,
|
|
520
|
+
widthEmu,
|
|
521
|
+
refWidthPx,
|
|
522
|
+
);
|
|
523
|
+
const borders = TABLE_CELL_SIDES.map((side) =>
|
|
524
|
+
tableCellBorderCss(side, cell.borders?.[side], widthEmu, refWidthPx),
|
|
525
|
+
).join("");
|
|
526
|
+
return `<td colspan="${cell.colSpan ?? 1}" rowspan="${cell.rowSpan ?? 1}" style="padding:${paddingY}px ${paddingX}px;vertical-align:top;${fill}${borders}">${paragraphsHtml}</td>`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const TABLE_CELL_SIDES = ["top", "right", "bottom", "left"] as const;
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* A side the source draws nothing on emits nothing: under
|
|
533
|
+
* `border-collapse: collapse` an unset side yields to its neighbour's rule
|
|
534
|
+
* rather than erasing it, which is what a table styled with only outer edges
|
|
535
|
+
* needs. The 1px floor is there because the hairline these decks author
|
|
536
|
+
* (9525 EMU = 0.75pt) scales below a device pixel on the reference canvas,
|
|
537
|
+
* and a rule the browser rounds away is the same missing grid this is fixing.
|
|
538
|
+
*/
|
|
539
|
+
function tableCellBorderCss(
|
|
540
|
+
side: (typeof TABLE_CELL_SIDES)[number],
|
|
541
|
+
border: NonNullable<ParsedTableCell["borders"]>["top"],
|
|
542
|
+
widthEmu: number,
|
|
543
|
+
refWidthPx: number,
|
|
544
|
+
): string {
|
|
545
|
+
if (!border) return "";
|
|
546
|
+
const width = Math.max(
|
|
547
|
+
1,
|
|
548
|
+
toSlidePxX(border.widthEmu ?? 9525, widthEmu, refWidthPx),
|
|
549
|
+
);
|
|
550
|
+
return `border-${side}:${round3(width)}px ${border.dash ?? "solid"} ${esc(border.color)};`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Preset geometries whose real outline leaves most of their bounding box
|
|
555
|
+
* empty — a ring, an L-bracket, a hooked arrow. There is no CSS shape for
|
|
556
|
+
* them here, and painting the bounding box instead is not a degraded
|
|
557
|
+
* rendering but an actively wrong one: it covers the neighbouring content
|
|
558
|
+
* the real geometry leaves visible, so a four-ring diagram becomes one
|
|
559
|
+
* opaque square over the title. Until a geometry is reproduced, its fill and
|
|
560
|
+
* stroke are dropped rather than approximated by a rectangle.
|
|
561
|
+
*/
|
|
562
|
+
const UNRENDERABLE_GEOMETRIES = new Set([
|
|
563
|
+
"arc",
|
|
564
|
+
"bentUpArrow",
|
|
565
|
+
"bracePair",
|
|
566
|
+
"bracketPair",
|
|
567
|
+
"chord",
|
|
568
|
+
"circularArrow",
|
|
569
|
+
"corner",
|
|
570
|
+
"curvedDownArrow",
|
|
571
|
+
"curvedLeftArrow",
|
|
572
|
+
"curvedRightArrow",
|
|
573
|
+
"curvedUpArrow",
|
|
574
|
+
"donut",
|
|
575
|
+
"frame",
|
|
576
|
+
"leftBrace",
|
|
577
|
+
"leftBracket",
|
|
578
|
+
"leftCircularArrow",
|
|
579
|
+
"noSmoking",
|
|
580
|
+
"rightBrace",
|
|
581
|
+
"rightBracket",
|
|
582
|
+
]);
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* PowerPoint's own default `a:avLst` adjustment for the corner-rounding
|
|
586
|
+
* presets, as a fraction of the shape's shortest side. The parser records
|
|
587
|
+
* `a:prstGeom/@_prst` but not the adjust values, so a deck that overrides
|
|
588
|
+
* `adj` (a 50% pill, say) still renders at this default.
|
|
589
|
+
*/
|
|
590
|
+
const DEFAULT_CORNER_ADJUSTMENT = 0.16667;
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Preset geometries reproduced as a `clip-path` polygon, keyed by
|
|
594
|
+
* `a:prstGeom/@_prst`. `ss` is the shape's shortest side, which is what
|
|
595
|
+
* OOXML's own guide formulas measure their adjustments against; each literal
|
|
596
|
+
* fraction below is that preset's default `a:avLst` value.
|
|
597
|
+
*/
|
|
598
|
+
const diamondPoints = (w: number, h: number): [number, number][] => [
|
|
599
|
+
[w / 2, 0],
|
|
600
|
+
[w, h / 2],
|
|
601
|
+
[w / 2, h],
|
|
602
|
+
[0, h / 2],
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
const CLIP_PATH_GEOMETRIES: Record<
|
|
606
|
+
string,
|
|
607
|
+
(
|
|
608
|
+
w: number,
|
|
609
|
+
h: number,
|
|
610
|
+
ss: number,
|
|
611
|
+
adj?: Record<string, number>,
|
|
612
|
+
) => [number, number][]
|
|
613
|
+
> = {
|
|
614
|
+
halfFrame: (w, h, ss, adj) => {
|
|
615
|
+
// An L-bracket: `adj2` is the top arm's thickness and `adj1` the left
|
|
616
|
+
// arm's, each measured against the shortest side, and the inner corner is
|
|
617
|
+
// mitred so the two arms meet along the box's own diagonal.
|
|
618
|
+
const x1 = (ss * pin(0, adj?.adj2 ?? 33333, (100000 * w) / ss)) / 100000;
|
|
619
|
+
const y1 =
|
|
620
|
+
(ss * pin(0, adj?.adj1 ?? 33333, (100000 * (h - (h * x1) / w)) / ss)) /
|
|
621
|
+
100000;
|
|
622
|
+
return [
|
|
623
|
+
[0, 0],
|
|
624
|
+
[w, 0],
|
|
625
|
+
[w - (y1 * w) / h, y1],
|
|
626
|
+
[x1, y1],
|
|
627
|
+
[x1, h - (x1 * h) / w],
|
|
628
|
+
[0, h],
|
|
629
|
+
];
|
|
630
|
+
},
|
|
631
|
+
triangle: (w, h) => [
|
|
632
|
+
[w / 2, 0],
|
|
633
|
+
[w, h],
|
|
634
|
+
[0, h],
|
|
635
|
+
],
|
|
636
|
+
rtTriangle: (w, h) => [
|
|
637
|
+
[0, 0],
|
|
638
|
+
[w, h],
|
|
639
|
+
[0, h],
|
|
640
|
+
],
|
|
641
|
+
diamond: diamondPoints,
|
|
642
|
+
// OOXML states the flow chart decision node as the same four points; a
|
|
643
|
+
// rectangle in its place reads as one more process box in the chart.
|
|
644
|
+
flowChartDecision: diamondPoints,
|
|
645
|
+
homePlate: (w, h, ss) => {
|
|
646
|
+
const x = ss * 0.16667;
|
|
647
|
+
return [
|
|
648
|
+
[0, 0],
|
|
649
|
+
[w - x, 0],
|
|
650
|
+
[w, h / 2],
|
|
651
|
+
[w - x, h],
|
|
652
|
+
[0, h],
|
|
653
|
+
];
|
|
654
|
+
},
|
|
655
|
+
chevron: (w, h, ss) => {
|
|
656
|
+
const x = ss * 0.5;
|
|
657
|
+
return [
|
|
658
|
+
[0, 0],
|
|
659
|
+
[w - x, 0],
|
|
660
|
+
[w, h / 2],
|
|
661
|
+
[w - x, h],
|
|
662
|
+
[0, h],
|
|
663
|
+
[x, h / 2],
|
|
664
|
+
];
|
|
665
|
+
},
|
|
666
|
+
hexagon: (w, h, ss) => {
|
|
667
|
+
const x = ss * 0.25;
|
|
668
|
+
return [
|
|
669
|
+
[x, 0],
|
|
670
|
+
[w - x, 0],
|
|
671
|
+
[w, h / 2],
|
|
672
|
+
[w - x, h],
|
|
673
|
+
[x, h],
|
|
674
|
+
[0, h / 2],
|
|
675
|
+
];
|
|
676
|
+
},
|
|
677
|
+
trapezoid: (w, h, ss) => {
|
|
678
|
+
const x = ss * 0.25;
|
|
679
|
+
return [
|
|
680
|
+
[x, 0],
|
|
681
|
+
[w - x, 0],
|
|
682
|
+
[w, h],
|
|
683
|
+
[0, h],
|
|
684
|
+
];
|
|
685
|
+
},
|
|
686
|
+
parallelogram: (w, h, ss) => {
|
|
687
|
+
const x = ss * 0.25;
|
|
688
|
+
return [
|
|
689
|
+
[x, 0],
|
|
690
|
+
[w, 0],
|
|
691
|
+
[w - x, h],
|
|
692
|
+
[0, h],
|
|
693
|
+
];
|
|
694
|
+
},
|
|
695
|
+
octagon: (w, h, ss) => {
|
|
696
|
+
const c = ss * 0.29289;
|
|
697
|
+
return [
|
|
698
|
+
[c, 0],
|
|
699
|
+
[w - c, 0],
|
|
700
|
+
[w, c],
|
|
701
|
+
[w, h - c],
|
|
702
|
+
[w - c, h],
|
|
703
|
+
[c, h],
|
|
704
|
+
[0, h - c],
|
|
705
|
+
[0, c],
|
|
706
|
+
];
|
|
707
|
+
},
|
|
708
|
+
pentagon: (w, h) => [
|
|
709
|
+
[w / 2, 0],
|
|
710
|
+
[w, h * 0.38],
|
|
711
|
+
[w * 0.82, h],
|
|
712
|
+
[w * 0.18, h],
|
|
713
|
+
[0, h * 0.38],
|
|
714
|
+
],
|
|
715
|
+
plus: (w, h, ss) => {
|
|
716
|
+
const a = ss * 0.25;
|
|
717
|
+
return [
|
|
718
|
+
[a, 0],
|
|
719
|
+
[w - a, 0],
|
|
720
|
+
[w - a, a],
|
|
721
|
+
[w, a],
|
|
722
|
+
[w, h - a],
|
|
723
|
+
[w - a, h - a],
|
|
724
|
+
[w - a, h],
|
|
725
|
+
[a, h],
|
|
726
|
+
[a, h - a],
|
|
727
|
+
[0, h - a],
|
|
728
|
+
[0, a],
|
|
729
|
+
[a, a],
|
|
730
|
+
];
|
|
731
|
+
},
|
|
732
|
+
downArrow: (w, h, ss) => arrowPoints(w, h, ss, "down"),
|
|
733
|
+
upArrow: (w, h, ss) => arrowPoints(w, h, ss, "up"),
|
|
734
|
+
rightArrow: (w, h, ss) => arrowPoints(w, h, ss, "right"),
|
|
735
|
+
leftArrow: (w, h, ss) => arrowPoints(w, h, ss, "left"),
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
function arrowPoints(
|
|
739
|
+
w: number,
|
|
740
|
+
h: number,
|
|
741
|
+
ss: number,
|
|
742
|
+
direction: "up" | "down" | "left" | "right",
|
|
743
|
+
): [number, number][] {
|
|
744
|
+
const shaft = ss * 0.25;
|
|
745
|
+
const head = ss * 0.5;
|
|
746
|
+
if (direction === "down" || direction === "up") {
|
|
747
|
+
const cx = w / 2;
|
|
748
|
+
const base: [number, number][] = [
|
|
749
|
+
[cx - shaft, 0],
|
|
750
|
+
[cx + shaft, 0],
|
|
751
|
+
[cx + shaft, h - head],
|
|
752
|
+
[w, h - head],
|
|
753
|
+
[cx, h],
|
|
754
|
+
[0, h - head],
|
|
755
|
+
[cx - shaft, h - head],
|
|
756
|
+
];
|
|
757
|
+
return direction === "down"
|
|
758
|
+
? base
|
|
759
|
+
: base.map(([x, y]) => [x, h - y] as [number, number]);
|
|
760
|
+
}
|
|
761
|
+
const cy = h / 2;
|
|
762
|
+
const base: [number, number][] = [
|
|
763
|
+
[0, cy - shaft],
|
|
764
|
+
[w - head, cy - shaft],
|
|
765
|
+
[w - head, 0],
|
|
766
|
+
[w, cy],
|
|
767
|
+
[w - head, h],
|
|
768
|
+
[w - head, cy + shaft],
|
|
769
|
+
[0, cy + shaft],
|
|
770
|
+
];
|
|
771
|
+
return direction === "right"
|
|
772
|
+
? base
|
|
773
|
+
: base.map(([x, y]) => [w - x, y] as [number, number]);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function toPercent(value: number, total: number): number {
|
|
777
|
+
return Math.round((value / Math.max(total, 0.001)) * 10000) / 100;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* A `blockArc` is a ring segment: two concentric elliptical arcs joined at
|
|
782
|
+
* their ends. Its three `a:avLst` adjustments — start angle, end angle, and
|
|
783
|
+
* ring thickness — are the only thing distinguishing one segment of a
|
|
784
|
+
* six-part ring diagram from another, so reproducing the preset from its
|
|
785
|
+
* defaults would draw six identical half-rings stacked on each other.
|
|
786
|
+
*/
|
|
787
|
+
function blockArcPath(
|
|
788
|
+
adjustments: Record<string, number> | undefined,
|
|
789
|
+
widthPx: number,
|
|
790
|
+
heightPx: number,
|
|
791
|
+
): string | undefined {
|
|
792
|
+
const startAngle = (adjustments?.adj1 ?? 10800000) / 60000;
|
|
793
|
+
const endAngle = (adjustments?.adj2 ?? 0) / 60000;
|
|
794
|
+
// OOXML's own `pin 0 adj3 50000`: past half the shortest side the ring has
|
|
795
|
+
// no hole left and the segment is a pie slice.
|
|
796
|
+
const thickness = Math.min(Math.max(adjustments?.adj3 ?? 25000, 0), 50000);
|
|
797
|
+
// A swing of zero is a *whole* ring, not an empty one; the tiny shortfall
|
|
798
|
+
// keeps the two arcs from collapsing onto the same point, where SVG draws
|
|
799
|
+
// nothing at all.
|
|
800
|
+
let swing = endAngle - startAngle;
|
|
801
|
+
while (swing <= 0) swing += 360;
|
|
802
|
+
swing = Math.min(swing, 359.9);
|
|
803
|
+
const outerX = widthPx / 2;
|
|
804
|
+
const outerY = heightPx / 2;
|
|
805
|
+
const inset = (Math.min(widthPx, heightPx) * thickness) / 100000;
|
|
806
|
+
const innerX = outerX - inset;
|
|
807
|
+
const innerY = outerY - inset;
|
|
808
|
+
if (!(innerX > 0) || !(innerY > 0)) return undefined;
|
|
809
|
+
// ponytail: parametric angles, exact for a circular block arc — which is
|
|
810
|
+
// every one in the decks this was measured against. A markedly elliptical
|
|
811
|
+
// one starts and ends a few degrees around from where PowerPoint puts it;
|
|
812
|
+
// OOXML's `cat2`/`sat2` true-angle correction is the upgrade.
|
|
813
|
+
const at = (radiusX: number, radiusY: number, degrees: number) => {
|
|
814
|
+
const angle = (degrees * Math.PI) / 180;
|
|
815
|
+
return `${round1(outerX + radiusX * Math.cos(angle))} ${round1(outerY + radiusY * Math.sin(angle))}`;
|
|
816
|
+
};
|
|
817
|
+
const large = swing > 180 ? 1 : 0;
|
|
818
|
+
return [
|
|
819
|
+
`M${at(outerX, outerY, startAngle)}`,
|
|
820
|
+
`A${round1(outerX)} ${round1(outerY)} 0 ${large} 1 ${at(outerX, outerY, startAngle + swing)}`,
|
|
821
|
+
`L${at(innerX, innerY, startAngle + swing)}`,
|
|
822
|
+
`A${round1(innerX)} ${round1(innerY)} 0 ${large} 0 ${at(innerX, innerY, startAngle)}`,
|
|
823
|
+
"Z",
|
|
824
|
+
].join(" ");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** OOXML's `pin`: a preset's declared adjustment clamped to the range its own guides allow. */
|
|
828
|
+
function pin(min: number, value: number, max: number): number {
|
|
829
|
+
return Math.min(Math.max(value, min), max);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* OOXML angles are 60000ths of a degree, and on an ellipse they are the angle
|
|
834
|
+
* a ray from the centre actually makes, not the parametric angle whose sine
|
|
835
|
+
* and cosine give the point. The presets say so themselves: `pie` computes its
|
|
836
|
+
* arc's endpoints with `cat2`/`sat2`, which is this correction, and those
|
|
837
|
+
* endpoints must land where its own `arcTo` lands or the slice never closes.
|
|
838
|
+
* On a circular arc — every arc in the two arrow presets — it is the identity.
|
|
839
|
+
*/
|
|
840
|
+
function ellipseAngle(
|
|
841
|
+
angle60k: number,
|
|
842
|
+
radiusX: number,
|
|
843
|
+
radiusY: number,
|
|
844
|
+
): number {
|
|
845
|
+
const radians = (angle60k / 60000) * (Math.PI / 180);
|
|
846
|
+
return Math.atan2(radiusX * Math.sin(radians), radiusY * Math.cos(radians));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Draws one preset outline as an SVG path in the shape's own pixel box. An
|
|
851
|
+
* OOXML `arcTo` gives radii and a start/sweep angle but neither the centre nor
|
|
852
|
+
* the end point SVG's `A` needs — the current point is the arc's start, so the
|
|
853
|
+
* centre is that point walked back along the start angle.
|
|
854
|
+
*
|
|
855
|
+
* `flipH`/`flipV` mirror the geometry before the element's CSS rotation runs,
|
|
856
|
+
* and are folded into the coordinates rather than added to the `transform`,
|
|
857
|
+
* which would mirror any text the box carries along with its outline.
|
|
858
|
+
*/
|
|
859
|
+
function presetPen(
|
|
860
|
+
widthPx: number,
|
|
861
|
+
heightPx: number,
|
|
862
|
+
flipH?: boolean,
|
|
863
|
+
flipV?: boolean,
|
|
864
|
+
) {
|
|
865
|
+
const parts: string[] = [];
|
|
866
|
+
const toX = (x: number) => round1(flipH ? widthPx - x : x);
|
|
867
|
+
const toY = (y: number) => round1(flipV ? heightPx - y : y);
|
|
868
|
+
// A single mirror reverses the direction an arc sweeps in; two cancel out.
|
|
869
|
+
const mirrored = Boolean(flipH) !== Boolean(flipV);
|
|
870
|
+
let currentX = 0;
|
|
871
|
+
let currentY = 0;
|
|
872
|
+
const emit = (command: string, points: [number, number][]) => {
|
|
873
|
+
const last = points[points.length - 1]!;
|
|
874
|
+
currentX = last[0];
|
|
875
|
+
currentY = last[1];
|
|
876
|
+
parts.push(
|
|
877
|
+
`${command}${points.map(([x, y]) => `${toX(x)} ${toY(y)}`).join(" ")}`,
|
|
878
|
+
);
|
|
879
|
+
};
|
|
880
|
+
return {
|
|
881
|
+
move: (x: number, y: number) => emit("M", [[x, y]]),
|
|
882
|
+
line: (x: number, y: number) => emit("L", [[x, y]]),
|
|
883
|
+
cubic: (points: [number, number][]) => emit("C", points),
|
|
884
|
+
arc: (
|
|
885
|
+
radiusX: number,
|
|
886
|
+
radiusY: number,
|
|
887
|
+
start60k: number,
|
|
888
|
+
swing60k: number,
|
|
889
|
+
) => {
|
|
890
|
+
const start = ellipseAngle(start60k, radiusX, radiusY);
|
|
891
|
+
const end = ellipseAngle(start60k + swing60k, radiusX, radiusY);
|
|
892
|
+
const centerX = currentX - radiusX * Math.cos(start);
|
|
893
|
+
const centerY = currentY - radiusY * Math.sin(start);
|
|
894
|
+
currentX = centerX + radiusX * Math.cos(end);
|
|
895
|
+
currentY = centerY + radiusY * Math.sin(end);
|
|
896
|
+
const large = Math.abs(swing60k) > 180 * 60000 ? 1 : 0;
|
|
897
|
+
const sweep = swing60k >= 0 !== mirrored ? 1 : 0;
|
|
898
|
+
parts.push(
|
|
899
|
+
`A${round1(radiusX)} ${round1(radiusY)} 0 ${large} ${sweep} ${toX(currentX)} ${toY(currentY)}`,
|
|
900
|
+
);
|
|
901
|
+
},
|
|
902
|
+
close: () => parts.push("Z"),
|
|
903
|
+
path: () => parts.join(" "),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Preset geometries whose outline needs an arc or a curve, so no polygon can
|
|
909
|
+
* state them. Each is OOXML's own `gdLst` and `pathLst` for that preset
|
|
910
|
+
* evaluated against the shape's box and its `a:avLst` adjustments — a
|
|
911
|
+
* `uturnArrow`'s shaft width, head width, head length and bend radius all live
|
|
912
|
+
* there, and a version built from the preset's defaults draws a different
|
|
913
|
+
* arrow from the one the deck asked for.
|
|
914
|
+
*/
|
|
915
|
+
const PRESET_PATH_GEOMETRIES: Record<
|
|
916
|
+
string,
|
|
917
|
+
(
|
|
918
|
+
pen: ReturnType<typeof presetPen>,
|
|
919
|
+
w: number,
|
|
920
|
+
h: number,
|
|
921
|
+
adj: Record<string, number> | undefined,
|
|
922
|
+
) => void
|
|
923
|
+
> = {
|
|
924
|
+
heart: (pen, w, h) => {
|
|
925
|
+
// The one preset here with no adjustments at all: two cubics whose control
|
|
926
|
+
// points sit outside the box on purpose — `hc - dx1` is left of it and
|
|
927
|
+
// `y1` is a third of the height above it, which is what gives the lobes
|
|
928
|
+
// their overhang.
|
|
929
|
+
const hc = w / 2;
|
|
930
|
+
const quarter = h / 4;
|
|
931
|
+
const dx1 = (w * 49) / 48;
|
|
932
|
+
const dx2 = (w * 10) / 48;
|
|
933
|
+
const y1 = -h / 3;
|
|
934
|
+
pen.move(hc, quarter);
|
|
935
|
+
pen.cubic([
|
|
936
|
+
[hc + dx2, y1],
|
|
937
|
+
[hc + dx1, quarter],
|
|
938
|
+
[hc, h],
|
|
939
|
+
]);
|
|
940
|
+
pen.cubic([
|
|
941
|
+
[hc - dx1, quarter],
|
|
942
|
+
[hc - dx2, y1],
|
|
943
|
+
[hc, quarter],
|
|
944
|
+
]);
|
|
945
|
+
pen.close();
|
|
946
|
+
},
|
|
947
|
+
pie: (pen, w, h, adj) => {
|
|
948
|
+
const radiusX = w / 2;
|
|
949
|
+
const radiusY = h / 2;
|
|
950
|
+
const startAngle = pin(0, adj?.adj1 ?? 0, 21599999);
|
|
951
|
+
const endAngle = pin(0, adj?.adj2 ?? 16200000, 21599999);
|
|
952
|
+
const span = endAngle - startAngle;
|
|
953
|
+
// OOXML's `?: sw1 sw1 sw2` wraps a backwards slice forward a full turn. A
|
|
954
|
+
// slice that then closes on itself is a whole disc in PowerPoint but draws
|
|
955
|
+
// nothing at all in SVG, where the arc's two ends coincide.
|
|
956
|
+
const swing = Math.min(span > 0 ? span : span + 21600000, 21594000);
|
|
957
|
+
const start = ellipseAngle(startAngle, radiusX, radiusY);
|
|
958
|
+
pen.move(
|
|
959
|
+
radiusX + radiusX * Math.cos(start),
|
|
960
|
+
radiusY + radiusY * Math.sin(start),
|
|
961
|
+
);
|
|
962
|
+
pen.arc(radiusX, radiusY, startAngle, swing);
|
|
963
|
+
pen.line(radiusX, radiusY);
|
|
964
|
+
pen.close();
|
|
965
|
+
},
|
|
966
|
+
uturnArrow: (pen, w, h, adj) => {
|
|
967
|
+
// A stadium-shaped arrow: two parallel straight runs joined by a 180°
|
|
968
|
+
// bend, with the head on the returning run. `adj1` is the shaft width,
|
|
969
|
+
// `adj2` the half head width, `adj3` the head length, `adj4` the bend
|
|
970
|
+
// radius and `adj5` how far down the box the head's tip reaches — each a
|
|
971
|
+
// fraction of the shortest side except `adj5`, which is of the height.
|
|
972
|
+
const ss = Math.min(w, h);
|
|
973
|
+
const a2 = pin(0, adj?.adj2 ?? 25000, 25000);
|
|
974
|
+
const a1 = pin(0, adj?.adj1 ?? 25000, a2 * 2);
|
|
975
|
+
const a3 = pin(0, adj?.adj3 ?? 25000, ((100000 - (a1 * ss) / h) * h) / ss);
|
|
976
|
+
const a5 = pin(((a3 + a1) * ss) / h, adj?.adj5 ?? 75000, 100000);
|
|
977
|
+
const th = (ss * a1) / 100000;
|
|
978
|
+
const aw2 = (ss * a2) / 100000;
|
|
979
|
+
const dh2 = aw2 - th / 2;
|
|
980
|
+
const y5 = (h * a5) / 100000;
|
|
981
|
+
const y4 = y5 - (ss * a3) / 100000;
|
|
982
|
+
const x9 = w - dh2;
|
|
983
|
+
const a4 = pin(0, adj?.adj4 ?? 43750, (Math.min(x9 / 2, y4) * 100000) / ss);
|
|
984
|
+
const bd = (ss * a4) / 100000;
|
|
985
|
+
const bd2 = Math.max(bd - th, 0);
|
|
986
|
+
const x3 = th + bd2;
|
|
987
|
+
const x8 = w - aw2;
|
|
988
|
+
const x6 = x8 - aw2;
|
|
989
|
+
const x7 = x6 + dh2;
|
|
990
|
+
pen.move(0, h);
|
|
991
|
+
pen.line(0, bd);
|
|
992
|
+
pen.arc(bd, bd, 10800000, 5400000);
|
|
993
|
+
pen.line(x9 - bd, 0);
|
|
994
|
+
pen.arc(bd, bd, 16200000, 5400000);
|
|
995
|
+
pen.line(x9, y4);
|
|
996
|
+
pen.line(w, y4);
|
|
997
|
+
pen.line(x8, y5);
|
|
998
|
+
pen.line(x6, y4);
|
|
999
|
+
pen.line(x7, y4);
|
|
1000
|
+
// Not a mis-copied `y3`: OOXML reuses the guide named `x3` as this
|
|
1001
|
+
// vertical, and it is where the inner bend starts.
|
|
1002
|
+
pen.line(x7, x3);
|
|
1003
|
+
pen.arc(bd2, bd2, 0, -5400000);
|
|
1004
|
+
pen.line(x3, th);
|
|
1005
|
+
pen.arc(bd2, bd2, 16200000, -5400000);
|
|
1006
|
+
pen.line(th, h);
|
|
1007
|
+
pen.close();
|
|
1008
|
+
},
|
|
1009
|
+
bentArrow: (pen, w, h, adj) => {
|
|
1010
|
+
// The same shaft/head/bend adjustments as `uturnArrow`, but one 90° bend
|
|
1011
|
+
// instead of a 180° one: up the left edge, round the corner, right to a
|
|
1012
|
+
// head pointing at the box's right side.
|
|
1013
|
+
const ss = Math.min(w, h);
|
|
1014
|
+
const a2 = pin(0, adj?.adj2 ?? 25000, 50000);
|
|
1015
|
+
const a1 = pin(0, adj?.adj1 ?? 25000, a2 * 2);
|
|
1016
|
+
const a3 = pin(0, adj?.adj3 ?? 25000, 50000);
|
|
1017
|
+
const th = (ss * a1) / 100000;
|
|
1018
|
+
const aw2 = (ss * a2) / 100000;
|
|
1019
|
+
const dh2 = aw2 - th / 2;
|
|
1020
|
+
const ah = (ss * a3) / 100000;
|
|
1021
|
+
const a4 = pin(
|
|
1022
|
+
0,
|
|
1023
|
+
adj?.adj4 ?? 43750,
|
|
1024
|
+
(100000 * Math.min(w - ah, h - dh2)) / ss,
|
|
1025
|
+
);
|
|
1026
|
+
const bd = (ss * a4) / 100000;
|
|
1027
|
+
const bd2 = Math.max(bd - th, 0);
|
|
1028
|
+
const x4 = w - ah;
|
|
1029
|
+
const y3 = dh2 + th;
|
|
1030
|
+
pen.move(0, h);
|
|
1031
|
+
pen.line(0, dh2 + bd);
|
|
1032
|
+
pen.arc(bd, bd, 10800000, 5400000);
|
|
1033
|
+
pen.line(x4, dh2);
|
|
1034
|
+
pen.line(x4, 0);
|
|
1035
|
+
pen.line(w, aw2);
|
|
1036
|
+
pen.line(x4, y3 + dh2);
|
|
1037
|
+
pen.line(x4, y3);
|
|
1038
|
+
pen.line(th + bd2, y3);
|
|
1039
|
+
pen.arc(bd2, bd2, 16200000, -5400000);
|
|
1040
|
+
pen.line(th, h);
|
|
1041
|
+
pen.close();
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
function presetGeometryPath(
|
|
1046
|
+
element: ParsedElement,
|
|
1047
|
+
widthPx: number,
|
|
1048
|
+
heightPx: number,
|
|
1049
|
+
): string | undefined {
|
|
1050
|
+
const build = element.shapeType
|
|
1051
|
+
? PRESET_PATH_GEOMETRIES[element.shapeType]
|
|
1052
|
+
: undefined;
|
|
1053
|
+
if (!build) return undefined;
|
|
1054
|
+
if (!(widthPx > 0) || !(heightPx > 0)) return undefined;
|
|
1055
|
+
const pen = presetPen(widthPx, heightPx, element.flipH, element.flipV);
|
|
1056
|
+
build(pen, widthPx, heightPx, element.shapeAdjustments);
|
|
1057
|
+
return pen.path();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Reproduce the shape's declared preset geometry. `shapeType` is the only
|
|
1062
|
+
* geometry the parser records, so this maps the preset to the CSS that draws
|
|
1063
|
+
* it — without it every preset renders as the plain rectangle its bounding
|
|
1064
|
+
* box happens to be.
|
|
1065
|
+
*/
|
|
1066
|
+
function geometryCss(
|
|
1067
|
+
element: ParsedElement,
|
|
1068
|
+
widthPx: number,
|
|
1069
|
+
heightPx: number,
|
|
1070
|
+
): string {
|
|
1071
|
+
const shapeType = element.shapeType;
|
|
1072
|
+
const adjustments = element.shapeAdjustments;
|
|
1073
|
+
if (!shapeType) return "";
|
|
1074
|
+
const shortest = Math.min(widthPx, heightPx);
|
|
1075
|
+
const corner = round3(shortest * DEFAULT_CORNER_ADJUSTMENT);
|
|
1076
|
+
switch (shapeType) {
|
|
1077
|
+
case "ellipse":
|
|
1078
|
+
case "smileyFace":
|
|
1079
|
+
return "border-radius: 50%;";
|
|
1080
|
+
case "roundRect":
|
|
1081
|
+
return `border-radius: ${corner}px;`;
|
|
1082
|
+
case "round1Rect":
|
|
1083
|
+
return `border-radius: 0 ${corner}px 0 0;`;
|
|
1084
|
+
case "round2SameRect":
|
|
1085
|
+
return `border-radius: ${corner}px ${corner}px 0 0;`;
|
|
1086
|
+
case "round2DiagRect":
|
|
1087
|
+
return `border-radius: ${corner}px 0 ${corner}px 0;`;
|
|
1088
|
+
// OOXML defines this one as a `roundRect` whose adjustment is pinned at
|
|
1089
|
+
// 50%: a pill, and a flow chart's start and end nodes are the one place a
|
|
1090
|
+
// reader tells them apart from its process boxes by shape alone.
|
|
1091
|
+
case "flowChartTerminator":
|
|
1092
|
+
return `border-radius: ${round3(shortest / 2)}px;`;
|
|
1093
|
+
case "blockArc": {
|
|
1094
|
+
const path = blockArcPath(adjustments, widthPx, heightPx);
|
|
1095
|
+
return path ? `clip-path: path('${path}');` : "";
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
const presetPath = presetGeometryPath(element, widthPx, heightPx);
|
|
1099
|
+
if (presetPath) return `clip-path: path('${presetPath}');`;
|
|
1100
|
+
const points = CLIP_PATH_GEOMETRIES[shapeType]?.(
|
|
1101
|
+
widthPx,
|
|
1102
|
+
heightPx,
|
|
1103
|
+
shortest,
|
|
1104
|
+
adjustments,
|
|
1105
|
+
);
|
|
1106
|
+
if (!points) return "";
|
|
1107
|
+
const polygon = points
|
|
1108
|
+
.map(([x, y]) => `${toPercent(x, widthPx)}% ${toPercent(y, heightPx)}%`)
|
|
1109
|
+
.join(", ");
|
|
1110
|
+
return `clip-path: polygon(${polygon});`;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* The outline a preset geometry is *clipped* to, as an SVG path — the same
|
|
1115
|
+
* shape `geometryCss` writes into `clip-path`, in the form a stroke can
|
|
1116
|
+
* follow. `border` only paints the bounding box's four edges, so on a clipped
|
|
1117
|
+
* preset the clip then removes every part of them the outline does not cover:
|
|
1118
|
+
* a stroke-only triangle keeps a sliver of its base and loses both diagonals,
|
|
1119
|
+
* which is the whole shape gone. Presets that clip nothing (`ellipse`,
|
|
1120
|
+
* `roundRect`) are absent on purpose — `border` plus `border-radius` draws
|
|
1121
|
+
* those correctly.
|
|
1122
|
+
*/
|
|
1123
|
+
function clippedPresetPath(
|
|
1124
|
+
element: ParsedElement,
|
|
1125
|
+
widthPx: number,
|
|
1126
|
+
heightPx: number,
|
|
1127
|
+
): string | undefined {
|
|
1128
|
+
const shapeType = element.shapeType;
|
|
1129
|
+
const adjustments = element.shapeAdjustments;
|
|
1130
|
+
if (!shapeType) return undefined;
|
|
1131
|
+
if (shapeType === "blockArc") {
|
|
1132
|
+
return blockArcPath(adjustments, widthPx, heightPx);
|
|
1133
|
+
}
|
|
1134
|
+
const presetPath = presetGeometryPath(element, widthPx, heightPx);
|
|
1135
|
+
if (presetPath) return presetPath;
|
|
1136
|
+
const points = CLIP_PATH_GEOMETRIES[shapeType]?.(
|
|
1137
|
+
widthPx,
|
|
1138
|
+
heightPx,
|
|
1139
|
+
Math.min(widthPx, heightPx),
|
|
1140
|
+
adjustments,
|
|
1141
|
+
);
|
|
1142
|
+
if (!points) return undefined;
|
|
1143
|
+
return `${points
|
|
1144
|
+
.map(
|
|
1145
|
+
([x, y], index) => `${index === 0 ? "M" : "L"}${round1(x)} ${round1(y)}`,
|
|
1146
|
+
)
|
|
1147
|
+
.join(" ")} Z`;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function round3(value: number): number {
|
|
1151
|
+
return Math.round(value * 1000) / 1000;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Path coordinates are rounded harder than the rest of the renderer: a single
|
|
1156
|
+
* freeform illustration can carry ten thousand of them, and at the 960px
|
|
1157
|
+
* reference width 0.1px is well under one device pixel on any display. Three
|
|
1158
|
+
* decimals instead costs ~25% more HTML on a slide that is already the
|
|
1159
|
+
* largest this importer produces.
|
|
1160
|
+
*/
|
|
1161
|
+
function round1(value: number): number {
|
|
1162
|
+
return Math.round(value * 10) / 10;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Freeform path data is by far the largest thing this importer stores: one
|
|
1167
|
+
* decorative layout illustration in a real template is 86KB of coordinates,
|
|
1168
|
+
* and the layout layer repeats it on every slide that uses the layout. Writing
|
|
1169
|
+
* it in absolute commands spends 5-6 characters per coordinate on the shape's
|
|
1170
|
+
* own box origin; the same outline in relative commands spends 1-3 on the step
|
|
1171
|
+
* from the previous point.
|
|
1172
|
+
*
|
|
1173
|
+
* Deltas are measured against the point that was actually *emitted*, never the
|
|
1174
|
+
* exact one, so per-step rounding cannot accumulate: every emitted point stays
|
|
1175
|
+
* within half a rounding unit of its true position no matter how many
|
|
1176
|
+
* thousands of segments precede it.
|
|
1177
|
+
*/
|
|
1178
|
+
function createPathWriter() {
|
|
1179
|
+
let out = "";
|
|
1180
|
+
let lastCommand = "";
|
|
1181
|
+
let afterLetter = false;
|
|
1182
|
+
let previousNumberHadPoint = false;
|
|
1183
|
+
let x = 0;
|
|
1184
|
+
let y = 0;
|
|
1185
|
+
let subpathX = 0;
|
|
1186
|
+
let subpathY = 0;
|
|
1187
|
+
|
|
1188
|
+
/** Writes the shortest legal spelling of `value` and returns what it rounded to. */
|
|
1189
|
+
const number = (value: number): number => {
|
|
1190
|
+
const rounded = round1(value);
|
|
1191
|
+
let text = String(rounded);
|
|
1192
|
+
if (text.startsWith("0.")) text = text.slice(1);
|
|
1193
|
+
else if (text.startsWith("-0.")) text = `-${text.slice(2)}`;
|
|
1194
|
+
// A leading `.` only runs into the previous number when that one had no
|
|
1195
|
+
// decimal point of its own: `1 .5` may compact to `1.5`, which is one
|
|
1196
|
+
// number, but `1.5 .5` reads as two either way.
|
|
1197
|
+
const joins =
|
|
1198
|
+
afterLetter ||
|
|
1199
|
+
text.startsWith("-") ||
|
|
1200
|
+
(text.startsWith(".") && previousNumberHadPoint);
|
|
1201
|
+
out += joins ? text : ` ${text}`;
|
|
1202
|
+
afterLetter = false;
|
|
1203
|
+
previousNumberHadPoint = text.includes(".");
|
|
1204
|
+
return rounded;
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
const command = (letter: string) => {
|
|
1208
|
+
// SVG repeats the previous command for a bare run of coordinates, and the
|
|
1209
|
+
// command implied after a `moveto` is `lineto`.
|
|
1210
|
+
if (lastCommand === letter) return;
|
|
1211
|
+
out += letter;
|
|
1212
|
+
afterLetter = true;
|
|
1213
|
+
lastCommand = letter === "m" ? "l" : letter;
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
/** `points` are absolute px; every one is written relative to the point the command starts from. */
|
|
1218
|
+
write(letter: string, points: { x: number; y: number }[]) {
|
|
1219
|
+
command(letter);
|
|
1220
|
+
const fromX = x;
|
|
1221
|
+
const fromY = y;
|
|
1222
|
+
for (const point of points) {
|
|
1223
|
+
x = fromX + number(point.x - fromX);
|
|
1224
|
+
y = fromY + number(point.y - fromY);
|
|
1225
|
+
}
|
|
1226
|
+
if (letter === "m") {
|
|
1227
|
+
subpathX = x;
|
|
1228
|
+
subpathY = y;
|
|
1229
|
+
}
|
|
1230
|
+
},
|
|
1231
|
+
arc(
|
|
1232
|
+
radiusX: number,
|
|
1233
|
+
radiusY: number,
|
|
1234
|
+
largeArc: number,
|
|
1235
|
+
sweep: number,
|
|
1236
|
+
toX: number,
|
|
1237
|
+
toY: number,
|
|
1238
|
+
) {
|
|
1239
|
+
command("a");
|
|
1240
|
+
number(radiusX);
|
|
1241
|
+
number(radiusY);
|
|
1242
|
+
number(0);
|
|
1243
|
+
number(largeArc);
|
|
1244
|
+
number(sweep);
|
|
1245
|
+
const fromX = x;
|
|
1246
|
+
const fromY = y;
|
|
1247
|
+
x = fromX + number(toX - fromX);
|
|
1248
|
+
y = fromY + number(toY - fromY);
|
|
1249
|
+
},
|
|
1250
|
+
close() {
|
|
1251
|
+
out += "z";
|
|
1252
|
+
afterLetter = true;
|
|
1253
|
+
// `z` returns the pen to where the subpath started, and no command is
|
|
1254
|
+
// implied after it.
|
|
1255
|
+
lastCommand = "";
|
|
1256
|
+
x = subpathX;
|
|
1257
|
+
y = subpathY;
|
|
1258
|
+
},
|
|
1259
|
+
result: () => out,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Convert a shape's `a:custGeom` outline into an SVG path `d` string in the
|
|
1265
|
+
* shape's own pixel box. Every OOXML path command has an exact SVG
|
|
1266
|
+
* counterpart, so a freeform outline — a country on a map, a line-art
|
|
1267
|
+
* pictogram, one segment of a curved-arrow ring — is reproduced rather than
|
|
1268
|
+
* flattened into the rectangle its bounding box happens to be, which is what
|
|
1269
|
+
* turns a 422-path world map into a field of staircase blocks.
|
|
1270
|
+
*
|
|
1271
|
+
* Returns `undefined` rather than a partial path when the geometry cannot be
|
|
1272
|
+
* converted, so the caller falls back to the shape's existing rendering
|
|
1273
|
+
* instead of clipping it down to a fragment.
|
|
1274
|
+
*/
|
|
1275
|
+
function customGeometryPath(
|
|
1276
|
+
element: ParsedElement,
|
|
1277
|
+
widthPx: number,
|
|
1278
|
+
heightPx: number,
|
|
1279
|
+
): string | undefined {
|
|
1280
|
+
const geometry = element.geometry;
|
|
1281
|
+
if (!geometry || geometry.kind !== "custom") return undefined;
|
|
1282
|
+
if (!(widthPx > 0) || !(heightPx > 0)) return undefined;
|
|
1283
|
+
const writer = createPathWriter();
|
|
1284
|
+
let wrote = false;
|
|
1285
|
+
for (const path of geometry.paths) {
|
|
1286
|
+
const scaleX = widthPx / path.w;
|
|
1287
|
+
const scaleY = heightPx / path.h;
|
|
1288
|
+
// OOXML path space is top-left origin like SVG's, so only the shape's own
|
|
1289
|
+
// `flipH`/`flipV` mirror it — there is no axis flip to undo.
|
|
1290
|
+
const toX = (x: number) =>
|
|
1291
|
+
element.flipH ? widthPx - x * scaleX : x * scaleX;
|
|
1292
|
+
const toY = (y: number) =>
|
|
1293
|
+
element.flipV ? heightPx - y * scaleY : y * scaleY;
|
|
1294
|
+
let currentX = 0;
|
|
1295
|
+
let currentY = 0;
|
|
1296
|
+
for (const command of path.commands) {
|
|
1297
|
+
wrote = true;
|
|
1298
|
+
if (command.kind === "close") {
|
|
1299
|
+
writer.close();
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
if (command.kind === "arcTo") {
|
|
1303
|
+
const start = (command.stAng / 60000) * (Math.PI / 180);
|
|
1304
|
+
const swing = (command.swAng / 60000) * (Math.PI / 180);
|
|
1305
|
+
// OOXML gives the arc's radii and angles but not its center: the
|
|
1306
|
+
// current point is the arc's start, so the center is that point
|
|
1307
|
+
// walked back along the start angle.
|
|
1308
|
+
const centerX = currentX - command.wR * Math.cos(start);
|
|
1309
|
+
const centerY = currentY - command.hR * Math.sin(start);
|
|
1310
|
+
currentX = centerX + command.wR * Math.cos(start + swing);
|
|
1311
|
+
currentY = centerY + command.hR * Math.sin(start + swing);
|
|
1312
|
+
const largeArc = Math.abs(command.swAng) > 180 * 60000 ? 1 : 0;
|
|
1313
|
+
// A single mirror reverses the direction the arc sweeps in; two
|
|
1314
|
+
// cancel out.
|
|
1315
|
+
const sweep =
|
|
1316
|
+
command.swAng >= 0 !==
|
|
1317
|
+
(Boolean(element.flipH) !== Boolean(element.flipV))
|
|
1318
|
+
? 1
|
|
1319
|
+
: 0;
|
|
1320
|
+
writer.arc(
|
|
1321
|
+
command.wR * scaleX,
|
|
1322
|
+
command.hR * scaleY,
|
|
1323
|
+
largeArc,
|
|
1324
|
+
sweep,
|
|
1325
|
+
toX(currentX),
|
|
1326
|
+
toY(currentY),
|
|
1327
|
+
);
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
const last = command.points[command.points.length - 1]!;
|
|
1331
|
+
currentX = last.x;
|
|
1332
|
+
currentY = last.y;
|
|
1333
|
+
writer.write(
|
|
1334
|
+
{ moveTo: "m", lnTo: "l", quadBezTo: "q", cubicBezTo: "c" }[
|
|
1335
|
+
command.kind
|
|
1336
|
+
],
|
|
1337
|
+
command.points.map((point) => ({ x: toX(point.x), y: toY(point.y) })),
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return wrote ? writer.result() : undefined;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* A clipped outline's stroke follows the path, not the bounding box, so the
|
|
1346
|
+
* `border` shorthand cannot draw it — on a line-art pictogram or an
|
|
1347
|
+
* outline-only triangle (no fill, a stroked outline only) a border is exactly
|
|
1348
|
+
* the generic square the shape collapses into today. The path is stroked as
|
|
1349
|
+
* an overlay instead, leaving the fill to the div's own clipped background.
|
|
1350
|
+
*/
|
|
1351
|
+
function customGeometryStroke(
|
|
1352
|
+
element: ParsedElement,
|
|
1353
|
+
pathData: string,
|
|
1354
|
+
widthEmu: number,
|
|
1355
|
+
refWidthPx: number,
|
|
1356
|
+
widthPx: number,
|
|
1357
|
+
heightPx: number,
|
|
1358
|
+
): string {
|
|
1359
|
+
if (!element.lineColor) return "";
|
|
1360
|
+
const stroke = Math.max(
|
|
1361
|
+
1,
|
|
1362
|
+
toSlidePxX(element.lineWidth ?? 12700, widthEmu, refWidthPx),
|
|
1363
|
+
);
|
|
1364
|
+
return `<svg viewBox="0 0 ${round3(widthPx)} ${round3(heightPx)}" preserveAspectRatio="none" style="position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;"><path d="${esc(pathData)}" fill="none" stroke="${esc(element.lineColor)}" stroke-width="${stroke}" /></svg>`;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* A PPTX line or connector is a box with one dimension of zero — or thinner
|
|
1369
|
+
* than the two borders that would have to meet inside it. Emitting the
|
|
1370
|
+
* `border` shorthand on it paints *both* parallel edges, so the rule draws at
|
|
1371
|
+
* twice its authored weight, overruns its own length by the stroke width at
|
|
1372
|
+
* each end, and grows perpendicular nubs from the two edges that should not
|
|
1373
|
+
* exist at all. A box with no room for an interior gets the single edge it is.
|
|
1374
|
+
*/
|
|
1375
|
+
function strokeDecoration(
|
|
1376
|
+
element: ParsedElement,
|
|
1377
|
+
widthEmu: number,
|
|
1378
|
+
refWidthPx: number,
|
|
1379
|
+
widthPx: number,
|
|
1380
|
+
heightPx: number,
|
|
1381
|
+
): string {
|
|
1382
|
+
if (!element.lineColor) return "";
|
|
1383
|
+
const stroke = strokeWidthPx(element, widthEmu, refWidthPx);
|
|
1384
|
+
const color = esc(element.lineColor);
|
|
1385
|
+
const axis = lineAxis(widthPx, heightPx, stroke);
|
|
1386
|
+
if (axis === "x") return `border-top: ${stroke}px solid ${color};`;
|
|
1387
|
+
if (axis === "y") return `border-left: ${stroke}px solid ${color};`;
|
|
1388
|
+
return `border: ${stroke}px solid ${color};`;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function strokeWidthPx(
|
|
1392
|
+
element: ParsedElement,
|
|
1393
|
+
widthEmu: number,
|
|
1394
|
+
refWidthPx: number,
|
|
1395
|
+
): number {
|
|
1396
|
+
return Math.max(
|
|
1397
|
+
1,
|
|
1398
|
+
toSlidePxX(element.lineWidth ?? 12700, widthEmu, refWidthPx),
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* The axis a degenerate box draws its line along, or `undefined` for a box
|
|
1404
|
+
* with room for a real four-sided outline. The longer axis has to win, or a
|
|
1405
|
+
* small square outline (a 2px dot, say) would lose three of its four edges.
|
|
1406
|
+
*/
|
|
1407
|
+
function lineAxis(
|
|
1408
|
+
widthPx: number,
|
|
1409
|
+
heightPx: number,
|
|
1410
|
+
stroke: number,
|
|
1411
|
+
): "x" | "y" | undefined {
|
|
1412
|
+
if (heightPx < stroke * 2 && widthPx > heightPx) return "x";
|
|
1413
|
+
if (widthPx < stroke * 2 && heightPx > widthPx) return "y";
|
|
1414
|
+
return undefined;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* `a:headEnd`/`a:tailEnd` `@_w` sizes, as multiples of the line's own width:
|
|
1419
|
+
* PowerPoint scales a line end with its stroke rather than to a fixed size, so
|
|
1420
|
+
* a 1.5pt connector and a 6pt one do not get the same dot.
|
|
1421
|
+
*/
|
|
1422
|
+
const LINE_END_SCALE: Record<string, number> = { sm: 2, med: 3, lg: 5 };
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* The round dots a connector terminates in (`<a:headEnd type="oval"/>` — the
|
|
1426
|
+
* ends of every rule on a chevron timeline) are a decoration on top of the
|
|
1427
|
+
* stroke, not part of it, so neither the border above nor
|
|
1428
|
+
* `customGeometryStroke` draws them and they were dropped on import.
|
|
1429
|
+
*
|
|
1430
|
+
* Only `oval` is reproduced. An arrowhead's shape and orientation are not
|
|
1431
|
+
* something this can get right unverified, and a wrong arrowhead reads worse
|
|
1432
|
+
* than the bare line the source at least still communicates; the parsed end
|
|
1433
|
+
* survives on the element either way, so an unrendered one stays
|
|
1434
|
+
* distinguishable from a line the source drew bare.
|
|
1435
|
+
*/
|
|
1436
|
+
function lineEndCaps(
|
|
1437
|
+
element: ParsedElement,
|
|
1438
|
+
widthEmu: number,
|
|
1439
|
+
refWidthPx: number,
|
|
1440
|
+
widthPx: number,
|
|
1441
|
+
heightPx: number,
|
|
1442
|
+
): string {
|
|
1443
|
+
const head = ovalCapRadius(element.lineHeadEnd);
|
|
1444
|
+
const tail = ovalCapRadius(element.lineTailEnd);
|
|
1445
|
+
if (!element.lineColor || (!head && !tail)) return "";
|
|
1446
|
+
const stroke = strokeWidthPx(element, widthEmu, refWidthPx);
|
|
1447
|
+
const axis = lineAxis(widthPx, heightPx, stroke);
|
|
1448
|
+
// Only the line case: on a four-sided outline there is no "end" to cap.
|
|
1449
|
+
if (!axis) return "";
|
|
1450
|
+
// The border paints its line half a stroke inside the box's own edge, and a
|
|
1451
|
+
// cap is centred on the line's endpoint, not offset from it.
|
|
1452
|
+
const along = axis === "x" ? widthPx : heightPx;
|
|
1453
|
+
const flipped = axis === "x" ? element.flipH : element.flipV;
|
|
1454
|
+
const point = (distance: number, radius: number) => ({
|
|
1455
|
+
radius: radius * stroke,
|
|
1456
|
+
x: axis === "x" ? distance : stroke / 2,
|
|
1457
|
+
y: axis === "x" ? stroke / 2 : distance,
|
|
1458
|
+
});
|
|
1459
|
+
const caps = [
|
|
1460
|
+
head ? point(flipped ? along : 0, head) : undefined,
|
|
1461
|
+
tail ? point(flipped ? 0 : along, tail) : undefined,
|
|
1462
|
+
].filter((cap) => cap !== undefined);
|
|
1463
|
+
// A zero-width viewBox disables rendering, and every capped line has one
|
|
1464
|
+
// degenerate axis — the overlay is inset by its own largest cap so both the
|
|
1465
|
+
// box and the part of the cap hanging past the line's end stay inside it.
|
|
1466
|
+
const pad = Math.max(...caps.map((cap) => cap.radius));
|
|
1467
|
+
const boxWidth = round3(widthPx + pad * 2);
|
|
1468
|
+
const boxHeight = round3(heightPx + pad * 2);
|
|
1469
|
+
const circles = caps
|
|
1470
|
+
.map(
|
|
1471
|
+
(cap) =>
|
|
1472
|
+
`<circle cx="${round3(cap.x + pad)}" cy="${round3(cap.y + pad)}" r="${round3(cap.radius)}" fill="${esc(element.lineColor ?? "")}" />`,
|
|
1473
|
+
)
|
|
1474
|
+
.join("");
|
|
1475
|
+
return `<svg viewBox="0 0 ${boxWidth} ${boxHeight}" style="position:absolute;left:${round3(-pad)}px;top:${round3(-pad)}px;width:${boxWidth}px;height:${boxHeight}px;overflow:visible;pointer-events:none;">${circles}</svg>`;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** The cap's radius as a multiple of the line's stroke width, or `undefined` for an end this does not draw. */
|
|
1479
|
+
function ovalCapRadius(end: ParsedElement["lineHeadEnd"]): number | undefined {
|
|
1480
|
+
if (end?.type !== "oval") return undefined;
|
|
1481
|
+
return (LINE_END_SCALE[end.w ?? "med"] ?? LINE_END_SCALE.med) / 2;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
334
1484
|
function shapeDecoration(
|
|
335
1485
|
element: ParsedElement,
|
|
336
1486
|
widthEmu: number,
|
|
337
1487
|
refWidthPx: number,
|
|
1488
|
+
widthPx: number,
|
|
1489
|
+
heightPx: number,
|
|
1490
|
+
customPath: string | undefined,
|
|
1491
|
+
outlinePath: string | undefined,
|
|
338
1492
|
): string {
|
|
1493
|
+
// A reproduced outline is no longer an occluding box, so it paints its real
|
|
1494
|
+
// fill even when its preset is one this renderer cannot otherwise draw.
|
|
1495
|
+
if (
|
|
1496
|
+
!customPath &&
|
|
1497
|
+
element.shapeType &&
|
|
1498
|
+
UNRENDERABLE_GEOMETRIES.has(element.shapeType)
|
|
1499
|
+
) {
|
|
1500
|
+
return "";
|
|
1501
|
+
}
|
|
339
1502
|
const fill = element.fill ? `background: ${esc(element.fill)};` : "";
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
1503
|
+
if (customPath) {
|
|
1504
|
+
// No `border`: the stroke follows the outline, and `customGeometryStroke`
|
|
1505
|
+
// draws it. Clipping an unfilled box would only eat half that stroke, so
|
|
1506
|
+
// the clip is the fill's, not the shape's.
|
|
1507
|
+
return fill ? `${fill}clip-path: path('${customPath}');` : "";
|
|
1508
|
+
}
|
|
1509
|
+
// Same reason as above for a clipped preset: `border` draws the box, the
|
|
1510
|
+
// clip eats it, and `customGeometryStroke` draws the outline instead.
|
|
1511
|
+
const line = outlinePath
|
|
1512
|
+
? ""
|
|
1513
|
+
: strokeDecoration(element, widthEmu, refWidthPx, widthPx, heightPx);
|
|
1514
|
+
return `${fill}${line}${geometryCss(element, widthPx, heightPx)}`;
|
|
345
1515
|
}
|
|
346
1516
|
|
|
347
1517
|
function textBoxStyle(
|
|
@@ -368,6 +1538,35 @@ function textBoxStyle(
|
|
|
368
1538
|
return `display:flex;flex-direction:column;${vertical}padding:${top}px ${right}px ${bottom}px ${left}px;font-family:${cssFontFamily(themeFont)};text-align:${align};overflow:visible;`;
|
|
369
1539
|
}
|
|
370
1540
|
|
|
1541
|
+
/**
|
|
1542
|
+
* OOXML's default `a:lnSpc` is `spcPct val="100000"` — single spacing. The
|
|
1543
|
+
* parser resolves that declared value against the font's own line height
|
|
1544
|
+
* (`SINGLE_LINE_SPACING_RATIO`), so an inherited default has to land on the
|
|
1545
|
+
* same number, or an unspecified paragraph renders tighter than the identical
|
|
1546
|
+
* paragraph that states its spacing explicitly.
|
|
1547
|
+
*/
|
|
1548
|
+
const DEFAULT_LINE_SPACING = 1.2;
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* The first size any run in this text box declares. A blank spacer paragraph
|
|
1552
|
+
* has no run to read a size from, so it would otherwise fall back to the
|
|
1553
|
+
* format-wide default and reserve a taller empty line than the copy it
|
|
1554
|
+
* separates — every blank paragraph in a 14pt box adding a few px of drift
|
|
1555
|
+
* that pushes the rest of the box down. Its real size lives in
|
|
1556
|
+
* `<a:endParaRPr>`, which the parser does not surface; the box's own declared
|
|
1557
|
+
* size is the closest value the source actually states.
|
|
1558
|
+
*/
|
|
1559
|
+
function firstDeclaredFontSizePt(
|
|
1560
|
+
paragraphs: ParsedParagraph[] | undefined,
|
|
1561
|
+
): number | undefined {
|
|
1562
|
+
for (const paragraph of paragraphs ?? []) {
|
|
1563
|
+
for (const run of paragraph.runs) {
|
|
1564
|
+
if (run.fontSize !== undefined) return run.fontSize;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return undefined;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
371
1570
|
function buildFidelityParagraph(
|
|
372
1571
|
paragraph: ParsedParagraph,
|
|
373
1572
|
paragraphIndex: number,
|
|
@@ -375,17 +1574,27 @@ function buildFidelityParagraph(
|
|
|
375
1574
|
refWidthPx: number,
|
|
376
1575
|
themeFont: string | undefined,
|
|
377
1576
|
defaultFontWeight: number,
|
|
1577
|
+
boxFontSizePt?: number,
|
|
378
1578
|
): string {
|
|
379
1579
|
const firstRun = paragraph.runs[0];
|
|
380
|
-
const
|
|
381
|
-
|
|
1580
|
+
const paragraphFontSizePt =
|
|
1581
|
+
firstRun?.fontSize ??
|
|
1582
|
+
(paragraph.runs.length === 0 ? boxFontSizePt : undefined) ??
|
|
1583
|
+
DEFAULT_PPTX_FONT_SIZE_PT;
|
|
1584
|
+
const fontSize = ptToSlidePx(paragraphFontSizePt, widthEmu, refWidthPx);
|
|
1585
|
+
const lineHeight = paragraph.lineSpacing ?? DEFAULT_LINE_SPACING;
|
|
382
1586
|
const bulletFontSize = ptToSlidePx(
|
|
383
|
-
paragraph.bulletSize ??
|
|
1587
|
+
paragraph.bulletSize ?? paragraphFontSizePt,
|
|
384
1588
|
widthEmu,
|
|
385
1589
|
refWidthPx,
|
|
386
1590
|
);
|
|
1591
|
+
// `min-width` (not a hard `width`) with `white-space:nowrap`: the parent
|
|
1592
|
+
// paragraph inherits `white-space:pre-wrap`, and a hard width sized for a
|
|
1593
|
+
// single bullet glyph wrapped multi-character auto-num bullets like "2."
|
|
1594
|
+
// internally — the digit on one line, the period pushed onto the next
|
|
1595
|
+
// alongside the paragraph text.
|
|
387
1596
|
const bullet = paragraph.bulletChar
|
|
388
|
-
? `<span aria-hidden="true" style="display:inline-block;
|
|
1597
|
+
? `<span aria-hidden="true" style="display:inline-block;min-width:${fontSize * 0.75}px;white-space:nowrap;margin-right:${fontSize * 0.65}px;color:${esc(paragraph.bulletColor ?? firstRun?.color ?? DEFAULT_PPTX_FOREGROUND)};font-family:${cssFontFamily(paragraph.bulletFontFamily ?? themeFont)};font-size:${bulletFontSize}px;">${esc(paragraph.bulletChar)}</span>`
|
|
389
1598
|
: "";
|
|
390
1599
|
const marginLeft = paragraph.marginLeftEmu
|
|
391
1600
|
? toSlidePxX(paragraph.marginLeftEmu, widthEmu, refWidthPx)
|
|
@@ -409,7 +1618,15 @@ function buildFidelityParagraph(
|
|
|
409
1618
|
),
|
|
410
1619
|
)
|
|
411
1620
|
.join("");
|
|
412
|
-
|
|
1621
|
+
// A right-to-left paragraph needs its base direction stated, or the browser
|
|
1622
|
+
// infers one per run and mixed Arabic/Latin/numeral text reorders differently
|
|
1623
|
+
// than PowerPoint laid it out. The `dir` attribute is the semantic form the
|
|
1624
|
+
// export DOM walker and any non-sanitizing consumer read, but it is not in
|
|
1625
|
+
// `sanitizeSlideHtml`'s ALLOWED_ATTRS — so the CSS equivalent has to carry it
|
|
1626
|
+
// through the renderer, where only `style` survives.
|
|
1627
|
+
const direction = paragraph.rtl ? ` dir="rtl"` : "";
|
|
1628
|
+
const directionCss = paragraph.rtl ? "direction:rtl;" : "";
|
|
1629
|
+
return `<p data-pptx-paragraph="${paragraphIndex}"${direction} style="${directionCss}display:block;flex:0 0 auto;text-align:${paragraph.alignment ?? (paragraph.rtl ? "right" : "left")};white-space:pre-wrap;margin:${marginBefore}px 0 ${marginAfter}px;line-height:${lineHeight};font-size:${fontSize}px;min-height:${fontSize * lineHeight}px;padding-left:${marginLeft}px;text-indent:${paragraph.bulletChar ? 0 : indent}px;">${bullet.replace("display:inline-block;", `display:inline-block;${bulletMargin}`)}${text}</p>`;
|
|
413
1630
|
}
|
|
414
1631
|
|
|
415
1632
|
function formatFidelityRun(
|
|
@@ -420,7 +1637,7 @@ function formatFidelityRun(
|
|
|
420
1637
|
defaultFontWeight = 400,
|
|
421
1638
|
): string {
|
|
422
1639
|
const styles = [
|
|
423
|
-
`font-size:${ptToSlidePx(run.fontSize ??
|
|
1640
|
+
`font-size:${ptToSlidePx(run.fontSize ?? DEFAULT_PPTX_FONT_SIZE_PT, widthEmu, refWidthPx)}px`,
|
|
424
1641
|
`font-family:${cssFontFamily(run.fontFamily ?? themeFont)}`,
|
|
425
1642
|
`color:${esc(run.color ?? DEFAULT_PPTX_FOREGROUND)}`,
|
|
426
1643
|
`font-weight:${run.bold ? 700 : fontWeightForFamily(run.fontFamily, defaultFontWeight)}`,
|
|
@@ -446,7 +1663,8 @@ function fontWeightForFamily(
|
|
|
446
1663
|
const normalized = fontFamily?.toLowerCase() ?? "";
|
|
447
1664
|
if (!normalized) return fallback;
|
|
448
1665
|
if (/(?:semi|demi)bold|semibold/.test(normalized)) return 600;
|
|
449
|
-
if (/
|
|
1666
|
+
if (/black|heavy/.test(normalized)) return 900;
|
|
1667
|
+
if (/extra[- ]?bold|ultra[- ]?bold/.test(normalized)) return 800;
|
|
450
1668
|
if (/bold/.test(normalized)) return 700;
|
|
451
1669
|
if (/medium/.test(normalized)) return 500;
|
|
452
1670
|
if (/light|thin/.test(normalized)) return 300;
|