@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
@@ -25,10 +25,13 @@ export async function parsePptxPresentation(fileBuffer) {
25
25
  target: slideMasterRelationship.target,
26
26
  })
27
27
  : undefined;
28
+ const droppedSlides = [];
28
29
  const slidePaths = slideIds.flatMap((id) => {
29
30
  const relationship = relationships.get(id);
30
- if (!relationship)
31
+ if (!relationship) {
32
+ droppedSlides.push(`r:id="${id}" has no matching relationship in ppt/_rels/presentation.xml.rels`);
31
33
  return [];
34
+ }
32
35
  return [
33
36
  relationship.target.startsWith("/")
34
37
  ? relationship.target.slice(1)
@@ -36,51 +39,83 @@ export async function parsePptxPresentation(fileBuffer) {
36
39
  ];
37
40
  });
38
41
  if (slidePaths.length === 0) {
42
+ // Scanning the package recovers every slide the rels could not name, so
43
+ // the unresolved ids above are no longer missing content.
44
+ droppedSlides.length = 0;
39
45
  slidePaths.push(...Object.keys(zip.files)
40
46
  .filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))
41
47
  .sort((a, b) => slideNumber(a) - slideNumber(b)));
42
48
  }
49
+ const unresolvedSlideIdCount = droppedSlides.length;
43
50
  // Deck-wide fallback, used when a slide's own layout→master chain can't be
44
51
  // resolved (missing rels, unusual authoring tools) — see
45
52
  // `resolveSlideMasterContext` below for the per-slide resolution that
46
53
  // presentations with more than one slide master actually need.
47
- const theme = await parseTheme(zip, parseXml);
54
+ const theme = await parseTheme(zip, parseXml, slideMasterRelationship);
55
+ const tableStyles = await parseTableStyles(zip, parseXml);
48
56
  const masterColorInfo = slideMasterRelationship
49
57
  ? await parseMasterColorInfo({
50
58
  zip,
51
59
  target: slideMasterRelationship.target,
52
60
  parseXml,
53
61
  })
54
- : { clrMap: {}, titleFillByLevel: {}, bodyFillByLevel: {} };
62
+ : {
63
+ clrMap: {},
64
+ titleDefaultsByLevel: {},
65
+ bodyDefaultsByLevel: {},
66
+ otherDefaultsByLevel: {},
67
+ placeholderDefaults: [],
68
+ background: null,
69
+ };
55
70
  const colorContext = {
56
71
  themeColorsByName: theme.colorsByName,
57
72
  clrMap: masterColorInfo.clrMap,
58
73
  };
59
- const resolveFillsByLevel = (fillsByLevel) => Object.fromEntries(Object.entries(fillsByLevel).map(([level, fill]) => [
60
- Number(level),
61
- parseColor(fill, colorContext),
62
- ]));
63
74
  const placeholderDefaults = {
64
- title: resolveFillsByLevel(masterColorInfo.titleFillByLevel),
65
- body: resolveFillsByLevel(masterColorInfo.bodyFillByLevel),
75
+ title: resolveRunDefaultsByLevel(masterColorInfo.titleDefaultsByLevel, colorContext),
76
+ body: resolveRunDefaultsByLevel(masterColorInfo.bodyDefaultsByLevel, colorContext),
77
+ other: resolveRunDefaultsByLevel(masterColorInfo.otherDefaultsByLevel, colorContext),
78
+ layoutPlaceholders: [],
79
+ masterPlaceholders: resolvePlaceholderShapeDefaults(masterColorInfo.placeholderDefaults, colorContext),
80
+ };
81
+ const fallbackBackground = parseBackgroundNode(masterColorInfo.background, colorContext);
82
+ const fallbackContext = {
83
+ colorContext,
84
+ placeholderDefaults,
85
+ ...(fallbackBackground ? { background: fallbackBackground } : {}),
86
+ layerElements: [],
87
+ layerImages: [],
66
88
  };
67
89
  const themeCache = new Map();
68
90
  const masterInfoCache = new Map();
91
+ const templateContextCache = new Map();
69
92
  const slides = [];
93
+ let hiddenSlideCount = 0;
70
94
  for (const slidePath of slidePaths) {
71
95
  const xml = await zip.file(slidePath)?.async("string");
72
- if (!xml)
96
+ if (!xml) {
97
+ droppedSlides.push(`${slidePath} is missing from the package`);
73
98
  continue;
99
+ }
74
100
  let slide;
75
101
  try {
76
102
  slide = parseXml(xml);
77
103
  }
78
- catch {
104
+ catch (error) {
105
+ droppedSlides.push(`${slidePath} could not be parsed: ${error instanceof Error ? error.message : String(error)}`);
106
+ continue;
107
+ }
108
+ // `show="0"` is the author having removed this slide from the deck's own
109
+ // flow. Importing it anyway hands every deck back slides its presenter
110
+ // had already cut.
111
+ if (stringValue(record(record(slide)?.["p:sld"])?.["@_show"]) === "0") {
112
+ hiddenSlideCount += 1;
79
113
  continue;
80
114
  }
81
115
  const metadata = parsePptxSlideMetadata(slide);
82
116
  let elements = [];
83
117
  const images = [];
118
+ const tablesDegraded = { count: 0 };
84
119
  const relationshipPath = slidePath.replace(/slides\/(slide\d+\.xml)/, "slides/_rels/$1.rels");
85
120
  const slideRelationshipsXml = await zip
86
121
  .file(relationshipPath)
@@ -97,9 +132,13 @@ export async function parsePptxPresentation(fileBuffer) {
97
132
  zip,
98
133
  parseXml,
99
134
  slideRelationships,
135
+ slideWidthEmu,
136
+ slideHeightEmu,
100
137
  themeCache,
101
138
  masterInfoCache,
102
- })) ?? { colorContext, placeholderDefaults };
139
+ templateContextCache,
140
+ })) ?? fallbackContext;
141
+ const number = slideNumber(slidePath);
103
142
  elements = await parseSlideElements({
104
143
  xml,
105
144
  parseXml,
@@ -109,11 +148,18 @@ export async function parsePptxPresentation(fileBuffer) {
109
148
  slideWidthEmu,
110
149
  slideHeightEmu,
111
150
  images,
151
+ tablesDegraded,
112
152
  colorContext: slideMasterContext.colorContext,
113
153
  placeholderDefaults: slideMasterContext.placeholderDefaults,
154
+ slideNumber: slides.length + 1,
155
+ tableStyles,
114
156
  });
157
+ // The template layer sits behind the slide's own scene graph: it is the
158
+ // background band, brand mark and silhouette the layout draws under
159
+ // everything the slide itself places.
160
+ images.unshift(...slideMasterContext.layerImages);
161
+ elements.unshift(...substituteSlideNumber(slideMasterContext.layerElements, slides.length + 1));
115
162
  const texts = flattenElementText(elements);
116
- const number = slideNumber(slidePath);
117
163
  const notesXml = await zip
118
164
  .file(`ppt/notesSlides/notesSlide${number}.xml`)
119
165
  ?.async("string");
@@ -134,19 +180,41 @@ export async function parsePptxPresentation(fileBuffer) {
134
180
  elements,
135
181
  widthEmu: slideWidthEmu,
136
182
  heightEmu: slideHeightEmu,
137
- backgroundColor: extractSlideBackgroundColor(slide, slideMasterContext.colorContext),
183
+ backgroundColor: extractSlideBackground(slide, slideMasterContext.colorContext) ??
184
+ slideMasterContext.background,
138
185
  ...(backgroundGrid ? { backgroundGrid } : {}),
139
186
  notes,
140
187
  layoutHint: guessLayoutHint(texts, images.length > 0),
188
+ ...(tablesDegraded.count > 0
189
+ ? { tablesDegraded: tablesDegraded.count }
190
+ : {}),
141
191
  ...metadata,
142
192
  });
143
193
  }
194
+ // A short import must never come back looking complete: every slide the deck
195
+ // still presents has to survive, and slides the author hid are the only
196
+ // legitimate shortfall.
197
+ const expectedSlideCount = slidePaths.length + unresolvedSlideIdCount - hiddenSlideCount;
198
+ if (slides.length !== expectedSlideCount) {
199
+ throw new Error(`Invalid PPTX: expected ${expectedSlideCount} slides but parsed ${slides.length}` +
200
+ (hiddenSlideCount > 0
201
+ ? ` (${hiddenSlideCount} hidden slide${hiddenSlideCount === 1 ? "" : "s"} excluded)`
202
+ : "") +
203
+ (droppedSlides.length > 0
204
+ ? `; dropped: ${droppedSlides.join("; ")}`
205
+ : ""));
206
+ }
144
207
  const firstSlide = slides[0]?.texts ?? [];
145
208
  const title = [...firstSlide]
146
209
  .sort((a, b) => (b.fontSize ?? 0) - (a.fontSize ?? 0))[0]
147
210
  ?.content.trim()
148
211
  .slice(0, 200) || "Imported Presentation";
149
- return { title, slides, theme };
212
+ return {
213
+ title,
214
+ slides,
215
+ theme,
216
+ ...(hiddenSlideCount > 0 ? { hiddenSlideCount } : {}),
217
+ };
150
218
  }
151
219
  /**
152
220
  * Google Slides exports decorative grids as connector shapes on the slide
@@ -246,19 +314,21 @@ function relsPathForPptxPart(path) {
246
314
  const file = slashIndex >= 0 ? path.slice(slashIndex + 1) : path;
247
315
  return `${dir ? `${dir}/` : ""}_rels/${file}.rels`;
248
316
  }
249
- /**
250
- * Walks this slide's own `slideLayout` → `slideMaster` → `theme` relationship
251
- * chain so slides that belong to a different master than the deck's first
252
- * one (a presentation with more than one master) resolve `schemeClr`
253
- * aliases and placeholder defaults against their own palette instead of an
254
- * unrelated master's. Returns `undefined` when any hop in that chain is
255
- * missing, letting the caller fall back to the deck-wide default.
256
- */
257
317
  async function resolveSlideMasterContext(args) {
258
318
  const layoutRelationship = [...args.slideRelationships.values()].find((relationship) => relationship.type.endsWith("/slideLayout"));
259
319
  if (!layoutRelationship)
260
320
  return undefined;
261
321
  const layoutPath = resolvePptxRelationshipPath("ppt/slides", layoutRelationship.target);
322
+ let cached = args.templateContextCache.get(layoutPath);
323
+ if (!cached) {
324
+ cached = buildSlideTemplateContext({ ...args, layoutPath });
325
+ args.templateContextCache.set(layoutPath, cached);
326
+ }
327
+ return cached;
328
+ }
329
+ async function buildSlideTemplateContext(args) {
330
+ const layoutPath = args.layoutPath;
331
+ const layoutXml = await args.zip.file(layoutPath)?.async("string");
262
332
  const layoutRelsXml = await args.zip
263
333
  .file(relsPathForPptxPart(layoutPath))
264
334
  ?.async("string");
@@ -279,6 +349,12 @@ async function resolveSlideMasterContext(args) {
279
349
  args.masterInfoCache.set(masterPath, masterInfoPromise);
280
350
  }
281
351
  const masterInfo = await masterInfoPromise;
352
+ const layoutRoot = layoutXml
353
+ ? record(record(args.parseXml(layoutXml))?.["p:sldLayout"])
354
+ : null;
355
+ const layoutPlaceholderDefaults = layoutXml
356
+ ? parsePlaceholderShapeDefaults(layoutXml, args.parseXml)
357
+ : [];
282
358
  const masterRelsXml = await args.zip
283
359
  .file(relsPathForPptxPart(masterPath))
284
360
  ?.async("string");
@@ -299,21 +375,117 @@ async function resolveSlideMasterContext(args) {
299
375
  themeColorsByName: theme.colorsByName,
300
376
  clrMap: masterInfo.clrMap,
301
377
  };
302
- const resolveFillsByLevel = (fillsByLevel) => Object.fromEntries(Object.entries(fillsByLevel).map(([level, fill]) => [
303
- Number(level),
304
- parseColor(fill, colorContext),
305
- ]));
378
+ const background = parseBackgroundNode(record(layoutRoot?.["p:cSld"])?.["p:bg"], colorContext) ?? parseBackgroundNode(masterInfo.background, colorContext);
379
+ // `showMasterSp="0"` is a layout opting out of the master's own decoration;
380
+ // honouring it is the difference between reproducing a template and
381
+ // stamping the master's furniture onto slides that deliberately hid it.
382
+ const layerImages = [];
383
+ const layerElements = [];
384
+ const layerSources = [];
385
+ if (masterInfo.xml && stringValue(layoutRoot?.["@_showMasterSp"]) !== "0") {
386
+ layerSources.push({
387
+ xml: masterInfo.xml,
388
+ path: masterPath,
389
+ prefix: "master",
390
+ });
391
+ }
392
+ if (layoutXml) {
393
+ layerSources.push({ xml: layoutXml, path: layoutPath, prefix: "layout" });
394
+ }
395
+ for (const source of layerSources) {
396
+ layerElements.push(...(await parseTemplateLayerElements({
397
+ zip: args.zip,
398
+ parseXml: args.parseXml,
399
+ xml: source.xml,
400
+ path: source.path,
401
+ idPrefix: source.prefix,
402
+ slideWidthEmu: args.slideWidthEmu,
403
+ slideHeightEmu: args.slideHeightEmu,
404
+ images: layerImages,
405
+ colorContext,
406
+ })));
407
+ }
306
408
  return {
307
409
  colorContext,
410
+ ...(background ? { background } : {}),
411
+ layerElements,
412
+ layerImages,
308
413
  placeholderDefaults: {
309
- title: resolveFillsByLevel(masterInfo.titleFillByLevel),
310
- body: resolveFillsByLevel(masterInfo.bodyFillByLevel),
414
+ title: resolveRunDefaultsByLevel(masterInfo.titleDefaultsByLevel, colorContext),
415
+ body: resolveRunDefaultsByLevel(masterInfo.bodyDefaultsByLevel, colorContext),
416
+ other: resolveRunDefaultsByLevel(masterInfo.otherDefaultsByLevel, colorContext),
417
+ layoutPlaceholders: resolvePlaceholderShapeDefaults(layoutPlaceholderDefaults, colorContext),
418
+ masterPlaceholders: resolvePlaceholderShapeDefaults(masterInfo.placeholderDefaults, colorContext),
311
419
  },
312
420
  };
313
421
  }
