@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.
Files changed (154) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/purge-first-party-analytics-postgres.ts +26 -8
  3. package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +261 -0
  4. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
  5. package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +75 -31
  6. package/corpus/templates/analytics/server/lib/workspace-feature-flags.ts +4 -1
  7. package/corpus/templates/analytics/server/plugins/db.ts +139 -0
  8. package/corpus/templates/slides/AGENTS.md +14 -10
  9. package/corpus/templates/slides/actions/export-pptx.ts +1311 -121
  10. package/corpus/templates/slides/actions/import-file.ts +28 -1
  11. package/corpus/templates/slides/actions/import-pptx.ts +69 -42
  12. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
  13. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
  14. package/corpus/templates/slides/app/global.css +19 -1
  15. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
  16. package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
  17. package/corpus/templates/slides/app/lib/export-pptx-client.ts +601 -30
  18. package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
  19. package/corpus/templates/slides/server/handlers/import/html-converter.ts +1240 -22
  20. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +329 -64
  21. package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
  22. package/corpus/templates/slides/server/lib/source-import.ts +10 -1
  23. package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
  24. package/dist/agent/harness/migrations.d.ts +4 -0
  25. package/dist/agent/harness/migrations.js +60 -0
  26. package/dist/agent/production-agent.d.ts +2 -0
  27. package/dist/agent/production-agent.js +15 -0
  28. package/dist/agent/run-loop-with-resume.d.ts +1 -0
  29. package/dist/agent/run-loop-with-resume.js +44 -12
  30. package/dist/agent/run-migrations.d.ts +8 -0
  31. package/dist/agent/run-migrations.js +61 -0
  32. package/dist/automations/service.d.ts +6 -0
  33. package/dist/automations/service.js +35 -0
  34. package/dist/chat-threads/migrations.d.ts +1 -0
  35. package/dist/chat-threads/migrations.js +1 -0
  36. package/dist/chat-threads/schema-migrations.d.ts +8 -0
  37. package/dist/chat-threads/schema-migrations.js +60 -0
  38. package/dist/cli/code-agent-connector.d.ts +4 -1
  39. package/dist/cli/code-agent-connector.js +154 -23
  40. package/dist/cli/code-agent-executor.js +255 -13
  41. package/dist/cli/code-agent-runs.d.ts +1 -0
  42. package/dist/cli/code-agent-runs.js +2 -1
  43. package/dist/cli/portal-workspace.d.ts +49 -0
  44. package/dist/cli/portal-workspace.js +320 -0
  45. package/dist/client/AssistantChat.d.ts +1 -1
  46. package/dist/client/AssistantChat.js +43 -61
  47. package/dist/client/ConnectBuilderCard.js +33 -5
  48. package/dist/client/agent-chat-adapter.js +4 -0
  49. package/dist/client/analytics-platform.d.ts +2 -0
  50. package/dist/client/analytics-platform.js +12 -0
  51. package/dist/client/analytics.d.ts +3 -0
  52. package/dist/client/analytics.js +10 -1
  53. package/dist/client/chat/index.d.ts +1 -0
  54. package/dist/client/chat/index.js +1 -0
  55. package/dist/client/chat/message-components.d.ts +2 -0
  56. package/dist/client/chat/message-components.js +11 -1
  57. package/dist/client/chat/run-recovery.d.ts +2 -1
  58. package/dist/client/chat/run-recovery.js +2 -2
  59. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  60. package/dist/client/chat-first/apps-rail.js +11 -11
  61. package/dist/client/chat-first/surface-tabs.d.ts +1 -1
  62. package/dist/client/chat-first/surface-tabs.js +38 -36
  63. package/dist/client/chat-first/types.d.ts +5 -0
  64. package/dist/client/chat-first.d.ts +1 -1
  65. package/dist/client/chat-first.js +4 -4
  66. package/dist/client/desktop-local-code-change.d.ts +10 -0
  67. package/dist/client/desktop-local-code-change.js +16 -0
  68. package/dist/client/index.d.ts +1 -0
  69. package/dist/client/index.js +1 -0
  70. package/dist/client/observability/ThumbsFeedback.js +53 -26
  71. package/dist/client/observability/useObservability.d.ts +1 -1
  72. package/dist/client/observability/useObservability.js +6 -3
  73. package/dist/client/terminal/AgentTerminal.d.ts +9 -1
  74. package/dist/client/terminal/AgentTerminal.js +38 -3
  75. package/dist/client/terminal/index.d.ts +1 -1
  76. package/dist/client/terminal/index.js +1 -1
  77. package/dist/client/track.js +9 -1
  78. package/dist/client/use-action.js +3 -0
  79. package/dist/collab/awareness.d.ts +2 -2
  80. package/dist/deploy/build.js +1 -1
  81. package/dist/feature-flags/a2a-action-route.js +4 -2
  82. package/dist/feature-flags/permissions.d.ts +2 -0
  83. package/dist/feature-flags/permissions.js +6 -1
  84. package/dist/ingestion/pptx.d.ts +92 -1
  85. package/dist/ingestion/pptx.js +1180 -115
  86. package/dist/integrations/index.d.ts +3 -3
  87. package/dist/integrations/index.js +2 -2
  88. package/dist/integrations/plugin.js +95 -6
  89. package/dist/integrations/remote-commands-store.d.ts +5 -0
  90. package/dist/integrations/remote-commands-store.js +63 -22
  91. package/dist/integrations/remote-devices-store.d.ts +2 -1
  92. package/dist/integrations/remote-devices-store.js +46 -0
  93. package/dist/integrations/remote-types.d.ts +20 -0
  94. package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
  95. package/dist/jobs/actions/list-recurring-jobs.js +5 -1
  96. package/dist/jobs/frontmatter.d.ts +16 -0
  97. package/dist/jobs/frontmatter.js +53 -0
  98. package/dist/jobs/remote-execution.d.ts +42 -0
  99. package/dist/jobs/remote-execution.js +287 -0
  100. package/dist/jobs/scheduler.js +102 -3
  101. package/dist/jobs/tools.js +50 -3
  102. package/dist/localization/core-messages/ar-SA.js +4 -0
  103. package/dist/localization/core-messages/de-DE.js +4 -0
  104. package/dist/localization/core-messages/en-US.d.ts +4 -0
  105. package/dist/localization/core-messages/en-US.js +4 -0
  106. package/dist/localization/core-messages/es-ES.js +4 -0
  107. package/dist/localization/core-messages/fr-FR.js +4 -0
  108. package/dist/localization/core-messages/hi-IN.js +4 -0
  109. package/dist/localization/core-messages/ja-JP.js +4 -0
  110. package/dist/localization/core-messages/ko-KR.js +4 -0
  111. package/dist/localization/core-messages/pt-BR.js +4 -0
  112. package/dist/localization/core-messages/zh-CN.js +4 -0
  113. package/dist/localization/core-messages/zh-TW.js +4 -0
  114. package/dist/localization/core-messages.d.ts +4 -0
  115. package/dist/notifications/routes.d.ts +3 -3
  116. package/dist/observability/feedback.js +40 -16
  117. package/dist/observability/routes.d.ts +6 -6
  118. package/dist/observability/routes.js +15 -3
  119. package/dist/observability/store.js +2 -0
  120. package/dist/observability/traces.js +5 -1
  121. package/dist/provider-api/actions/custom-provider-registration.d.ts +16 -16
  122. package/dist/provider-api/actions/provider-api.d.ts +13 -13
  123. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  124. package/dist/resources/handlers.d.ts +1 -1
  125. package/dist/secrets/routes.d.ts +9 -9
  126. package/dist/server/action-routes.d.ts +7 -5
  127. package/dist/server/action-routes.js +13 -6
  128. package/dist/server/agent-chat-plugin.js +6 -0
  129. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  130. package/dist/server/agent-run-context.d.ts +1 -0
  131. package/dist/server/agent-run-context.js +9 -0
  132. package/dist/server/better-auth-instance.js +4 -0
  133. package/dist/server/core-routes-plugin.js +8 -1
  134. package/dist/server/realtime-voice.js +2 -1
  135. package/dist/server/release-migrations.js +16 -0
  136. package/dist/server/request-context.d.ts +2 -0
  137. package/dist/server/transcribe-voice.d.ts +1 -1
  138. package/dist/shared/analytics-platform.d.ts +6 -0
  139. package/dist/shared/analytics-platform.js +14 -0
  140. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  141. package/dist/shared/mcp-embed-headers.js +1 -1
  142. package/dist/styles/agent-native.css +83 -103
  143. package/dist/terminal/cli-registry.js +5 -0
  144. package/dist/tracking/registry.js +9 -1
  145. package/dist/triggers/actions/list-automations.d.ts +3 -0
  146. package/dist/triggers/actions/list-automations.js +3 -0
  147. package/dist/triggers/actions/manage-automation.d.ts +9 -0
  148. package/dist/triggers/actions/manage-automation.js +14 -2
  149. package/dist/triggers/actions.js +82 -7
  150. package/dist/usage/migrations.d.ts +4 -0
  151. package/dist/usage/migrations.js +45 -0
  152. package/docs/content/template-content-developers.mdx +48 -0
  153. package/docs/content/toolkit-ui.mdx +57 -0
  154. 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
