@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
|
@@ -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
|
|
|
@@ -172,7 +196,22 @@ const DEFAULT_SLIDE_HEIGHT_EMU = 5143500;
|
|
|
172
196
|
// (often dark, theme-default) text unreadable or fully invisible against a
|
|
173
197
|
// background the source file never actually specified.
|
|
174
198
|
const DEFAULT_PPTX_BACKGROUND = "#ffffff"; // guard:allow-raw-color - PPTX's own white default when no background is declared
|
|
175
|
-
|
|
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;
|
|
176
215
|
|
|
177
216
|
/**
|
|
178
217
|
* The absolute px box `toSlidePxX`/`toSlidePxY` scale positions and sizes
|
|
@@ -242,7 +281,9 @@ function buildFidelityElement(
|
|
|
242
281
|
imageUrls: string | Record<string, string> | undefined,
|
|
243
282
|
themeFont: string | undefined,
|
|
244
283
|
): string {
|
|
245
|
-
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;`;
|
|
246
287
|
const rotation = element.rotation
|
|
247
288
|
? ` transform: rotate(${element.rotation}deg); transform-origin: center center;`
|
|
248
289
|
: "";
|
|
@@ -251,7 +292,15 @@ function buildFidelityElement(
|
|
|
251
292
|
if (element.kind === "image") {
|
|
252
293
|
const url = imageUrlForElement(element, imageUrls);
|
|
253
294
|
const imageStyle = imageRenderStyle(element);
|
|
254
|
-
|
|
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>`;
|
|
255
304
|
}
|
|
256
305
|
|
|
257
306
|
if (element.kind === "table") {
|
|
@@ -266,9 +315,44 @@ function buildFidelityElement(
|
|
|
266
315
|
);
|
|
267
316
|
}
|
|
268
317
|
|
|
269
|
-
|
|
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
|
+
);
|
|
270
337
|
if (element.kind === "shape") {
|
|
271
|
-
|
|
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>`;
|
|
272
356
|
}
|
|
273
357
|
|
|
274
358
|
const textStyle = textBoxStyle(
|
|
@@ -279,6 +363,7 @@ function buildFidelityElement(
|
|
|
279
363
|
themeFont,
|
|
280
364
|
);
|
|
281
365
|
const defaultFontWeight = element.placeholderType === "title" ? 700 : 400;
|
|
366
|
+
const boxFontSizePt = firstDeclaredFontSizePt(element.paragraphs);
|
|
282
367
|
const paragraphs = (element.paragraphs ?? [])
|
|
283
368
|
.map((paragraph, paragraphIndex) =>
|
|
284
369
|
buildFidelityParagraph(
|
|
@@ -288,6 +373,7 @@ function buildFidelityElement(
|
|
|
288
373
|
refBox.width,
|
|
289
374
|
themeFont,
|
|
290
375
|
defaultFontWeight,
|
|
376
|
+
boxFontSizePt,
|
|
291
377
|
),
|
|
292
378
|
)
|
|
293
379
|
.join("\n");
|
|
@@ -351,7 +437,15 @@ type ParsedTableCell = NonNullable<
|
|
|
351
437
|
ParsedElement["table"]
|
|
352
438
|
>["rows"][number][number];
|
|
353
439
|
|
|
354
|
-
|
|
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;
|
|
355
449
|
|
|
356
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. */
|
|
357
451
|
function buildFidelityTable(
|
|
@@ -366,15 +460,35 @@ function buildFidelityTable(
|
|
|
366
460
|
const rows = element.table?.rows ?? [];
|
|
367
461
|
const rowsHtml = rows
|
|
368
462
|
.map(
|
|
369
|
-
(row) =>
|
|
370
|
-
`<tr>${row
|
|
463
|
+
(row, rowIndex) =>
|
|
464
|
+
`<tr${rowHeightStyle(element, rowIndex)}>${row
|
|
371
465
|
.map((cell) =>
|
|
372
466
|
buildFidelityTableCell(cell, widthEmu, refWidthPx, themeFont),
|
|
373
467
|
)
|
|
374
468
|
.join("")}</tr>`,
|
|
375
469
|
)
|
|
376
470
|
.join("");
|
|
377
|
-
|
|
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}%"`;
|
|
378
492
|
}
|
|
379
493
|
|
|
380
494
|
function buildFidelityTableCell(
|
|
@@ -396,20 +510,1008 @@ function buildFidelityTableCell(
|
|
|
396
510
|
),
|
|
397
511
|
)
|
|
398
512
|
.join("");
|
|
399
|
-
|
|
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;
|
|
400
1482
|
}
|
|
401
1483
|
|
|
402
1484
|
function shapeDecoration(
|
|
403
1485
|
element: ParsedElement,
|
|
404
1486
|
widthEmu: number,
|
|
405
1487
|
refWidthPx: number,
|
|
1488
|
+
widthPx: number,
|
|
1489
|
+
heightPx: number,
|
|
1490
|
+
customPath: string | undefined,
|
|
1491
|
+
outlinePath: string | undefined,
|
|
406
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
|
+
}
|
|
407
1502
|
const fill = element.fill ? `background: ${esc(element.fill)};` : "";
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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)}`;
|
|
413
1515
|
}
|
|
414
1516
|
|
|
415
1517
|
function textBoxStyle(
|
|
@@ -436,6 +1538,35 @@ function textBoxStyle(
|
|
|
436
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;`;
|
|
437
1539
|
}
|
|
438
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
|
+
|
|
439
1570
|
function buildFidelityParagraph(
|
|
440
1571
|
paragraph: ParsedParagraph,
|
|
441
1572
|
paragraphIndex: number,
|
|
@@ -443,17 +1574,27 @@ function buildFidelityParagraph(
|
|
|
443
1574
|
refWidthPx: number,
|
|
444
1575
|
themeFont: string | undefined,
|
|
445
1576
|
defaultFontWeight: number,
|
|
1577
|
+
boxFontSizePt?: number,
|
|
446
1578
|
): string {
|
|
447
1579
|
const firstRun = paragraph.runs[0];
|
|
448
|
-
const
|
|
449
|
-
|
|
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;
|
|
450
1586
|
const bulletFontSize = ptToSlidePx(
|
|
451
|
-
paragraph.bulletSize ??
|
|
1587
|
+
paragraph.bulletSize ?? paragraphFontSizePt,
|
|
452
1588
|
widthEmu,
|
|
453
1589
|
refWidthPx,
|
|
454
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.
|
|
455
1596
|
const bullet = paragraph.bulletChar
|
|
456
|
-
? `<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>`
|
|
457
1598
|
: "";
|
|
458
1599
|
const marginLeft = paragraph.marginLeftEmu
|
|
459
1600
|
? toSlidePxX(paragraph.marginLeftEmu, widthEmu, refWidthPx)
|
|
@@ -477,7 +1618,15 @@ function buildFidelityParagraph(
|
|
|
477
1618
|
),
|
|
478
1619
|
)
|
|
479
1620
|
.join("");
|
|
480
|
-
|
|
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>`;
|
|
481
1630
|
}
|
|
482
1631
|
|
|
483
1632
|
function formatFidelityRun(
|
|
@@ -488,7 +1637,7 @@ function formatFidelityRun(
|
|
|
488
1637
|
defaultFontWeight = 400,
|
|
489
1638
|
): string {
|
|
490
1639
|
const styles = [
|
|
491
|
-
`font-size:${ptToSlidePx(run.fontSize ??
|
|
1640
|
+
`font-size:${ptToSlidePx(run.fontSize ?? DEFAULT_PPTX_FONT_SIZE_PT, widthEmu, refWidthPx)}px`,
|
|
492
1641
|
`font-family:${cssFontFamily(run.fontFamily ?? themeFont)}`,
|
|
493
1642
|
`color:${esc(run.color ?? DEFAULT_PPTX_FOREGROUND)}`,
|
|
494
1643
|
`font-weight:${run.bold ? 700 : fontWeightForFamily(run.fontFamily, defaultFontWeight)}`,
|
|
@@ -514,7 +1663,8 @@ function fontWeightForFamily(
|
|
|
514
1663
|
const normalized = fontFamily?.toLowerCase() ?? "";
|
|
515
1664
|
if (!normalized) return fallback;
|
|
516
1665
|
if (/(?:semi|demi)bold|semibold/.test(normalized)) return 600;
|
|
517
|
-
if (/
|
|
1666
|
+
if (/black|heavy/.test(normalized)) return 900;
|
|
1667
|
+
if (/extra[- ]?bold|ultra[- ]?bold/.test(normalized)) return 800;
|
|
518
1668
|
if (/bold/.test(normalized)) return 700;
|
|
519
1669
|
if (/medium/.test(normalized)) return 500;
|
|
520
1670
|
if (/light|thin/.test(normalized)) return 300;
|