422
+ /**
423
+ * A slideLayout's and slideMaster's *non*-placeholder `<p:sp>`/`<p:pic>`/
424
+ * `<p:cxnSp>`/`<p:grpSp>` are where a template's visual identity actually
425
+ * lives — full-bleed bands, brand marks, logos, silhouettes. Reading only
426
+ * placeholder shapes for their inherited defaults threw all of it away, so
427
+ * slides whose entire design came from the layout imported as blank white
428
+ * cards. Placeholder shapes are skipped here: their content is prompt text,
429
+ * and their geometry/colors already reach the slide through
430
+ * `PlaceholderDefaults`.
431
+ */
432
+ async function parseTemplateLayerElements(args) {
433
+ const relsXml = await args.zip
434
+ .file(relsPathForPptxPart(args.path))
435
+ ?.async("string");
436
+ const relationships = relsXml
437
+ ? parseRelationships(args.parseXml(relsXml))
438
+ : new Map();
439
+ const elements = [];
440
+ const images = [];
441
+ const tablesDegraded = { count: 0 };
442
+ for (const fragment of extractDirectShapeFragments(args.xml, "spTree")) {
443
+ if (/<p:ph[\s/>]/.test(fragment))
444
+ continue;
445
+ elements.push(...(await parseShapeFragment(fragment, {
446
+ parseXml: args.parseXml,
447
+ zip: args.zip,
448
+ slideRelationships: relationships,
449
+ slideWidthEmu: args.slideWidthEmu,
450
+ slideHeightEmu: args.slideHeightEmu,
451
+ images,
452
+ tablesDegraded,
453
+ colorContext: args.colorContext,
454
+ context: { matrix: IDENTITY_MAT, rotation: 0 },
455
+ })));
456
+ }
457
+ // A template layer's icons and logos are frequently EMF/WMF vector art,
458
+ // which no browser can render and which the import boundary rejects for the
459
+ // whole deck. Dropping the deck's every other slide over a layout logo is
460
+ // strictly worse than importing without it, so unrenderable *layer* art is
461
+ // left out; a slide's own images still fail loudly.
462
+ const renderable = new Set(images.filter(isBrowserRenderableImage));
463
+ args.images.push(...renderable);
464
+ return elements
465
+ .filter((element) => !element.image || renderable.has(element.image))
466
+ .map((element) => ({ ...element, id: `${args.idPrefix}-${element.id}` }));
467
+ }
468
+ const BROWSER_RENDERABLE_IMAGE_MIME_TYPES = new Set([
469
+ "image/png",
470
+ "image/jpeg",
471
+ "image/gif",
472
+ "image/webp",
473
+ "image/svg+xml",
474
+ "image/bmp",
475
+ ]);
476
+ function isBrowserRenderableImage(image) {
477
+ return BROWSER_RENDERABLE_IMAGE_MIME_TYPES.has(image.mimeType);
478
+ }
314
479
  /** Resolves a slide master's color-alias mapping plus its title/body default text-color fills (from p:txStyles), so placeholder text without its own explicit color can inherit the right one. */