- if (safeName.toLowerCase().startsWith("poppins")) {
93
- return "'Poppins', sans-serif";
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
- return `'${safeName}', sans-serif`;
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
- if (slide.elements?.length) {
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
- const DEFAULT_PPTX_BACKGROUND = "#000000"; // guard:allow-raw-color - preserve PPTX black when no background is declared
171
- const DEFAULT_PPTX_FOREGROUND = "#ffffff"; // guard:allow-raw-color - preserve PPTX white when no run color is declared
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 position = `position: absolute; left: ${toSlidePxX(element.x, widthEmu, refBox.width)}px; top: ${toSlidePxY(element.y, heightEmu, refBox.height)}px; width: ${toSlidePxX(element.width, widthEmu, refBox.width)}px; height: ${toSlidePxY(element.height, heightEmu, refBox.height)}px; z-index: ${index}; box-sizing: border-box;`;
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
- 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;">${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>`;
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
- const decoration = shapeDecoration(element, widthEmu, refBox.width);
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
- return `<div class="fmd-pptx-shape" data-pptx-element-kind="shape"${objectId} style="${position}${rotation}${decoration}"></div>`;
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
- const line = element.lineColor
341
- ? `border: ${Math.max(1, toSlidePxX(element.lineWidth ?? 12700, widthEmu, refWidthPx))}px solid ${esc(element.lineColor)};`
342
- : "";
343
- const radius = element.shapeType === "roundRect" ? "border-radius: 6px;" : "";
344
- return `${fill}${line}${radius}`;
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 fontSize = ptToSlidePx(firstRun?.fontSize ?? 18, widthEmu, refWidthPx);
381
- const lineHeight = paragraph.lineSpacing ?? 1.2;
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 ?? firstRun?.fontSize ?? 18,
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;width:${fontSize * 0.75}px;min-width:${fontSize * 0.75}px;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>`
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
- return `<p data-pptx-paragraph="${paragraphIndex}" style="display:block;flex:0 0 auto;text-align:${paragraph.alignment ?? "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>`;
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 ?? 18, widthEmu, refWidthPx)}px`,
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 (/extra[- ]?bold|heavy/.test(normalized)) return 800;
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;