315
480
  async function parseMasterColorInfo(args) {
316
- const empty = { clrMap: {}, titleFillByLevel: {}, bodyFillByLevel: {} };
481
+ const empty = {
482
+ clrMap: {},
483
+ titleDefaultsByLevel: {},
484
+ bodyDefaultsByLevel: {},
485
+ otherDefaultsByLevel: {},
486
+ placeholderDefaults: [],
487
+ background: null,
488
+ };
317
489
  const path = args.target.startsWith("/")
318
490
  ? args.target.slice(1)
319
491
  : "ppt/" + args.target.replace(/^\.\.\//, "");
@@ -327,18 +499,82 @@ async function parseMasterColorInfo(args) {
327
499
  const txStyles = record(root["p:txStyles"]);
328
500
  return {
329
501
  clrMap,
330
- titleFillByLevel: levelFillsFromTextStyle(record(txStyles?.["p:titleStyle"])),
331
- bodyFillByLevel: levelFillsFromTextStyle(record(txStyles?.["p:bodyStyle"])),
502
+ titleDefaultsByLevel: levelDefaultsFromTextStyle(record(txStyles?.["p:titleStyle"])),
503
+ bodyDefaultsByLevel: levelDefaultsFromTextStyle(record(txStyles?.["p:bodyStyle"])),
504
+ otherDefaultsByLevel: levelDefaultsFromTextStyle(record(txStyles?.["p:otherStyle"])),
505
+ placeholderDefaults: parsePlaceholderShapeDefaults(xml, args.parseXml),
506
+ background: record(record(root["p:cSld"])?.["p:bg"]),
507
+ xml,
332
508
  };
333
509
  }
334
- /** Reads a `<p:titleStyle>`/`<p:bodyStyle>` node's per-level (`a:lvl1pPr`..`a:lvl9pPr`) default run fill, keyed 0-indexed to match `ParsedPptxParagraph.level` (`a:lvl1pPr` is level 0, `a:lvl2pPr` is level 1, etc.) — using only the first level's default for every nested bullet level silently drops the distinct colors PowerPoint themes commonly assign to deeper levels. */
335
- function levelFillsFromTextStyle(style) {
336
- const fills = {};
510
+ /** Reads a `<p:titleStyle>`/`<p:bodyStyle>`/`<a:lstStyle>` node's per-level (`a:lvl1pPr`..`a:lvl9pPr`) `<a:defRPr>`, keyed 0-indexed to match `ParsedPptxParagraph.level` (`a:lvl1pPr` is level 0, `a:lvl2pPr` is level 1, etc.) — using only the first level's default for every nested bullet level silently drops the distinct styling PowerPoint themes commonly assign to deeper levels. The whole `defRPr` is kept, not just its fill: size, typeface and bold/italic are inherited by exactly the same chain the color is, and a placeholder run that declares none of them is the common case, not the exception. */
511
+ function levelDefaultsFromTextStyle(style) {
512
+ const defaults = {};
337
513
  for (let level = 0; level < 9; level++) {
338
- const defRPr = record(record(style?.[`a:lvl${level + 1}pPr`])?.["a:defRPr"]);
339
- fills[level] = record(defRPr?.["a:solidFill"]);
514
+ defaults[level] = record(record(style?.[`a:lvl${level + 1}pPr`])?.["a:defRPr"]);
515
+ }
516
+ return defaults;
517
+ }
518
+ function resolveRunDefaultsByLevel(defaultsByLevel, colorContext) {
519
+ return Object.fromEntries(Object.entries(defaultsByLevel).map(([level, defRPr]) => [
520
+ Number(level),
521
+ runProperties(defRPr, {}, colorContext),
522
+ ]));
523
+ }
524
+ /** Parses every direct placeholder shape out of a slideLayout's or slideMaster's `p:spTree`, keyed by that shape's own `<p:ph>` `type`/`idx` — reuses `extractDirectShapeFragments`, since layouts and masters share the same `spTree` structure slides do. */
525
+ function parsePlaceholderShapeDefaults(xml, parseXml) {
526
+ const placeholders = [];
527
+ for (const fragment of extractDirectShapeFragments(xml, "spTree")) {
528
+ const node = record(record(parseXml(fragment))?.["p:sp"]);
529
+ const ph = record(record(record(node?.["p:nvSpPr"])?.["p:nvPr"])?.["p:ph"]);
530
+ if (!node || !ph)
531
+ continue;
532
+ const transform = readTransform(node, "p:spPr");
533
+ placeholders.push({
534
+ type: stringValue(ph["@_type"]),
535
+ idx: stringValue(ph["@_idx"]),
536
+ defaultsByLevel: levelDefaultsFromTextStyle(record(record(node["p:txBody"])?.["a:lstStyle"])),
537
+ ...(transform.width > 0 && transform.height > 0 ? { transform } : {}),
538
+ });
340
539
  }
341
- return fills;
540
+ return placeholders;
541
+ }
542
+ /** Resolves a set of raw placeholder-shape defaults' `schemeClr` references against the slide's theme/clrMap (the raw nodes are cached before the theme is known, so resolution happens per-call instead). */
543
+ function resolvePlaceholderShapeDefaults(raw, colorContext) {
544
+ return raw.map((placeholder) => ({
545
+ type: placeholder.type,
546
+ idx: placeholder.idx,
547
+ runDefaultsByLevel: resolveRunDefaultsByLevel(placeholder.defaultsByLevel, colorContext),
548
+ transform: placeholder.transform,
549
+ }));
550
+ }
551
+ const IDENTITY_MAT = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
552
+ /** Composes two transforms so `inner` is applied to a point first, then `outer`. */
553
+ function composeMat(outer, inner) {
554
+ return {
555
+ a: outer.a * inner.a + outer.c * inner.b,
556
+ b: outer.b * inner.a + outer.d * inner.b,
557
+ c: outer.a * inner.c + outer.c * inner.d,
558
+ d: outer.b * inner.c + outer.d * inner.d,
559
+ e: outer.a * inner.e + outer.c * inner.f + outer.e,
560
+ f: outer.b * inner.e + outer.d * inner.f + outer.f,
561
+ };
562
+ }
563
+ function scaleMat(sx, sy) {
564
+ return { a: sx, b: 0, c: 0, d: sy, e: 0, f: 0 };
565
+ }
566
+ function translateMat(tx, ty) {
567
+ return { a: 1, b: 0, c: 0, d: 1, e: tx, f: ty };
568
+ }
569
+ function rotateAroundMat(degrees, cx, cy) {
570
+ const radians = (degrees * Math.PI) / 180;
571
+ const cos = Math.cos(radians);
572
+ const sin = Math.sin(radians);
573
+ const rotation = { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 };
574
+ return composeMat(translateMat(cx, cy), composeMat(rotation, translateMat(-cx, -cy)));
575
+ }
576
+ function applyMatPoint(m, x, y) {
577
+ return { x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f };
342
578
  }
343
579
  const SHAPE_ELEMENT_NAMES = new Set([
344
580
  "sp",
@@ -347,14 +583,14 @@ const SHAPE_ELEMENT_NAMES = new Set([
347
583
  "cxnSp",
348
584
  "graphicFrame",
349
585
  ]);
586
+ /** Standard 16:9 widescreen slide size — a last-resort content box for a placeholder shape whose own `<a:xfrm>` is missing and the deck's own slide size wasn't determinable either. */
587
+ const FALLBACK_SLIDE_WIDTH_EMU = 12192000;
588
+ const FALLBACK_SLIDE_HEIGHT_EMU = 6858000;
350
589
  async function parseSlideElements(args) {
351
590
  const fragments = extractDirectShapeFragments(args.xml, "spTree");
352
591
  const elements = [];
353
592
  const context = {
354
- originX: 0,
355
- originY: 0,
356
- scaleX: 1,
357
- scaleY: 1,
593
+ matrix: IDENTITY_MAT,
358
594
  rotation: 0,
359
595
  };
360
596
  for (const fragment of fragments) {
@@ -403,16 +639,28 @@ async function parseShapeFragment(fragment, args) {
403
639
  const groupTransform = readTransform(node, "p:grpSpPr");
404
640
  const groupXfrm = record(record(node["p:grpSpPr"])?.["a:xfrm"]);
405
641
  const childOffset = readPoint(groupXfrm?.["a:chOff"]);
406
- const childExtent = readPoint(groupXfrm?.["a:chExt"]);
642
+ // `a:chExt` carries `cx`/`cy` attributes like `a:ext`, not `x`/`y` like
643
+ // `a:off`/`a:chOff` — using `readPoint` here silently read 0, which made
644
+ // every scaled group (chExt != ext) fall back to an identity scale and
645
+ // rendered children at their unscaled local size (e.g. a connector/line
646
+ // shape's width/height came out too large, overflowing the canvas).
647
+ const childExtent = readExtent(groupXfrm?.["a:chExt"]);
407
648
  const groupScaleX = childExtent.x > 0 ? groupTransform.width / childExtent.x : 1;
408
649
  const groupScaleY = childExtent.y > 0 ? groupTransform.height / childExtent.y : 1;
650
+ // Places this group's children into the parent's coordinate space,
651
+ // ignoring the group's own rotation for now.
652
+ let localToParent = composeMat(translateMat(groupTransform.x - childOffset.x * groupScaleX, groupTransform.y - childOffset.y * groupScaleY), scaleMat(groupScaleX, groupScaleY));
653
+ // PowerPoint rotates the entire *placed* group box — every child already
654
+ // positioned by `localToParent` above — as a rigid body around that
655
+ // box's own center, not each child around its own center. Composing this
656
+ // rotation on top of the placement (instead of only summing `rotation`
657
+ // degrees, as before) is what makes children actually orbit the group's
658
+ // pivot instead of just spinning in place.
659
+ if (groupTransform.rotation) {
660
+ localToParent = composeMat(rotateAroundMat(groupTransform.rotation, groupTransform.x + groupTransform.width / 2, groupTransform.y + groupTransform.height / 2), localToParent);
661
+ }
409
662
  const nextContext = {
410
- originX: args.context.originX +
411
- args.context.scaleX * (groupTransform.x - childOffset.x * groupScaleX),
412
- originY: args.context.originY +
413
- args.context.scaleY * (groupTransform.y - childOffset.y * groupScaleY),
414
- scaleX: args.context.scaleX * groupScaleX,
415
- scaleY: args.context.scaleY * groupScaleY,
663
+ matrix: composeMat(args.context.matrix, localToParent),
416
664
  rotation: args.context.rotation + (groupTransform.rotation ?? 0),
417
665
  };
418
666
  const output = [];
@@ -424,21 +672,64 @@ async function parseShapeFragment(fragment, args) {
424
672
  }
425
673
  return output;
426
674
  }
427
- const transform = applyTransform(readTransform(node, "p:spPr"), args.context);
675
+ if (entry === "graphicFrame") {
676
+ return parseGraphicFrameFragment(node, args);
677
+ }
678
+ const localTransform = readTransform(node, "p:spPr");
428
679
  const id = readShapeId(node);
429
680
  const name = readShapeName(node);
430
- const placeholderType = stringValue(record(record(record(node["p:nvSpPr"])?.["p:nvPr"])?.["p:ph"])?.["@_type"]);
681
+ const placeholder = record(record(record(node["p:nvSpPr"])?.["p:nvPr"])?.["p:ph"]);
682
+ const placeholderType = stringValue(placeholder?.["@_type"]);
683
+ const placeholderIdx = stringValue(placeholder?.["@_idx"]);
684
+ // A placeholder shape (`<p:ph>`) commonly omits its own `<a:xfrm>` on the
685
+ // slide, inheriting position/size from the matching placeholder on the
686
+ // slide layout/master instead. Try the layout's own placeholder shape
687
+ // first, then the master's — same order as the color inheritance chain
688
+ // above — and only fall back to the slide's own content box when neither
689
+ // defines explicit geometry for this placeholder either (real for
690
+ // `idx="4294967295"` sentinel placeholders in some Google Slides exports).
691
+ // Without that last-resort fallback the shape would land at a literal 0×0
692
+ // box, making real title/body text invisible instead of just mispositioned.
693
+ const hasOwnSize = localTransform.width > 0 && localTransform.height > 0;
694
+ const inheritedTransform = entry === "sp" && !hasOwnSize && placeholder && args.placeholderDefaults
695
+ ? resolvePlaceholderTransform({
696
+ type: placeholderType,
697
+ idx: placeholderIdx,
698
+ defaults: args.placeholderDefaults,
699
+ })
700
+ : undefined;
701
+ const effectiveLocalTransform = entry === "sp" && !hasOwnSize
702
+ ? {
703
+ ...localTransform,
704
+ x: inheritedTransform?.x ?? 0,
705
+ y: inheritedTransform?.y ?? 0,
706
+ width: inheritedTransform?.width ??
707
+ args.slideWidthEmu ??
708
+ FALLBACK_SLIDE_WIDTH_EMU,
709
+ height: inheritedTransform?.height ??
710
+ args.slideHeightEmu ??
711
+ FALLBACK_SLIDE_HEIGHT_EMU,
712
+ }
713
+ : localTransform;
714
+ const transform = applyTransform(effectiveLocalTransform, args.context);
431
715
  const shapeProperties = record(node["p:spPr"]);
432
- const rawText = parseTextBody(node, args.colorContext);
433
- const placeholderDefaultColor = placeholderType === "title" || placeholderType === "ctrTitle"
434
- ? args.placeholderDefaults?.title
435
- : placeholderType
436
- ? args.placeholderDefaults?.body
437
- : undefined;
438
- const text = applyPlaceholderDefaultColor(rawText, placeholderDefaultColor);
716
+ const rawText = parseTextBody(node, args.colorContext, {
717
+ relationships: args.slideRelationships,
718
+ slideNumber: args.slideNumber,
719
+ });
720
+ const placeholderRunDefaults = placeholder && args.placeholderDefaults
721
+ ? resolvePlaceholderRunDefaults({
722
+ type: placeholderType,
723
+ idx: placeholderIdx,
724
+ defaults: args.placeholderDefaults,
725
+ })
726
+ : undefined;
727
+ const text = applyAutofitScale(applyPlaceholderRunDefaults(rawText, placeholderRunDefaults), record(record(node["p:txBody"])?.["a:bodyPr"]));
439
728
  const fill = parseShapeFill(shapeProperties, args.colorContext);
440
729
  const line = parseShapeLine(shapeProperties, args.colorContext);
441
730
  const shapeType = stringValue(record(shapeProperties?.["a:prstGeom"])?.["@_prst"]);
731
+ const shapeAdjustments = parseShapeAdjustments(shapeProperties);
732
+ const geometry = parseCustomGeometry(shapeProperties, effectiveLocalTransform);
442
733
  if (entry === "pic") {
443
734
  const embedId = stringValue(record(record(node["p:blipFill"])?.["a:blip"])?.["@_r:embed"]);
444
735
  if (!embedId)
@@ -466,6 +757,12 @@ async function parseShapeFragment(fragment, args) {
466
757
  name,
467
758
  kind: "image",
468
759
  ...transform,
760
+ // A picture is painted inside its `p:spPr` geometry, not its bounding
761
+ // box: dropping the shape here is what turns a portrait cropped to an
762
+ // `ellipse` frame back into the hard square its box happens to be.
763
+ shapeType,
764
+ ...(shapeAdjustments ? { shapeAdjustments } : {}),
765
+ ...(geometry ? { geometry } : {}),
469
766
  image: image.image,
470
767
  },
471
768
  ];
@@ -480,8 +777,17 @@ async function parseShapeFragment(fragment, args) {
480
777
  kind: "text",
481
778
  ...transform,
482
779
  shapeType,
780
+ ...(shapeAdjustments ? { shapeAdjustments } : {}),
781
+ ...(geometry ? { geometry } : {}),
483
782
  ...(fill ? { fill } : {}),
484
- ...(line ? { lineColor: line.color, lineWidth: line.width } : {}),
783
+ ...(line
784
+ ? {
785
+ lineColor: line.color,
786
+ lineWidth: line.width,
787
+ lineHeadEnd: line.headEnd,
788
+ lineTailEnd: line.tailEnd,
789
+ }
790
+ : {}),
485
791
  ...parseTextBoxProperties(node),
486
792
  paragraphs: text,
487
793
  },
@@ -495,13 +801,255 @@ async function parseShapeFragment(fragment, args) {
495
801
  kind: "shape",
496
802
  ...transform,
497
803
  shapeType,
804
+ ...(shapeAdjustments ? { shapeAdjustments } : {}),
805
+ ...(geometry ? { geometry } : {}),
498
806
  ...(fill ? { fill } : {}),
499
- ...(line ? { lineColor: line.color, lineWidth: line.width } : {}),
807
+ ...(line
808
+ ? {
809
+ lineColor: line.color,
810
+ lineWidth: line.width,
811
+ lineHeadEnd: line.headEnd,
812
+ lineTailEnd: line.tailEnd,
813
+ }
814
+ : {}),
500
815
  },
501
816
  ];
502
817
  }
503
818
  return [];
504
819
  }
820
+ /**
821
+ * `p:graphicFrame` has no `p:spPr` (its transform lives at `p:xfrm` directly)
822
+ * and its content is `a:graphic/a:graphicData` rather than `p:txBody` — most
823
+ * commonly a table, but also charts, SmartArt, and embedded OLE objects that
824
+ * have no shape structure we can reconstruct. Only tables convert; anything
825
+ * else is counted as a dropped-content fidelity signal instead of silently
826
+ * vanishing with no signal at all.
827
+ */
828
+ function parseGraphicFrameFragment(node, args) {
829
+ const table = parseGraphicFrameTable(node, args.colorContext, {
830
+ relationships: args.slideRelationships,
831
+ slideNumber: args.slideNumber,
832
+ }, args.tableStyles);
833
+ if (!table) {
834
+ args.tablesDegraded.count += 1;
835
+ return [];
836
+ }
837
+ // Google Slides always writes the sentinel `3000000x3000000` into a table
838
+ // graphicFrame's `<a:ext>`; the authored `<a:tblGrid>`/`<a:tr h>` is the
839
+ // real geometry, and PowerPoint sizes tables from it too. Trusting the ext
840
+ // rendered an 88%-wide grid at 33% and perfectly square, wrapping every
841
+ // header one character per line.
842
+ const frameTransform = transformFromXfrmNode(record(node["p:xfrm"]));
843
+ const gridWidth = sumOf(table.columnWidthsEmu);
844
+ const gridHeight = sumOf(table.rowHeightsEmu);
845
+ const transform = applyTransform({
846
+ ...frameTransform,
847
+ width: gridWidth ?? frameTransform.width,
848
+ height: gridHeight ?? frameTransform.height,
849
+ }, args.context);
850
+ return [
851
+ {
852
+ id: readShapeId(node),
853
+ name: readShapeName(node),
854
+ kind: "table",
855
+ ...transform,
856
+ table,
857
+ },
858
+ ];
859
+ }
860
+ /** Reads a graphicFrame's `a:graphic/a:graphicData/a:tbl` into a row/cell grid. Returns `undefined` for a non-table graphicFrame (chart/SmartArt/OLE) or a table with no rows. */
861
+ function parseGraphicFrameTable(node, context, text, tableStyles) {
862
+ const graphicData = record(record(node["a:graphic"])?.["a:graphicData"]);
863
+ const tbl = record(graphicData?.["a:tbl"]);
864
+ if (!tbl)
865
+ return undefined;
866
+ const rawRows = asArray(tbl["a:tr"]);
867
+ const rawGridColumns = asArray(record(tbl["a:tblGrid"])?.["a:gridCol"]);
868
+ const columnWidthsEmu = rawGridColumns
869
+ .map((rawColumn) => positiveAttributeNumber(rawColumn, "@_w"))
870
+ .filter((width) => width !== undefined);
871
+ const rowHeightsEmu = rawRows
872
+ .map((rawRow) => positiveAttributeNumber(rawRow, "@_h"))
873
+ .filter((height) => height !== undefined);
874
+ const tblPr = record(tbl["a:tblPr"]);
875
+ const style = tableStyles?.get(normalizeTableStyleId(innerText(tblPr?.["a:tableStyleId"])));
876
+ const banding = {
877
+ firstRow: xmlBoolean(tblPr?.["@_firstRow"]),
878
+ lastRow: xmlBoolean(tblPr?.["@_lastRow"]),
879
+ firstCol: xmlBoolean(tblPr?.["@_firstCol"]),
880
+ lastCol: xmlBoolean(tblPr?.["@_lastCol"]),
881
+ bandRow: xmlBoolean(tblPr?.["@_bandRow"]),
882
+ bandCol: xmlBoolean(tblPr?.["@_bandCol"]),
883
+ };
884
+ const columnCount = Math.max(rawGridColumns.length, ...rawRows.map((rawRow) => asArray(record(rawRow)?.["a:tc"]).length));
885
+ const rows = rawRows.map((rawRow, rowIndex) => {
886
+ const row = record(rawRow);
887
+ const cells = [];
888
+ for (const [columnIndex, rawCell] of asArray(row?.["a:tc"]).entries()) {
889
+ const cell = record(rawCell);
890
+ if (!cell)
891
+ continue;
892
+ // A merge-continuation cell's content is already represented once, by
893
+ // the spanning cell's gridSpan/rowSpan below.
894
+ if (xmlBoolean(cell["@_hMerge"]) || xmlBoolean(cell["@_vMerge"]))
895
+ continue;
896
+ const gridSpan = Number(cell["@_gridSpan"]);
897
+ const rowSpan = Number(cell["@_rowSpan"]);
898
+ const tcPr = record(cell["a:tcPr"]);
899
+ const styleParts = tableStyleParts(banding, rowIndex, columnIndex);
900
+ const fill = TABLE_CELL_FILL_ELEMENTS.some((name) => tcPr?.[name] !== undefined)
901
+ ? parseShapeFill(tcPr, context)
902
+ : tableStylePartFill(style, styleParts, context);
903
+ const borders = resolveTableCellBorders({
904
+ tcPr,
905
+ style,
906
+ styleParts,
907
+ context,
908
+ firstColumn: columnIndex === 0,
909
+ lastColumn: columnIndex + Math.max(1, Number(cell["@_gridSpan"]) || 1) >=
910
+ columnCount,
911
+ firstRow: rowIndex === 0,
912
+ lastRow: rowIndex + Math.max(1, Number(cell["@_rowSpan"]) || 1) >=
913
+ rawRows.length,
914
+ });
915
+ cells.push({
916
+ paragraphs: parseTextBodyParagraphs(record(cell["a:txBody"]), context, text),
917
+ ...(Number.isFinite(gridSpan) && gridSpan > 1
918
+ ? { colSpan: gridSpan }
919
+ : {}),
920
+ ...(Number.isFinite(rowSpan) && rowSpan > 1 ? { rowSpan } : {}),
921
+ ...(fill ? { fill } : {}),
922
+ ...(borders ? { borders } : {}),
923
+ });
924
+ }
925
+ return cells;
926
+ });
927
+ return rows.length > 0
928
+ ? {
929
+ rows,
930
+ ...(columnWidthsEmu.length > 0 ? { columnWidthsEmu } : {}),
931
+ ...(rowHeightsEmu.length > 0 ? { rowHeightsEmu } : {}),
932
+ }
933
+ : undefined;
934
+ }
935
+ async function parseTableStyles(zip, parseXml) {
936
+ const styles = new Map();
937
+ const xml = await zip.file("ppt/tableStyles.xml")?.async("string");
938
+ if (!xml)
939
+ return styles;
940
+ const list = record(record(parseXml(xml))?.["a:tblStyleLst"]);
941
+ if (!list)
942
+ return styles;
943
+ for (const raw of asArray(list["a:tblStyle"])) {
944
+ const style = record(raw);
945
+ const id = stringValue(style?.["@_styleId"]);
946
+ if (style && id)
947
+ styles.set(normalizeTableStyleId(id), style);
948
+ }
949
+ const fallback = styles.get(normalizeTableStyleId(stringValue(list["@_def"]) ?? ""));
950
+ if (fallback)
951
+ styles.set("", fallback);
952
+ return styles;
953
+ }
954
+ function normalizeTableStyleId(value) {
955
+ return value.replace(/[{}\s]/g, "").toLowerCase();
956
+ }
957
+ /**
958
+ * The table style parts that apply to one cell, lowest precedence first
959
+ * (ECMA-376 §20.1.4.2). The corner parts (`nwCell`, `seCell`, ...) are not
960
+ * resolved — no table style shipped by the decks this parser was built
961
+ * against defines them.
962
+ */
963
+ function tableStyleParts(banding, rowIndex, columnIndex) {
964
+ const parts = ["wholeTbl"];
965
+ if (banding.bandCol) {
966
+ const band = columnIndex - (banding.firstCol ? 1 : 0);
967
+ if (band >= 0)
968
+ parts.push(band % 2 === 0 ? "band1V" : "band2V");
969
+ }
970
+ if (banding.bandRow) {
971
+ const band = rowIndex - (banding.firstRow ? 1 : 0);
972
+ if (band >= 0)
973
+ parts.push(band % 2 === 0 ? "band1H" : "band2H");
974
+ }
975
+ if (banding.firstCol && columnIndex === 0)
976
+ parts.push("firstCol");
977
+ if (banding.firstRow && rowIndex === 0)
978
+ parts.push("firstRow");
979
+ return parts;
980
+ }
981
+ /** `a:tcPr`'s own fill elements. Their presence — not the color they resolve to — is what makes a cell's fill an override, since `a:noFill` is an explicit "no fill" that has to beat the table style. */
982
+ const TABLE_CELL_FILL_ELEMENTS = ["a:noFill", "a:solidFill", "a:gradFill"];
983
+ const TABLE_BORDER_SIDES = [
984
+ { side: "left", cellElement: "a:lnL", edge: "a:left", inside: "a:insideV" },
985
+ { side: "right", cellElement: "a:lnR", edge: "a:right", inside: "a:insideV" },
986
+ { side: "top", cellElement: "a:lnT", edge: "a:top", inside: "a:insideH" },
987
+ {
988
+ side: "bottom",
989
+ cellElement: "a:lnB",
990
+ edge: "a:bottom",
991
+ inside: "a:insideH",
992
+ },
993
+ ];
994
+ function resolveTableCellBorders(args) {
995
+ const atEdge = {
996
+ left: args.firstColumn,
997
+ right: args.lastColumn,
998
+ top: args.firstRow,
999
+ bottom: args.lastRow,
1000
+ };
1001
+ const borders = {};
1002
+ for (const { side, cellElement, edge, inside } of TABLE_BORDER_SIDES) {
1003
+ // A cell that declares the side at all decides it, including
1004
+ // `<a:lnL><a:noFill/></a:lnL>` — that is the author switching the style's
1005
+ // rule off for this cell, not a missing value to fall back from.
1006
+ const declared = record(args.tcPr?.[cellElement]);
1007
+ const border = declared
1008
+ ? parseTableBorderLine(declared, args.context)
1009
+ : tableStylePartBorder(args.style, args.styleParts, atEdge[side] ? edge : inside, args.context);
1010
+ if (border)
1011
+ borders[side] = border;
1012
+ }
1013
+ return Object.keys(borders).length > 0 ? borders : undefined;
1014
+ }
1015
+ /** Walks the cell's style parts highest precedence first, stopping at the first part that declares this side — including one that declares it as `a:noFill`. */
1016
+ function tableStylePartBorder(style, parts, side, context) {
1017
+ for (let index = parts.length - 1; index >= 0; index -= 1) {
1018
+ const borderSet = record(record(record(style?.[`a:${parts[index]}`])?.["a:tcStyle"])?.["a:tcBdr"]);
1019
+ const declared = record(borderSet?.[side]);
1020
+ if (declared)
1021
+ return parseTableBorderLine(record(declared["a:ln"]), context);
1022
+ }
1023
+ return undefined;
1024
+ }
1025
+ function tableStylePartFill(style, parts, context) {
1026
+ for (let index = parts.length - 1; index >= 0; index -= 1) {
1027
+ const fill = record(record(record(style?.[`a:${parts[index]}`])?.["a:tcStyle"])?.["a:fill"]);
1028
+ if (fill)
1029
+ return parseShapeFill(fill, context);
1030
+ }
1031
+ return undefined;
1032
+ }
1033
+ /** An `a:ln` cell edge. `undefined` means "draws nothing here": either the line declares `a:noFill`, or its fill is one this parser cannot resolve to a color. */
1034
+ function parseTableBorderLine(line, context) {
1035
+ if (!line || line["a:noFill"] !== undefined)
1036
+ return undefined;
1037
+ const color = parseColor(record(line["a:solidFill"]), context);
1038
+ if (!color)
1039
+ return undefined;
1040
+ const widthEmu = positiveAttributeNumber(line, "@_w");
1041
+ const preset = stringValue(record(line["a:prstDash"])?.["@_val"]);
1042
+ const dash = !preset || preset === "solid"
1043
+ ? undefined
1044
+ : preset === "dot" || preset === "sysDot"
1045
+ ? "dotted"
1046
+ : "dashed";
1047
+ return {
1048
+ color,
1049
+ ...(widthEmu ? { widthEmu } : {}),
1050
+ ...(dash ? { dash } : {}),
1051
+ };
1052
+ }
505
1053
  function extractDirectShapeFragments(xml, container) {
506
1054
  const containerMatch = new RegExp(`<p:${container}\\b[^>]*>`, "i").exec(xml);
507
1055
  if (!containerMatch)
@@ -559,14 +1107,20 @@ function findMatchingXmlTag(xml, start, localName) {
559
1107
  }
560
1108
  return -1;
561
1109
  }
1110
+ /** Every shape flavour carries its `<p:cNvPr>` under its own non-visual wrapper. Missing one (`p:cxnSpPr`, for connectors) means that shape gets a fresh random id on every import, breaking the `data-slide-object-id` stability contract. */
1111
+ function readNonVisualProperties(node) {
1112
+ return (record(record(node["p:nvSpPr"])?.["p:cNvPr"]) ??
1113
+ record(record(node["p:nvPicPr"])?.["p:cNvPr"]) ??
1114
+ record(record(node["p:nvCxnSpPr"])?.["p:cNvPr"]) ??
1115
+ record(record(node["p:nvGraphicFramePr"])?.["p:cNvPr"]) ??
1116
+ record(record(node["p:nvGrpSpPr"])?.["p:cNvPr"]));
1117
+ }
562
1118
  function readShapeId(node) {
563
- const cNvPr = record(record(node["p:nvSpPr"])?.["p:cNvPr"]) ??
564
- record(record(node["p:nvPicPr"])?.["p:cNvPr"]);
565
- return (stringValue(cNvPr?.["@_id"]) ??
1119
+ return (stringValue(readNonVisualProperties(node)?.["@_id"]) ??
566
1120
  `shape-${Math.random().toString(36).slice(2)}`);
567
1121
  }
568
1122
  function readShapeName(node) {
569
- return stringValue(record(record(node["p:nvSpPr"])?.["p:cNvPr"])?.["@_name"]);
1123
+ return stringValue(readNonVisualProperties(node)?.["@_name"]);
570
1124
  }
571
1125
  function readPoint(value) {
572
1126
  const point = record(value);
@@ -583,7 +1137,10 @@ function readExtent(value) {
583
1137
  };
584
1138
  }
585
1139
  function readTransform(node, key) {
586
- const xfrm = record(record(node[key])?.["a:xfrm"]);
1140
+ return transformFromXfrmNode(record(record(node[key])?.["a:xfrm"]));
1141
+ }
1142
+ /** Shared by `readTransform` (a wrapper-nested `a:xfrm`, e.g. `p:spPr/a:xfrm`) and `graphicFrame`, whose `p:xfrm` sits directly on the node instead of inside a wrapper element. */
1143
+ function transformFromXfrmNode(xfrm) {
587
1144
  const off = readPoint(xfrm?.["a:off"]);
588
1145
  const ext = readExtent(xfrm?.["a:ext"]);
589
1146
  const rawRotation = Number(xfrm?.["@_rot"]);
@@ -592,26 +1149,74 @@ function readTransform(node, key) {
592
1149
  y: off.y,
593
1150
  width: ext.x,
594
1151
  height: ext.y,
1152
+ ...(xfrm?.["@_flipH"] !== undefined && xmlBoolean(xfrm["@_flipH"])
1153
+ ? { flipH: true }
1154
+ : {}),
1155
+ ...(xfrm?.["@_flipV"] !== undefined && xmlBoolean(xfrm["@_flipV"])
1156
+ ? { flipV: true }
1157
+ : {}),
595
1158
  ...(Number.isFinite(rawRotation) && rawRotation !== 0
596
1159
  ? { rotation: rawRotation / 60000 }
597
1160
  : {}),
598
1161
  };
599
1162
  }
600
1163
  function applyTransform(transform, context) {
1164
+ const scaleX = Math.hypot(context.matrix.a, context.matrix.b);
1165
+ const scaleY = Math.hypot(context.matrix.c, context.matrix.d);
1166
+ const width = transform.width * scaleX;
1167
+ const height = transform.height * scaleY;
1168
+ // Map the shape's own (un-rotated) box center through the accumulated
1169
+ // group matrix so a child inside a rotated group orbits the group's pivot
1170
+ // — the box's own visual spin is applied separately below via `rotation`,
1171
+ // so only the center (not the whole box) needs to go through the matrix.
1172
+ const center = applyMatPoint(context.matrix, transform.x + transform.width / 2, transform.y + transform.height / 2);
1173
+ const rotation = (transform.rotation ?? 0) + context.rotation;
601
1174
  return {
602
- x: context.originX + transform.x * context.scaleX,
603
- y: context.originY + transform.y * context.scaleY,
604
- width: transform.width * context.scaleX,
605
- height: transform.height * context.scaleY,
606
- ...(transform.rotation || context.rotation
607
- ? { rotation: (transform.rotation ?? 0) + context.rotation }
608
- : {}),
1175
+ x: center.x - width / 2,
1176
+ y: center.y - height / 2,
1177
+ width,
1178
+ height,
1179
+ ...(transform.flipH ? { flipH: true } : {}),
1180
+ ...(transform.flipV ? { flipV: true } : {}),
1181
+ ...(rotation ? { rotation } : {}),
609
1182
  };
610
1183
  }
611
- function parseTextBody(node, context) {
612
- const txBody = record(node["p:txBody"]);
1184
+ /** A slide-number field on a slideLayout/slideMaster is parsed once and shared by every slide using that layout, so its value cannot be known at parse time. This token stands in until `substituteSlideNumber` resolves it per slide — the alternative, keeping the cached "‹#›" glyph, is the literal placeholder text showing up on 36 of 36 slides. */
1185
+ const SLIDE_NUMBER_TOKEN = "\u0001slidenum\u0001";
1186
+ /** Replaces `SLIDE_NUMBER_TOKEN` in shared template-layer elements, cloning only the elements that actually carry one. */
1187
+ function substituteSlideNumber(elements, slideNumber) {
1188
+ return elements.map((element) => {
1189
+ if (!element.paragraphs?.some((paragraph) => paragraph.runs.some((run) => run.content.includes(SLIDE_NUMBER_TOKEN)))) {
1190
+ return element;
1191
+ }
1192
+ return {
1193
+ ...element,
1194
+ paragraphs: element.paragraphs.map((paragraph) => ({
1195
+ ...paragraph,
1196
+ runs: paragraph.runs.map((run) => ({
1197
+ ...run,
1198
+ content: run.content.replaceAll(SLIDE_NUMBER_TOKEN, String(slideNumber)),
1199
+ })),
1200
+ })),
1201
+ };
1202
+ });
1203
+ }
1204
+ function parseTextBody(node, context, text) {
1205
+ return parseTextBodyParagraphs(record(node["p:txBody"]), context, text);
1206
+ }
1207
+ /** PowerPoint's `buAutoNum` variants (arabicPeriod, alphaLcPeriod, romanUcPeriod, ...) all number the same underlying sequence — approximating every variant with arabic digits keeps list order/grouping correct even though the glyph style doesn't match `type` exactly. */
1208
+ function formatAutoNumBullet(n) {
1209
+ return `${n}.`;
1210
+ }
1211
+ /** Core `a:p` paragraph parsing, shared by a shape's `p:txBody` and a table cell's `a:txBody` (which sit at different paths in their parent node, so the caller resolves the `txBody` record itself). */
1212
+ function parseTextBodyParagraphs(txBody, context, text) {
613
1213
  if (!txBody)
614
1214
  return [];
1215
+ // `a:buAutoNum` carries no explicit number — PowerPoint derives it from
1216
+ // paragraph order — so the sequence has to be tracked per nesting level as
1217
+ // paragraphs are walked in document order, restarting whenever a deeper
1218
+ // level's own sequence begins or a non-auto-numbered paragraph interrupts it.
1219
+ const autoNumCounters = new Map();
615
1220
  return asArray(txBody["a:p"]).map((rawParagraph) => {
616
1221
  const paragraph = record(rawParagraph);
617
1222
  const pPr = record(paragraph?.["a:pPr"]);
@@ -622,34 +1227,67 @@ function parseTextBody(node, context) {
622
1227
  if (content) {
623
1228
  runs.push({
624
1229
  content,
625
- ...runProperties(record(run?.["a:rPr"]), {}, context),
1230
+ ...runProperties(record(run?.["a:rPr"]), {}, context, text?.relationships),
626
1231
  });
627
1232
  }
628
1233
  }
629
1234
  for (const rawField of asArray(paragraph?.["a:fld"])) {
630
1235
  const field = record(rawField);
631
- const content = innerText(field?.["a:t"]);
1236
+ // A slide-number field caches the authoring tool's placeholder glyph
1237
+ // ("‹#›") in its `<a:t>`; importing that literally puts the glyph on
1238
+ // the slide instead of the number it stands for.
1239
+ const content = stringValue(field?.["@_type"]) === "slidenum"
1240
+ ? (text?.slideNumber?.toString() ?? SLIDE_NUMBER_TOKEN)
1241
+ : innerText(field?.["a:t"]);
632
1242
  if (content) {
633
1243
  runs.push({
634
1244
  content,
635
- ...runProperties(record(field?.["a:rPr"]), {}, context),
1245
+ ...runProperties(record(field?.["a:rPr"]), {}, context, text?.relationships),
636
1246
  });
637
1247
  }
638
1248
  }
1249
+ // `<a:br/>` was rewritten into a bare newline run before parsing, so it
1250
+ // carries none of its neighbours' styling and would otherwise collapse to
1251
+ // the default font size mid-paragraph.
1252
+ for (const [index, run] of runs.entries()) {
1253
+ if (run.content !== "\n" || run.fontSize !== undefined)
1254
+ continue;
1255
+ const source = runs[index - 1] ?? runs[index + 1];
1256
+ if (source)
1257
+ runs[index] = { ...source, content: "\n" };
1258
+ }
639
1259
  const bullet = record(pPr?.["a:buChar"]);
640
1260
  const bulletColor = parseColor(record(pPr?.["a:buClr"]), context);
641
1261
  const bulletFont = stringValue(record(pPr?.["a:buFont"])?.["@_typeface"]);
642
1262
  const bulletSize = Number(record(pPr?.["a:buSzPts"])?.["@_val"]);
643
- const lineSpacing = parseParagraphSpacing(pPr?.["a:lnSpc"]);
1263
+ const lineSpacing = parseParagraphSpacing(pPr?.["a:lnSpc"], runs[0]?.fontSize);
644
1264
  const spaceBeforePt = parsePoints(pPr?.["a:spcBef"]);
645
1265
  const spaceAfterPt = parsePoints(pPr?.["a:spcAft"]);
646
1266
  const alignment = mapAlignment(stringValue(pPr?.["@_algn"]));
1267
+ const level = Number.isFinite(Number(pPr?.["@_lvl"]))
1268
+ ? Number(pPr?.["@_lvl"])
1269
+ : 0;
1270
+ let autoNumBullet;
1271
+ if (pPr?.["a:buAutoNum"] !== undefined && pPr?.["a:buNone"] === undefined) {
1272
+ for (const key of [...autoNumCounters.keys()]) {
1273
+ if (key > level)
1274
+ autoNumCounters.delete(key);
1275
+ }
1276
+ const next = (autoNumCounters.get(level) ?? 0) + 1;
1277
+ autoNumCounters.set(level, next);
1278
+ autoNumBullet = formatAutoNumBullet(next);
1279
+ }
1280
+ else if (bullet?.["@_char"] === undefined) {
1281
+ autoNumCounters.clear();
1282
+ }
647
1283
  return {
648
1284
  runs,
649
1285
  ...(alignment ? { alignment } : {}),
650
1286
  ...(bullet?.["@_char"] && !pPr?.["a:buNone"]
651
1287
  ? { bulletChar: String(bullet["@_char"]) }
652
- : {}),
1288
+ : autoNumBullet
1289
+ ? { bulletChar: autoNumBullet }
1290
+ : {}),
653
1291
  ...(bulletColor ? { bulletColor } : {}),
654
1292
  ...(bulletFont ? { bulletFontFamily: bulletFont } : {}),
655
1293
  ...(Number.isFinite(bulletSize) && bulletSize > 0
@@ -667,9 +1305,42 @@ function parseTextBody(node, context) {
667
1305
  ...(lineSpacing !== undefined ? { lineSpacing } : {}),
668
1306
  ...(spaceBeforePt !== undefined ? { spaceBeforePt } : {}),
669
1307
  ...(spaceAfterPt !== undefined ? { spaceAfterPt } : {}),
1308
+ ...(pPr?.["@_rtl"] !== undefined && xmlBoolean(pPr["@_rtl"])
1309
+ ? { rtl: true }
1310
+ : {}),
670
1311
  };
671
1312
  });
672
1313
  }
1314
+ /**
1315
+ * `<a:normAutofit fontScale="90000" lnSpcReduction="10000"/>` is the shrink
1316
+ * PowerPoint already computed and baked into the file when the author's text
1317
+ * overflowed its shape. Ignoring it renders that text at its nominal size,
1318
+ * spilling out of the box the author saw it fit into. A bare `<a:normAutofit/>`
1319
+ * carries no scale — computing one ourselves would need text measurement, so
1320
+ * it is left alone rather than guessed at.
1321
+ */
1322
+ function applyAutofitScale(paragraphs, bodyPr) {
1323
+ const autofit = record(bodyPr?.["a:normAutofit"]);
1324
+ if (!autofit)
1325
+ return paragraphs;
1326
+ const fontScale = Number(autofit["@_fontScale"]) / 100000;
1327
+ const lineReduction = Number(autofit["@_lnSpcReduction"]) / 100000;
1328
+ const scale = Number.isFinite(fontScale) && fontScale > 0 ? fontScale : 1;
1329
+ const lineScale = Number.isFinite(lineReduction) && lineReduction > 0 && lineReduction < 1
1330
+ ? 1 - lineReduction
1331
+ : 1;
1332
+ if (scale === 1 && lineScale === 1)
1333
+ return paragraphs;
1334
+ return paragraphs.map((paragraph) => ({
1335
+ ...paragraph,
1336
+ ...(paragraph.lineSpacing !== undefined
1337
+ ? { lineSpacing: roundTo(paragraph.lineSpacing * lineScale, 4) }
1338
+ : {}),
1339
+ runs: paragraph.runs.map((run) => run.fontSize === undefined
1340
+ ? run
1341
+ : { ...run, fontSize: roundTo(run.fontSize * scale, 2) }),
1342
+ }));
1343
+ }
673
1344
  function parseTextBoxProperties(node) {
674
1345
  const bodyPr = record(record(node["p:txBody"])?.["a:bodyPr"]);
675
1346
  if (!bodyPr)
@@ -689,12 +1360,120 @@ function parseTextBoxProperties(node) {
689
1360
  : { verticalAlign: "top" }),
690
1361
  };
691
1362
  }
1363
+ /** Point count each straight/curve command carries, keyed by tag. `a:arcTo` and `a:close` carry none and are read separately. */
1364
+ const PATH_COMMAND_POINTS = {
1365
+ "a:moveTo": 1,
1366
+ "a:lnTo": 1,
1367
+ "a:quadBezTo": 2,
1368
+ "a:cubicBezTo": 3,
1369
+ };
1370
+ /** `a:prstGeom/a:avLst` adjustments, keyed by guide name. A deck that overrides a preset's `adj` (a 50%-radius pill, a block arc's start and sweep) records it here and nowhere else, so a consumer reproducing the preset from its defaults alone draws the wrong shape. */
1371
+ function parseShapeAdjustments(shapeProperties) {
1372
+ const guides = asArray(record(record(shapeProperties?.["a:prstGeom"])?.["a:avLst"])?.["a:gd"]);
1373
+ const adjustments = {};
1374
+ for (const raw of guides) {
1375
+ const guide = record(raw);
1376
+ const name = stringValue(guide?.["@_name"]);
1377
+ // Only the literal `val <n>` form is a value; anything else is a formula
1378
+ // referencing other guides, which reproducing here would mean shipping
1379
+ // OOXML's whole guide language.
1380
+ const value = Number(stringValue(guide?.["@_fmla"])?.match(/^val\s+(-?\d+)$/)?.[1]);
1381
+ if (name && Number.isFinite(value))
1382
+ adjustments[name] = value;
1383
+ }
1384
+ return Object.keys(adjustments).length > 0 ? adjustments : undefined;
1385
+ }
1386
+ /**
1387
+ * `a:custGeom`'s authored outline. Every command maps 1:1 onto an SVG path
1388
+ * segment, so the shape can be reproduced exactly rather than flattened to
1389
+ * the rectangle its bounding box happens to be.
1390
+ */
1391
+ function parseCustomGeometry(shapeProperties, shapeBox) {
1392
+ const custGeom = record(shapeProperties?.["a:custGeom"]);
1393
+ if (!custGeom)
1394
+ return undefined;
1395
+ const paths = [];
1396
+ for (const rawList of asArray(custGeom["a:pathLst"])) {
1397
+ for (const rawPath of asArray(record(rawList)?.["a:path"])) {
1398
+ const node = record(rawPath);
1399
+ if (!node)
1400
+ continue;
1401
+ const commands = readPathCommands(node);
1402
+ if (!commands)
1403
+ continue;
1404
+ // A path with no `w`/`h` states its points in the shape's own EMU space.
1405
+ const w = Number(node["@_w"]) || shapeBox.width;
1406
+ const h = Number(node["@_h"]) || shapeBox.height;
1407
+ if (!(w > 0) || !(h > 0))
1408
+ continue;
1409
+ paths.push({ w, h, commands });
1410
+ }
1411
+ }
1412
+ return paths.length > 0 ? { kind: "custom", paths } : undefined;
1413
+ }
1414
+ /**
1415
+ * Rebuilds one `a:path`'s command sequence from the order stamped on by
1416
+ * `annotatePathCommandOrder`. Returns `undefined` — not a partial list — when
1417
+ * any command is unreadable: a path missing a segment is not a simpler path,
1418
+ * it is a different and wrong one, and the caller's fallback (the shape's
1419
+ * plain box) is at least a state a reader can recognize as unreproduced.
1420
+ */
1421
+ function readPathCommands(path) {
1422
+ const ordered = [];
1423
+ let unreadable = false;
1424
+ const push = (node, command) => {
1425
+ const order = Number(node?.[PATH_COMMAND_ORDER_ATTRIBUTE]);
1426
+ if (!command || !Number.isFinite(order))
1427
+ unreadable = true;
1428
+ else
1429
+ ordered.push({ order, command });
1430
+ };
1431
+ for (const [tag, count] of Object.entries(PATH_COMMAND_POINTS)) {
1432
+ for (const raw of asArray(path[tag])) {
1433
+ const node = record(raw);
1434
+ const points = asArray(node?.["a:pt"]).map((raw) => {
1435
+ const pt = record(raw);
1436
+ return { x: Number(pt?.["@_x"]), y: Number(pt?.["@_y"]) };
1437
+ });
1438
+ const usable = points.length === count &&
1439
+ points.every((pt) => Number.isFinite(pt.x) && Number.isFinite(pt.y));
1440
+ push(node, usable
1441
+ ? {
1442
+ kind: tag.slice(2),
1443
+ points,
1444
+ }
1445
+ : undefined);
1446
+ }
1447
+ }
1448
+ for (const raw of asArray(path["a:arcTo"])) {
1449
+ const node = record(raw);
1450
+ const arc = {
1451
+ kind: "arcTo",
1452
+ wR: Number(node?.["@_wR"]),
1453
+ hR: Number(node?.["@_hR"]),
1454
+ stAng: Number(node?.["@_stAng"]),
1455
+ swAng: Number(node?.["@_swAng"]),
1456
+ };
1457
+ const usable = [arc.wR, arc.hR, arc.stAng, arc.swAng].every((value) => Number.isFinite(value));
1458
+ push(node, usable ? arc : undefined);
1459
+ }
1460
+ for (const raw of asArray(path["a:close"])) {
1461
+ push(record(raw), { kind: "close" });
1462
+ }
1463
+ if (unreadable || ordered.length === 0)
1464
+ return undefined;
1465
+ ordered.sort((a, b) => a.order - b.order);
1466
+ return ordered.map((entry) => entry.command);
1467
+ }
692
1468
  function parseShapeFill(shapeProperties, context) {
693
1469
  if (!shapeProperties)
694
1470
  return undefined;
695
1471
  if (shapeProperties["a:noFill"] !== undefined)
696
1472
  return undefined;
697
- return parseColor(record(shapeProperties["a:solidFill"]), context);
1473
+ const solid = parseColor(record(shapeProperties["a:solidFill"]), context);
1474
+ if (solid)
1475
+ return solid;
1476
+ return parseGradientFill(record(shapeProperties["a:gradFill"]), context);
698
1477
  }
699
1478
  function parseShapeLine(shapeProperties, context) {
700
1479
  const line = record(shapeProperties?.["a:ln"]);
@@ -704,23 +1483,95 @@ function parseShapeLine(shapeProperties, context) {
704
1483
  if (!color)
705
1484
  return undefined;
706
1485
  const width = Number(line["@_w"]);
1486
+ const headEnd = parseLineEnd(line["a:headEnd"]);
1487
+ const tailEnd = parseLineEnd(line["a:tailEnd"]);
707
1488
  return {
708
1489
  color,
709
1490
  ...(Number.isFinite(width) && width > 0 ? { width } : {}),
1491
+ ...(headEnd ? { headEnd } : {}),
1492
+ ...(tailEnd ? { tailEnd } : {}),
710
1493
  };
711
1494
  }
712
- /** Placeholder text has no explicit color of its own when the author relied on the slide master's default run properties — apply that inherited color to any run that didn't resolve one, using each paragraph's own nested-bullet level (falling back to level 0's color when a deeper level has no default of its own). */
713
- function applyPlaceholderDefaultColor(paragraphs, colorsByLevel) {
714
- if (!colorsByLevel)
1495
+ function parseLineEnd(node) {
1496
+ const end = record(node);
1497
+ const type = stringValue(end?.["@_type"]);
1498
+ if (!type)
1499
+ return undefined;
1500
+ const w = stringValue(end?.["@_w"]);
1501
+ const len = stringValue(end?.["@_len"]);
1502
+ return { type, ...(w ? { w } : {}), ...(len ? { len } : {}) };
1503
+ }
1504
+ /** Placeholder types that inherit from each other: a slide's `ctrTitle` falls back to the layout/master's `title` shape, a `subTitle` to its `body` shape. Matching the type as a literal string instead sent every title slide past the shape that actually defines its look, down to the `<p:txStyles>` boilerplate that `PlaceholderDefaults.masterPlaceholders` documents as the last resort. */
1505
+ const PLACEHOLDER_TYPE_GROUPS = [
1506
+ ["title", "ctrTitle"],
1507
+ ["body", "subTitle", "obj"],
1508
+ ];
1509
+ function placeholderTypeCandidates(type) {
1510
+ const group = PLACEHOLDER_TYPE_GROUPS.find((names) => names.includes(type));
1511
+ return group ? [type, ...group.filter((name) => name !== type)] : [type];
1512
+ }
1513
+ /** Finds the layout/master placeholder shape a slide's own `<p:ph>` inherits from: an exact `type`+`idx` match first, then `type` alone (a title's `idx` is commonly a sentinel like `4294967295` that won't match anything real), then the same two passes for that type's aliases, then — for a slide placeholder with no `type` at all, a generic content placeholder — `idx` alone. */
1514
+ function findMatchingPlaceholderShape(placeholders, type, idx) {
1515
+ if (type) {
1516
+ for (const candidate of placeholderTypeCandidates(type)) {
1517
+ const match = placeholders.find((p) => p.type === candidate && p.idx === idx) ??
1518
+ placeholders.find((p) => p.type === candidate);
1519
+ if (match)
1520
+ return match;
1521
+ }
1522
+ return undefined;
1523
+ }
1524
+ return placeholders.find((p) => p.idx === idx);
1525
+ }
1526
+ /** `<p:txStyles>` groups its per-level defaults into three buckets by placeholder type — `titleStyle` for title-ish placeholders, `bodyStyle` for body/content-ish ones (including a bare `<p:ph/>` with no `type`, which the schema defaults to `"body"`), and `otherStyle` for everything else (`dt`, `ftr`, `sldNum`, ...). */
1527
+ function txStylesTierForType(type) {
1528
+ if (type === "title" || type === "ctrTitle")
1529
+ return "title";
1530
+ if (type === undefined ||
1531
+ type === "body" ||
1532
+ type === "subTitle" ||
1533
+ type === "obj")
1534
+ return "body";
1535
+ return "other";
1536
+ }
1537
+ /** Resolves a placeholder run's inherited property chain — this slide's own layout placeholder shape, then the slide master's own placeholder shape, then the master's generic `<p:txStyles>` bucket for this placeholder's type — per nesting level, so a paragraph at level N gets level N's own defaults instead of only ever level 0's. Merging is per-property, not per-tier: a layout that declares only a size still inherits the master's color. */
1538
+ function resolvePlaceholderRunDefaults(args) {
1539
+ const layoutMatch = findMatchingPlaceholderShape(args.defaults.layoutPlaceholders, args.type, args.idx);
1540
+ const masterMatch = findMatchingPlaceholderShape(args.defaults.masterPlaceholders, args.type, args.idx);
1541
+ const txStylesByLevel = args.defaults[txStylesTierForType(args.type)];
1542
+ const merged = {};
1543
+ let any = false;
1544
+ for (let level = 0; level < 9; level++) {
1545
+ const properties = {
1546
+ ...txStylesByLevel[level],
1547
+ ...masterMatch?.runDefaultsByLevel[level],
1548
+ ...layoutMatch?.runDefaultsByLevel[level],
1549
+ };
1550
+ merged[level] = properties;
1551
+ if (Object.keys(properties).length > 0)
1552
+ any = true;
1553
+ }
1554
+ return any ? merged : undefined;
1555
+ }
1556
+ /** Resolves a placeholder shape's inherited position/size — this slide's own layout placeholder shape's own `<a:xfrm>` first, then the slide master's, mirroring `resolvePlaceholderColorsByLevel`'s layout-before-master order. Returns `undefined` when neither defines explicit geometry for this placeholder type either (real for `idx="4294967295"` sentinel placeholders in some Google Slides exports), letting the caller fall back to a full-slide content box. */
1557
+ function resolvePlaceholderTransform(args) {
1558
+ const layoutMatch = findMatchingPlaceholderShape(args.defaults.layoutPlaceholders, args.type, args.idx);
1559
+ const masterMatch = findMatchingPlaceholderShape(args.defaults.masterPlaceholders, args.type, args.idx);
1560
+ return layoutMatch?.transform ?? masterMatch?.transform;
1561
+ }
1562
+ /** Placeholder text commonly declares no color, size or typeface of its own, relying entirely on the layout/master defaults — apply those to any property a run didn't resolve itself, using each paragraph's own nested-bullet level (falling back to level 0 when a deeper level has no default of its own). */
1563
+ function applyPlaceholderRunDefaults(paragraphs, defaultsByLevel) {
1564
+ if (!defaultsByLevel)
715
1565
  return paragraphs;
716
1566
  return paragraphs.map((paragraph) => {
717
1567
  const level = paragraph.level ?? 0;
718
- const color = colorsByLevel[level] ?? colorsByLevel[0];
719
- if (!color)
1568
+ const own = defaultsByLevel[level];
1569
+ const defaults = own && Object.keys(own).length > 0 ? own : defaultsByLevel[0];
1570
+ if (!defaults || Object.keys(defaults).length === 0)
720
1571
  return paragraph;
721
1572
  return {
722
1573
  ...paragraph,
723
- runs: paragraph.runs.map((run) => (run.color ? run : { ...run, color })),
1574
+ runs: paragraph.runs.map((run) => ({ ...defaults, ...run })),
724
1575
  };
725
1576
  });
726
1577
  }
@@ -819,32 +1670,42 @@ function readColorTransforms(node) {
819
1670
  const raw = stringValue(record(node[key])?.["@_val"]);
820
1671
  return raw !== undefined ? Number(raw) / 100000 : undefined;
821
1672
  };
1673
+ const alphaRaw = stringValue(record(node["a:alpha"])?.["@_val"]);
822
1674
  return {
823
1675
  lumMod: percent("a:lumMod"),
824
1676
  lumOff: percent("a:lumOff"),
825
1677
  tint: percent("a:tint"),
826
1678
  shade: percent("a:shade"),
1679
+ ...(alphaRaw !== undefined
1680
+ ? { alphaPercent: Number(alphaRaw) / 1000 }
1681
+ : {}),
827
1682
  };
828
1683
  }
1684
+ /** Colors flow through the parser as plain `#rrggbb` hex strings, and every consumer just drops that string straight into CSS — an 8-digit `#rrggbbaa` hex is valid CSS and needs no consumer changes, so alpha rides along as extra hex digits instead of a new color shape. */
829
1685
  function applyColorTransforms(hex, transforms) {
830
- const { lumMod, lumOff, tint, shade } = transforms;
831
- if (lumMod === undefined &&
832
- lumOff === undefined &&
833
- tint === undefined &&
834
- shade === undefined) {
835
- return hex;
1686
+ const { lumMod, lumOff, tint, shade, alphaPercent } = transforms;
1687
+ let result = hex;
1688
+ if (lumMod !== undefined ||
1689
+ lumOff !== undefined ||
1690
+ tint !== undefined ||
1691
+ shade !== undefined) {
1692
+ const [h, s, initialL] = hexToHsl(hex);
1693
+ let l = initialL;
1694
+ if (lumMod !== undefined)
1695
+ l *= lumMod;
1696
+ if (lumOff !== undefined)
1697
+ l += lumOff;
1698
+ if (tint !== undefined)
1699
+ l = l * tint + (1 - tint);
1700
+ if (shade !== undefined)
1701
+ l *= shade;
1702
+ result = hslToHex(h, s, Math.min(1, Math.max(0, l)));
836
1703
  }
837
- const [h, s, initialL] = hexToHsl(hex);
838
- let l = initialL;
839
- if (lumMod !== undefined)
840
- l *= lumMod;
841
- if (lumOff !== undefined)
842
- l += lumOff;
843
- if (tint !== undefined)
844
- l = l * tint + (1 - tint);
845
- if (shade !== undefined)
846
- l *= shade;
847
- return hslToHex(h, s, Math.min(1, Math.max(0, l)));
1704
+ if (alphaPercent !== undefined) {
1705
+ const alphaByte = Math.max(0, Math.min(255, Math.round((alphaPercent / 100) * 255)));
1706
+ result = `${result}${alphaByte.toString(16).padStart(2, "0")}`;
1707
+ }
1708
+ return result;
848
1709
  }
849
1710
  function parseColor(value, context) {
850
1711
  if (!value)
@@ -883,16 +1744,55 @@ function parseColor(value, context) {
883
1744
  ? applyColorTransforms(fallbackColor, transforms)
884
1745
  : undefined;
885
1746
  }
886
- function parseParagraphSpacing(value) {
1747
+ // `a:lnSpc` is either a unitless percent (`a:spcPct`, e.g. 100% = single
1748
+ // spacing) or an absolute point size (`a:spcPts`, e.g. "52pt line height").
1749
+ // Every consumer of the returned `lineSpacing` (html-converter.ts, and our
1750
+ // own PPTX export re-imported through this same parser) treats it as a
1751
+ // unitless ratio multiplied by the run's font size — so an absolute
1752
+ // `spcPts` value must be normalized to that same ratio here, by dividing by
1753
+ // the paragraph's own font size, or a 52pt line spacing on 52pt text
1754
+ // silently becomes a ~52x line-height and pushes the paragraph thousands of
1755
+ // pixels off the slide instead of the intended single-spaced line.
1756
+ // No real deck design intentionally sets exact line spacing under ~0.8x a
1757
+ // paragraph's own font size — anything tighter reads as overlapping text,
1758
+ // not a stylistic choice. Below that floor is a strong signal the exporting
1759
+ // tool (including our own dom-to-pptx-based export, whose line-height
1760
+ // pixel measurement isn't always attached to the same element it read the
1761
+ // font size from) wrote a spcPts value that doesn't correspond to this
1762
+ // paragraph's actual font size, so clamp rather than render it unreadable.
1763
+ const MIN_LINE_SPACING_RATIO = 0.8;
1764
+ const MAX_LINE_SPACING_RATIO = 3;
1765
+ // `a:spcPct` is a percentage of *single* line spacing, and single spacing in
1766
+ // PowerPoint and Google Slides is the font's own line height (ascent +
1767
+ // descent + line gap), not its em size — the same quantity CSS calls
1768
+ // `line-height: normal`. Treating `100%` as CSS `line-height: 1` therefore
1769
+ // shipped every body paragraph ~17% tighter than the source, which is what
1770
+ // five unrelated decks were independently reported for. CSS cannot scale
1771
+ // `normal`, so a constant stands in for it.
1772
+ // ponytail: one constant for every font; per-font ascent/descent metrics if a
1773
+ // specific deck's leading still reads off.
1774
+ const SINGLE_LINE_SPACING_RATIO = 1.2;
1775
+ // Must match html-converter's `DEFAULT_PPTX_FONT_SIZE_PT`: the ratio returned
1776
+ // here is divided out again against whatever font size that renderer puts on
1777
+ // the paragraph, so guessing a different default silently scales the line box.
1778
+ const DEFAULT_FONT_SIZE_PT = 18;
1779
+ function parseParagraphSpacing(value, fontSizePt) {
887
1780
  const node = record(value);
888
1781
  const percent = Number(record(node?.["a:spcPct"])?.["@_val"]);
889
- if (Number.isFinite(percent) && percent > 0)
890
- return percent / 100000;
891
- return parsePoints(node?.["a:spcPts"]);
1782
+ if (Number.isFinite(percent) && percent > 0) {
1783
+ return (percent / 100000) * SINGLE_LINE_SPACING_RATIO;
1784
+ }
1785
+ const points = parsePoints(node?.["a:spcPts"]);
1786
+ if (points === undefined)
1787
+ return undefined;
1788
+ const ratio = points / (fontSizePt && fontSizePt > 0 ? fontSizePt : DEFAULT_FONT_SIZE_PT);
1789
+ return Math.min(MAX_LINE_SPACING_RATIO, Math.max(MIN_LINE_SPACING_RATIO, ratio));
892
1790
  }
1791
+ /** `<a:spcBef>`/`<a:spcAft>` nest the value one level down (`<a:spcBef><a:spcPts val="1600"/></a:spcBef>`), while `<a:lnSpc>`'s caller unwraps `a:spcPts` itself — reading `@_val` off the outer node alone silently produced NaN for every paragraph spacing in every deck. */
893
1792
  function parsePoints(value) {
894
1793
  const node = record(value);
895
- const points = Number(node?.["@_val"]);
1794
+ const target = record(node?.["a:spcPts"]) ?? node;
1795
+ const points = Number(target?.["@_val"]);
896
1796
  return Number.isFinite(points) && points >= 0 ? points / 100 : undefined;
897
1797
  }
898
1798
  function mapAlignment(value) {
@@ -979,11 +1879,104 @@ function flattenElementText(elements) {
979
1879
  }
980
1880
  return output;
981
1881
  }
982
- function extractSlideBackgroundColor(value, context) {
1882
+ function extractSlideBackground(value, context) {
983
1883
  const root = record(value);
984
1884
  const cSld = record(record(root?.["p:sld"])?.["p:cSld"] ?? root?.["p:cSld"]);
985
- const bgPr = record(record(cSld?.["p:bg"])?.["p:bgPr"]);
986
- return parseColor(record(bgPr?.["a:solidFill"]), context);
1885
+ return parseBackgroundNode(cSld?.["p:bg"], context);
1886
+ }
1887
+ /** Resolves a `<p:bg>` into a CSS `background` value. Reading only `a:solidFill` left every gradient-backed deck rendering white — which, on a template whose text is white by design, is an entirely invisible slide. */
1888
+ function parseBackgroundNode(value, context) {
1889
+ const bg = record(value);
1890
+ if (!bg)
1891
+ return undefined;
1892
+ const bgPr = record(bg["p:bgPr"]);
1893
+ if (bgPr) {
1894
+ const solid = parseColor(record(bgPr["a:solidFill"]), context);
1895
+ if (solid)
1896
+ return solid;
1897
+ const gradient = parseGradientFill(record(bgPr["a:gradFill"]), context);
1898
+ if (gradient)
1899
+ return gradient;
1900
+ // `<a:pattFill>` is a two-color hatch we can't reproduce as a single CSS
1901
+ // value; its background color is still far closer than white.
1902
+ const pattern = record(bgPr["a:pattFill"]);
1903
+ if (pattern) {
1904
+ return (parseColor(record(pattern["a:bgClr"]), context) ??
1905
+ parseColor(record(pattern["a:fgClr"]), context));
1906
+ }
1907
+ return undefined;
1908
+ }
1909
+ // `<p:bgRef idx="1001"><a:schemeClr val="lt1"/></p:bgRef>` references the
1910
+ // theme's fill-style list; the referenced color is the whole of it for the
1911
+ // solid styles that idx 1001-1003 resolve to in practice.
1912
+ return parseColor(record(bg["p:bgRef"]), context);
1913
+ }
1914
+ /**
1915
+ * Converts an `<a:gradFill>` into a CSS gradient. Collapsing to the first
1916
+ * stop, as before, flattened four-stop brand gradients into one flat block.
1917
+ *
1918
+ * OOXML's `<a:lin ang>` is measured clockwise from the positive x-axis in
1919
+ * screen coordinates (y down); CSS measures clockwise from "up". The two
1920
+ * differ by exactly 90°.
1921
+ */
1922
+ function parseGradientFill(gradFill, context) {
1923
+ if (!gradFill)
1924
+ return undefined;
1925
+ const stops = asArray(record(gradFill["a:gsLst"])?.["a:gs"]).flatMap((rawStop) => {
1926
+ const stop = record(rawStop);
1927
+ const color = parseColor(stop, context);
1928
+ if (!color)
1929
+ return [];
1930
+ const position = Number(stop?.["@_pos"]);
1931
+ return [
1932
+ {
1933
+ color,
1934
+ position: Number.isFinite(position) ? position / 1000 : undefined,
1935
+ },
1936
+ ];
1937
+ });
1938
+ if (stops.length === 0)
1939
+ return undefined;
1940
+ if (stops.length === 1)
1941
+ return stops[0].color;
1942
+ const stopList = stops
1943
+ .map((stop) => stop.position === undefined
1944
+ ? stop.color
1945
+ : `${stop.color} ${roundTo(stop.position, 2)}%`)
1946
+ .join(", ");
1947
+ const path = record(gradFill["a:path"]);
1948
+ if (path) {
1949
+ const rect = record(path["a:fillToRect"]);
1950
+ const centerX = fillToRectCenter(rect, "@_l", "@_r");
1951
+ const centerY = fillToRectCenter(rect, "@_t", "@_b");
1952
+ return `radial-gradient(circle at ${centerX}% ${centerY}%, ${stopList})`;
1953
+ }
1954
+ const angle = Number(record(gradFill["a:lin"])?.["@_ang"]);
1955
+ const cssAngle = Number.isFinite(angle)
1956
+ ? (((angle / 60000 + 90) % 360) + 360) % 360
1957
+ : 180;
1958
+ return `linear-gradient(${roundTo(cssAngle, 2)}deg, ${stopList})`;
1959
+ }
1960
+ /** `<a:fillToRect>` gives inset percentages from each edge; the focus point is the center of the rect they collapse to. */
1961
+ function fillToRectCenter(rect, nearAttribute, farAttribute) {
1962
+ const near = percentAttribute(rect, nearAttribute) ?? 50;
1963
+ const far = percentAttribute(rect, farAttribute) ?? 50;
1964
+ return roundTo((near + (100 - far)) / 2, 2);
1965
+ }
1966
+ /** DrawingML writes these as either `"50%"` or the 1000ths-of-a-percent integer `50000`. */
1967
+ function percentAttribute(node, attribute) {
1968
+ const raw = node?.[attribute];
1969
+ if (raw === undefined || raw === null)
1970
+ return undefined;
1971
+ const text = String(raw).trim();
1972
+ const value = Number(text.replace(/%$/, ""));
1973
+ if (!Number.isFinite(value))
1974
+ return undefined;
1975
+ return text.endsWith("%") ? value : value / 1000;
1976
+ }
1977
+ function roundTo(value, digits) {
1978
+ const factor = 10 ** digits;
1979
+ return Math.round(value * factor) / factor;
987
1980
  }
988
1981
  export function parsePptxSlideMetadata(value) {
989
1982
  const slide = record(value)?.["p:sld"] ?? value;
@@ -993,8 +1986,30 @@ export function parsePptxSlideMetadata(value) {
993
1986
  ...(detectSplitByParagraph(slide) ? { splitByParagraph: true } : {}),
994
1987
  };
995
1988
  }
996
- async function parseTheme(zip, parseXml) {
997
- return parseThemeFromPath(zip, parseXml, "ppt/theme/theme1.xml");
1989
+ /**
1990
+ * The deck's exposed palette is the *slide* master's theme, not
1991
+ * `ppt/theme/theme1.xml`. In every Google Slides export theme1 belongs to the
1992
+ * notes master, so hardcoding that path persisted a stock scheme that appears
1993
+ * nowhere in the deck and poisoned every restyle, generated slide and export
1994
+ * that reads it.
1995
+ */
1996
+ async function parseTheme(zip, parseXml, slideMasterRelationship) {
1997
+ const masterPath = slideMasterRelationship
1998
+ ? resolvePptxRelationshipPath("ppt", slideMasterRelationship.target)
1999
+ : undefined;
2000
+ const masterRelsXml = masterPath
2001
+ ? await zip.file(relsPathForPptxPart(masterPath))?.async("string")
2002
+ : undefined;
2003
+ const themeTarget = masterRelsXml
2004
+ ? [...parseRelationships(parseXml(masterRelsXml)).values()].find((relationship) => relationship.type.endsWith("/theme"))?.target
2005
+ : undefined;
2006
+ const themePath = themeTarget
2007
+ ? resolvePptxRelationshipPath("ppt/slideMasters", themeTarget)
2008
+ : "ppt/theme/theme1.xml";
2009
+ const theme = await parseThemeFromPath(zip, parseXml, themePath);
2010
+ return theme.colors.length > 0
2011
+ ? theme
2012
+ : parseThemeFromPath(zip, parseXml, "ppt/theme/theme1.xml");
998
2013
  }
999
2014
  async function parseThemeFromPath(zip, parseXml, themePath) {
1000
2015
  const xml = await zip.file(themePath)?.async("string");
@@ -1120,10 +2135,15 @@ function extractBackgroundFillEmbedId(slide) {
1120
2135
  const blip = record(record(bgPr?.["a:blipFill"])?.["a:blip"]);
1121
2136
  return stringValue(blip?.["@_r:embed"]);
1122
2137
  }
1123
- function runProperties(value, inherited, context) {
2138
+ function runProperties(value, inherited, context, relationships) {
1124
2139
  if (!value)
1125
2140
  return inherited;
1126
2141
  const size = Number(value["@_sz"]);
2142
+ // `<a:hlinkClick r:id>` points at an external relationship whose Target is
2143
+ // the URL; without it, a deck's own "click here" instructions import as
2144
+ // styled but inert text.
2145
+ const linkId = stringValue(record(value["a:hlinkClick"])?.["@_r:id"]);
2146
+ const href = linkId ? relationships?.get(linkId)?.target : undefined;
1127
2147
  const color = parseColor(record(value["a:solidFill"]), context);
1128
2148
  const fontFamily = stringValue(record(value["a:latin"])?.["@_typeface"]) ??
1129
2149
  stringValue(record(value["a:ea"])?.["@_typeface"]) ??
@@ -1140,6 +2160,7 @@ function runProperties(value, inherited, context) {
1140
2160
  ...(color ? { color } : {}),
1141
2161
  ...(fontFamily ? { fontFamily } : {}),
1142
2162
  ...(value["@_u"] && value["@_u"] !== "none" ? { underline: true } : {}),
2163
+ ...(href ? { href } : {}),
1143
2164
  };
1144
2165
  }
1145
2166
  function parseRelationships(value) {
@@ -1194,16 +2215,47 @@ async function loadPptxDependencies() {
1194
2215
  ignoreAttributes: false,
1195
2216
  attributeNamePrefix: "@_",
1196
2217
  trimValues: false,
2218
+ // A numeric-looking `<a:t>` body is still text: fast-xml-parser's default
2219
+ // `parseTagValue: true` turns a type specimen "0123456789" into the
2220
+ // number 123456789 and a spec line "CMYK: 00, 00, 00, 00" loses its
2221
+ // leading zeros — content corruption, not a styling loss.
2222
+ parseTagValue: false,
1197
2223
  });
1198
2224
  return {
1199
2225
  loadZip: (data) => zipModule.default.loadAsync(data),
1200
- parseXml: (xml) => parser.parse(xml),
2226
+ parseXml: (xml) => parser.parse(annotatePathCommandOrder(normalizeHardLineBreaks(xml))),
1201
2227
  };
1202
2228
  }
1203
2229
  catch {
1204
2230
  throw new Error("Structured PPTX parsing requires the optional jszip and fast-xml-parser dependencies.");
1205
2231
  }
1206
2232
  }
2233
+ /**
2234
+ * `<a:br/>` is a hard line break sitting *between* `<a:r>` runs, but the
2235
+ * parser is not built with `preserveOrder`, so that interleaving is absent
2236
+ * from the parsed tree — an `a:br` array with no position in it is
2237
+ * unrecoverable. Rewriting each break into an ordinary run carrying a newline
2238
+ * keeps it in document order, which is the one thing a position-less tree
2239
+ * cannot reconstruct later. (A global `preserveOrder: true` would instead
2240
+ * rewrite every accessor in this file.)
2241
+ */
2242
+ function normalizeHardLineBreaks(xml) {
2243
+ return xml.replace(/<a:br(?:\s[^>]*?)?\/>|<a:br(?:\s[^>]*?)?>[\s\S]*?<\/a:br>/g, "<a:r><a:t>\n</a:t></a:r>");
2244
+ }
2245
+ /** Attribute `annotatePathCommandOrder` stamps on, as the parser exposes it. */
2246
+ const PATH_COMMAND_ORDER_ATTRIBUTE = "@_an-order";
2247
+ /**
2248
+ * An `<a:path>`'s children are a *sequence* — `moveTo`, `lnTo`, `cubicBezTo`,
2249
+ * `close` — and, exactly as with `<a:br/>` above, a tree built without
2250
+ * `preserveOrder` groups them by tag name and loses the one property a path
2251
+ * is made of. Stamping document order onto each command before parsing keeps
2252
+ * it recoverable; these six tag names appear nowhere else in the format, so
2253
+ * the global rewrite cannot touch anything but a path.
2254
+ */
2255
+ function annotatePathCommandOrder(xml) {
2256
+ let order = 0;
2257
+ return xml.replace(/<a:(?:moveTo|lnTo|cubicBezTo|quadBezTo|arcTo|close)\b/g, (match) => `${match} an-order="${order++}"`);
2258
+ }
1207
2259
  function slideNumber(value) {
1208
2260
  return Number(value.match(/slide(\d+)/)?.[1] ?? 0);
1209
2261
  }
@@ -1227,3 +2279,16 @@ function record(value) {
1227
2279
  function stringValue(value) {
1228
2280
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
1229
2281
  }
2282
+ function positiveAttributeNumber(value, attribute) {
2283
+ const parsed = Number(record(value)?.[attribute]);
2284
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
2285
+ }
2286
+ function sumOf(values) {
2287
+ if (!values || values.length === 0)
2288
+ return undefined;
2289
+ const total = values.reduce((sum, value) => sum + value, 0);
2290
+ return total > 0 ? total : undefined;
2291
+ }
2292
+ function xmlBoolean(value) {
2293
+ return value === true || value === 1 || /^(?:1|true)$/i.test(String(value));
2294
+ }