@agent-native/core 0.157.9 → 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 (107) hide show
  1. package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +5 -2
  2. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
  3. package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +18 -6
  4. package/corpus/templates/analytics/server/plugins/db.ts +139 -0
  5. package/corpus/templates/slides/AGENTS.md +14 -10
  6. package/corpus/templates/slides/actions/export-pptx.ts +1094 -94
  7. package/corpus/templates/slides/actions/import-file.ts +16 -1
  8. package/corpus/templates/slides/actions/import-pptx.ts +61 -43
  9. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
  10. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
  11. package/corpus/templates/slides/app/global.css +19 -1
  12. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
  13. package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
  14. package/corpus/templates/slides/app/lib/export-pptx-client.ts +556 -25
  15. package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
  16. package/corpus/templates/slides/server/handlers/import/html-converter.ts +1176 -26
  17. package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +13 -0
  18. package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
  19. package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
  20. package/dist/agent/harness/migrations.js +24 -3
  21. package/dist/agent/production-agent.js +11 -0
  22. package/dist/agent/run-loop-with-resume.js +21 -4
  23. package/dist/automations/service.d.ts +6 -0
  24. package/dist/automations/service.js +35 -0
  25. package/dist/cli/code-agent-connector.d.ts +4 -1
  26. package/dist/cli/code-agent-connector.js +154 -23
  27. package/dist/cli/code-agent-executor.js +255 -13
  28. package/dist/cli/code-agent-runs.d.ts +1 -0
  29. package/dist/cli/code-agent-runs.js +2 -1
  30. package/dist/cli/portal-workspace.d.ts +49 -0
  31. package/dist/cli/portal-workspace.js +320 -0
  32. package/dist/client/AssistantChat.d.ts +1 -1
  33. package/dist/client/AssistantChat.js +39 -61
  34. package/dist/client/ConnectBuilderCard.js +33 -5
  35. package/dist/client/agent-chat-adapter.js +4 -0
  36. package/dist/client/analytics-platform.d.ts +2 -0
  37. package/dist/client/analytics-platform.js +12 -0
  38. package/dist/client/analytics.d.ts +3 -0
  39. package/dist/client/analytics.js +10 -1
  40. package/dist/client/chat/index.d.ts +1 -0
  41. package/dist/client/chat/index.js +1 -0
  42. package/dist/client/chat/run-recovery.d.ts +2 -1
  43. package/dist/client/chat/run-recovery.js +2 -2
  44. package/dist/client/chat-first/apps-rail.d.ts +1 -1
  45. package/dist/client/chat-first/apps-rail.js +11 -11
  46. package/dist/client/chat-first/surface-tabs.d.ts +1 -1
  47. package/dist/client/chat-first/surface-tabs.js +38 -36
  48. package/dist/client/chat-first/types.d.ts +5 -0
  49. package/dist/client/chat-first.d.ts +1 -1
  50. package/dist/client/chat-first.js +4 -4
  51. package/dist/client/desktop-local-code-change.d.ts +10 -0
  52. package/dist/client/desktop-local-code-change.js +16 -0
  53. package/dist/client/index.d.ts +1 -0
  54. package/dist/client/index.js +1 -0
  55. package/dist/client/observability/ThumbsFeedback.js +21 -7
  56. package/dist/client/terminal/AgentTerminal.d.ts +9 -1
  57. package/dist/client/terminal/AgentTerminal.js +38 -3
  58. package/dist/client/terminal/index.d.ts +1 -1
  59. package/dist/client/terminal/index.js +1 -1
  60. package/dist/client/track.js +9 -1
  61. package/dist/client/use-action.js +3 -0
  62. package/dist/collab/struct-routes.d.ts +1 -1
  63. package/dist/deploy/build.js +1 -1
  64. package/dist/ingestion/pptx.d.ts +78 -0
  65. package/dist/ingestion/pptx.js +991 -107
  66. package/dist/integrations/index.d.ts +3 -3
  67. package/dist/integrations/index.js +2 -2
  68. package/dist/integrations/plugin.js +95 -6
  69. package/dist/integrations/remote-commands-store.d.ts +5 -0
  70. package/dist/integrations/remote-commands-store.js +63 -22
  71. package/dist/integrations/remote-devices-store.d.ts +2 -1
  72. package/dist/integrations/remote-devices-store.js +46 -0
  73. package/dist/integrations/remote-types.d.ts +20 -0
  74. package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
  75. package/dist/jobs/actions/list-recurring-jobs.js +5 -1
  76. package/dist/jobs/frontmatter.d.ts +16 -0
  77. package/dist/jobs/frontmatter.js +53 -0
  78. package/dist/jobs/remote-execution.d.ts +42 -0
  79. package/dist/jobs/remote-execution.js +287 -0
  80. package/dist/jobs/scheduler.js +102 -3
  81. package/dist/jobs/tools.js +50 -3
  82. package/dist/notifications/routes.d.ts +4 -4
  83. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  84. package/dist/provider-api/actions/provider-api.d.ts +14 -14
  85. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  86. package/dist/resources/handlers.d.ts +1 -1
  87. package/dist/server/action-routes.js +4 -2
  88. package/dist/server/agent-chat-plugin.js +6 -0
  89. package/dist/server/agent-run-context.d.ts +1 -0
  90. package/dist/server/agent-run-context.js +9 -0
  91. package/dist/server/core-routes-plugin.js +8 -1
  92. package/dist/server/request-context.d.ts +2 -0
  93. package/dist/shared/analytics-platform.d.ts +6 -0
  94. package/dist/shared/analytics-platform.js +14 -0
  95. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  96. package/dist/shared/mcp-embed-headers.js +1 -1
  97. package/dist/styles/agent-native.css +83 -103
  98. package/dist/terminal/cli-registry.js +5 -0
  99. package/dist/tracking/registry.js +9 -1
  100. package/dist/triggers/actions/list-automations.d.ts +3 -0
  101. package/dist/triggers/actions/list-automations.js +3 -0
  102. package/dist/triggers/actions/manage-automation.d.ts +9 -0
  103. package/dist/triggers/actions/manage-automation.js +14 -2
  104. package/dist/triggers/actions.js +82 -7
  105. package/docs/content/template-content-developers.mdx +48 -0
  106. package/docs/content/toolkit-ui.mdx +57 -0
  107. package/package.json +2 -2
@@ -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,46 +39,77 @@ 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);
@@ -98,9 +132,13 @@ export async function parsePptxPresentation(fileBuffer) {
98
132
  zip,
99
133
  parseXml,
100
134
  slideRelationships,
135
+ slideWidthEmu,
136
+ slideHeightEmu,
101
137
  themeCache,
102
138
  masterInfoCache,
103
- })) ?? { colorContext, placeholderDefaults };
139
+ templateContextCache,
140
+ })) ?? fallbackContext;
141
+ const number = slideNumber(slidePath);
104
142
  elements = await parseSlideElements({
105
143
  xml,
106
144
  parseXml,
@@ -113,9 +151,15 @@ export async function parsePptxPresentation(fileBuffer) {
113
151
  tablesDegraded,
114
152
  colorContext: slideMasterContext.colorContext,
115
153
  placeholderDefaults: slideMasterContext.placeholderDefaults,
154
+ slideNumber: slides.length + 1,
155
+ tableStyles,
116
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));
117
162
  const texts = flattenElementText(elements);
118
- const number = slideNumber(slidePath);
119
163
  const notesXml = await zip
120
164
  .file(`ppt/notesSlides/notesSlide${number}.xml`)
121
165
  ?.async("string");
@@ -136,7 +180,8 @@ export async function parsePptxPresentation(fileBuffer) {
136
180
  elements,
137
181
  widthEmu: slideWidthEmu,
138
182
  heightEmu: slideHeightEmu,
139
- backgroundColor: extractSlideBackgroundColor(slide, slideMasterContext.colorContext),
183
+ backgroundColor: extractSlideBackground(slide, slideMasterContext.colorContext) ??
184
+ slideMasterContext.background,
140
185
  ...(backgroundGrid ? { backgroundGrid } : {}),
141
186
  notes,
142
187
  layoutHint: guessLayoutHint(texts, images.length > 0),
@@ -146,12 +191,30 @@ export async function parsePptxPresentation(fileBuffer) {
146
191
  ...metadata,
147
192
  });
148
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
+ }
149
207
  const firstSlide = slides[0]?.texts ?? [];
150
208
  const title = [...firstSlide]
151
209
  .sort((a, b) => (b.fontSize ?? 0) - (a.fontSize ?? 0))[0]
152
210
  ?.content.trim()
153
211
  .slice(0, 200) || "Imported Presentation";
154
- return { title, slides, theme };
212
+ return {
213
+ title,
214
+ slides,
215
+ theme,
216
+ ...(hiddenSlideCount > 0 ? { hiddenSlideCount } : {}),
217
+ };
155
218
  }
156
219
  /**
157
220
  * Google Slides exports decorative grids as connector shapes on the slide
@@ -251,19 +314,21 @@ function relsPathForPptxPart(path) {
251
314
  const file = slashIndex >= 0 ? path.slice(slashIndex + 1) : path;
252
315
  return `${dir ? `${dir}/` : ""}_rels/${file}.rels`;
253
316
  }
254
- /**
255
- * Walks this slide's own `slideLayout` → `slideMaster` → `theme` relationship
256
- * chain so slides that belong to a different master than the deck's first
257
- * one (a presentation with more than one master) resolve `schemeClr`
258
- * aliases and placeholder defaults against their own palette instead of an
259
- * unrelated master's. Returns `undefined` when any hop in that chain is
260
- * missing, letting the caller fall back to the deck-wide default.
261
- */
262
317
  async function resolveSlideMasterContext(args) {
263
318
  const layoutRelationship = [...args.slideRelationships.values()].find((relationship) => relationship.type.endsWith("/slideLayout"));
264
319
  if (!layoutRelationship)
265
320
  return undefined;
266
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");
267
332
  const layoutRelsXml = await args.zip
268
333
  .file(relsPathForPptxPart(layoutPath))
269
334
  ?.async("string");
@@ -284,6 +349,12 @@ async function resolveSlideMasterContext(args) {
284
349
  args.masterInfoCache.set(masterPath, masterInfoPromise);
285
350
  }
286
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
+ : [];
287
358
  const masterRelsXml = await args.zip
288
359
  .file(relsPathForPptxPart(masterPath))
289
360
  ?.async("string");
@@ -304,21 +375,117 @@ async function resolveSlideMasterContext(args) {
304
375
  themeColorsByName: theme.colorsByName,
305
376
  clrMap: masterInfo.clrMap,
306
377
  };
307
- const resolveFillsByLevel = (fillsByLevel) => Object.fromEntries(Object.entries(fillsByLevel).map(([level, fill]) => [
308
- Number(level),
309
- parseColor(fill, colorContext),
310
- ]));
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
+ }
311
408
  return {
312
409
  colorContext,
410
+ ...(background ? { background } : {}),
411
+ layerElements,
412
+ layerImages,
313
413
  placeholderDefaults: {
314
- title: resolveFillsByLevel(masterInfo.titleFillByLevel),
315
- 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),
316
419
  },
317
420
  };
318
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
+ }
319
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. */
320
480
  async function parseMasterColorInfo(args) {
321
- const empty = { clrMap: {}, titleFillByLevel: {}, bodyFillByLevel: {} };
481
+ const empty = {
482
+ clrMap: {},
483
+ titleDefaultsByLevel: {},
484
+ bodyDefaultsByLevel: {},
485
+ otherDefaultsByLevel: {},
486
+ placeholderDefaults: [],
487
+ background: null,
488
+ };
322
489
  const path = args.target.startsWith("/")
323
490
  ? args.target.slice(1)
324
491
  : "ppt/" + args.target.replace(/^\.\.\//, "");
@@ -332,18 +499,54 @@ async function parseMasterColorInfo(args) {
332
499
  const txStyles = record(root["p:txStyles"]);
333
500
  return {
334
501
  clrMap,
335
- titleFillByLevel: levelFillsFromTextStyle(record(txStyles?.["p:titleStyle"])),
336
- 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,
337
508
  };
338
509
  }
339
- /** 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. */
340
- function levelFillsFromTextStyle(style) {
341
- 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 = {};
342
513
  for (let level = 0; level < 9; level++) {
343
- const defRPr = record(record(style?.[`a:lvl${level + 1}pPr`])?.["a:defRPr"]);
344
- 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
+ });
345
539
  }
346
- 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
+ }));
347
550
  }
348
551
  const IDENTITY_MAT = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
349
552
  /** Composes two transforms so `inner` is applied to a point first, then `outer`. */
@@ -436,7 +639,12 @@ async function parseShapeFragment(fragment, args) {
436
639
  const groupTransform = readTransform(node, "p:grpSpPr");
437
640
  const groupXfrm = record(record(node["p:grpSpPr"])?.["a:xfrm"]);
438
641
  const childOffset = readPoint(groupXfrm?.["a:chOff"]);
439
- 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"]);
440
648
  const groupScaleX = childExtent.x > 0 ? groupTransform.width / childExtent.x : 1;
441
649
  const groupScaleY = childExtent.y > 0 ? groupTransform.height / childExtent.y : 1;
442
650
  // Places this group's children into the parent's coordinate space,
@@ -468,39 +676,60 @@ async function parseShapeFragment(fragment, args) {
468
676
  return parseGraphicFrameFragment(node, args);
469
677
  }
470
678
  const localTransform = readTransform(node, "p:spPr");
679
+ const id = readShapeId(node);
680
+ const name = readShapeName(node);
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"]);
471
684
  // A placeholder shape (`<p:ph>`) commonly omits its own `<a:xfrm>` on the
472
685
  // slide, inheriting position/size from the matching placeholder on the
473
- // slide layout/master instead — that layout/master inheritance chain isn't
474
- // implemented, so without this fallback the shape silently lands at a
475
- // literal 0×0 box, making real title/body text invisible instead of just
476
- // mispositioned. This substitutes the slide's own content box so the text
477
- // is at least visible; it won't match the placeholder's real authored
478
- // position/size.
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.
479
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;
480
701
  const effectiveLocalTransform = entry === "sp" && !hasOwnSize
481
702
  ? {
482
703
  ...localTransform,
483
- x: 0,
484
- y: 0,
485
- width: args.slideWidthEmu ?? FALLBACK_SLIDE_WIDTH_EMU,
486
- height: args.slideHeightEmu ?? FALLBACK_SLIDE_HEIGHT_EMU,
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,
487
712
  }
488
713
  : localTransform;
489
714
  const transform = applyTransform(effectiveLocalTransform, args.context);
490
- const id = readShapeId(node);
491
- const name = readShapeName(node);
492
- const placeholderType = stringValue(record(record(record(node["p:nvSpPr"])?.["p:nvPr"])?.["p:ph"])?.["@_type"]);
493
715
  const shapeProperties = record(node["p:spPr"]);
494
- const rawText = parseTextBody(node, args.colorContext);
495
- const placeholderDefaultColor = placeholderType === "title" || placeholderType === "ctrTitle"
496
- ? args.placeholderDefaults?.title
497
- : placeholderType
498
- ? args.placeholderDefaults?.body
499
- : undefined;
500
- 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"]));
501
728
  const fill = parseShapeFill(shapeProperties, args.colorContext);
502
729
  const line = parseShapeLine(shapeProperties, args.colorContext);
503
730
  const shapeType = stringValue(record(shapeProperties?.["a:prstGeom"])?.["@_prst"]);
731
+ const shapeAdjustments = parseShapeAdjustments(shapeProperties);
732
+ const geometry = parseCustomGeometry(shapeProperties, effectiveLocalTransform);
504
733
  if (entry === "pic") {
505
734
  const embedId = stringValue(record(record(node["p:blipFill"])?.["a:blip"])?.["@_r:embed"]);
506
735
  if (!embedId)
@@ -528,6 +757,12 @@ async function parseShapeFragment(fragment, args) {
528
757
  name,
529
758
  kind: "image",
530
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 } : {}),
531
766
  image: image.image,
532
767
  },
533
768
  ];
@@ -542,8 +777,17 @@ async function parseShapeFragment(fragment, args) {
542
777
  kind: "text",
543
778
  ...transform,
544
779
  shapeType,
780
+ ...(shapeAdjustments ? { shapeAdjustments } : {}),
781
+ ...(geometry ? { geometry } : {}),
545
782
  ...(fill ? { fill } : {}),
546
- ...(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
+ : {}),
547
791
  ...parseTextBoxProperties(node),
548
792
  paragraphs: text,
549
793
  },
@@ -557,8 +801,17 @@ async function parseShapeFragment(fragment, args) {
557
801
  kind: "shape",
558
802
  ...transform,
559
803
  shapeType,
804
+ ...(shapeAdjustments ? { shapeAdjustments } : {}),
805
+ ...(geometry ? { geometry } : {}),
560
806
  ...(fill ? { fill } : {}),
561
- ...(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
+ : {}),
562
815
  },
563
816
  ];
564
817
  }
@@ -573,12 +826,27 @@ async function parseShapeFragment(fragment, args) {
573
826
  * vanishing with no signal at all.
574
827
  */
575
828
  function parseGraphicFrameFragment(node, args) {
576
- const transform = applyTransform(transformFromXfrmNode(record(node["p:xfrm"])), args.context);
577
- const table = parseGraphicFrameTable(node, args.colorContext);
829
+ const table = parseGraphicFrameTable(node, args.colorContext, {
830
+ relationships: args.slideRelationships,
831
+ slideNumber: args.slideNumber,
832
+ }, args.tableStyles);
578
833
  if (!table) {
579
834
  args.tablesDegraded.count += 1;
580
835
  return [];
581
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);
582
850
  return [
583
851
  {
584
852
  id: readShapeId(node),
@@ -590,37 +858,197 @@ function parseGraphicFrameFragment(node, args) {
590
858
  ];
591
859
  }
592
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. */
593
- function parseGraphicFrameTable(node, context) {
861
+ function parseGraphicFrameTable(node, context, text, tableStyles) {
594
862
  const graphicData = record(record(node["a:graphic"])?.["a:graphicData"]);
595
863
  const tbl = record(graphicData?.["a:tbl"]);
596
864
  if (!tbl)
597
865
  return undefined;
598
- const rows = asArray(tbl["a:tr"]).map((rawRow) => {
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) => {
599
886
  const row = record(rawRow);
600
887
  const cells = [];
601
- for (const rawCell of asArray(row?.["a:tc"])) {
888
+ for (const [columnIndex, rawCell] of asArray(row?.["a:tc"]).entries()) {
602
889
  const cell = record(rawCell);
603
890
  if (!cell)
604
891
  continue;
605
892
  // A merge-continuation cell's content is already represented once, by
606
893
  // the spanning cell's gridSpan/rowSpan below.
607
- if (cell["@_hMerge"] === "1" || cell["@_vMerge"] === "1")
894
+ if (xmlBoolean(cell["@_hMerge"]) || xmlBoolean(cell["@_vMerge"]))
608
895
  continue;
609
896
  const gridSpan = Number(cell["@_gridSpan"]);
610
897
  const rowSpan = Number(cell["@_rowSpan"]);
611
- const fill = parseShapeFill(record(cell["a:tcPr"]), context);
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
+ });
612
915
  cells.push({
613
- paragraphs: parseTextBodyParagraphs(record(cell["a:txBody"]), context),
916
+ paragraphs: parseTextBodyParagraphs(record(cell["a:txBody"]), context, text),
614
917
  ...(Number.isFinite(gridSpan) && gridSpan > 1
615
918
  ? { colSpan: gridSpan }
616
919
  : {}),
617
920
  ...(Number.isFinite(rowSpan) && rowSpan > 1 ? { rowSpan } : {}),
618
921
  ...(fill ? { fill } : {}),
922
+ ...(borders ? { borders } : {}),
619
923
  });
620
924
  }
621
925
  return cells;
622
926
  });
623
- return rows.length > 0 ? { rows } : undefined;
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
+ };
624
1052
  }
625
1053
  function extractDirectShapeFragments(xml, container) {
626
1054
  const containerMatch = new RegExp(`<p:${container}\\b[^>]*>`, "i").exec(xml);
@@ -679,17 +1107,20 @@ function findMatchingXmlTag(xml, start, localName) {
679
1107
  }
680
1108
  return -1;
681
1109
  }
682
- function readShapeId(node) {
683
- const cNvPr = record(record(node["p:nvSpPr"])?.["p:cNvPr"]) ??
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"]) ??
684
1113
  record(record(node["p:nvPicPr"])?.["p:cNvPr"]) ??
685
- record(record(node["p:nvGraphicFramePr"])?.["p:cNvPr"]);
686
- return (stringValue(cNvPr?.["@_id"]) ??
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
+ }
1118
+ function readShapeId(node) {
1119
+ return (stringValue(readNonVisualProperties(node)?.["@_id"]) ??
687
1120
  `shape-${Math.random().toString(36).slice(2)}`);
688
1121
  }
689
1122
  function readShapeName(node) {
690
- const cNvPr = record(record(node["p:nvSpPr"])?.["p:cNvPr"]) ??
691
- record(record(node["p:nvGraphicFramePr"])?.["p:cNvPr"]);
692
- return stringValue(cNvPr?.["@_name"]);
1123
+ return stringValue(readNonVisualProperties(node)?.["@_name"]);
693
1124
  }
694
1125
  function readPoint(value) {
695
1126
  const point = record(value);
@@ -718,6 +1149,12 @@ function transformFromXfrmNode(xfrm) {
718
1149
  y: off.y,
719
1150
  width: ext.x,
720
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
+ : {}),
721
1158
  ...(Number.isFinite(rawRotation) && rawRotation !== 0
722
1159
  ? { rotation: rawRotation / 60000 }
723
1160
  : {}),
@@ -739,18 +1176,40 @@ function applyTransform(transform, context) {
739
1176
  y: center.y - height / 2,
740
1177
  width,
741
1178
  height,
1179
+ ...(transform.flipH ? { flipH: true } : {}),
1180
+ ...(transform.flipV ? { flipV: true } : {}),
742
1181
  ...(rotation ? { rotation } : {}),
743
1182
  };
744
1183
  }
745
- function parseTextBody(node, context) {
746
- return parseTextBodyParagraphs(record(node["p:txBody"]), context);
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);
747
1206
  }
748
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. */
749
1208
  function formatAutoNumBullet(n) {
750
1209
  return `${n}.`;
751
1210
  }
752
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). */
753
- function parseTextBodyParagraphs(txBody, context) {
1212
+ function parseTextBodyParagraphs(txBody, context, text) {
754
1213
  if (!txBody)
755
1214
  return [];
756
1215
  // `a:buAutoNum` carries no explicit number — PowerPoint derives it from
@@ -768,25 +1227,40 @@ function parseTextBodyParagraphs(txBody, context) {
768
1227
  if (content) {
769
1228
  runs.push({
770
1229
  content,
771
- ...runProperties(record(run?.["a:rPr"]), {}, context),
1230
+ ...runProperties(record(run?.["a:rPr"]), {}, context, text?.relationships),
772
1231
  });
773
1232
  }
774
1233
  }
775
1234
  for (const rawField of asArray(paragraph?.["a:fld"])) {
776
1235
  const field = record(rawField);
777
- 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"]);
778
1242
  if (content) {
779
1243
  runs.push({
780
1244
  content,
781
- ...runProperties(record(field?.["a:rPr"]), {}, context),
1245
+ ...runProperties(record(field?.["a:rPr"]), {}, context, text?.relationships),
782
1246
  });
783
1247
  }
784
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
+ }
785
1259
  const bullet = record(pPr?.["a:buChar"]);
786
1260
  const bulletColor = parseColor(record(pPr?.["a:buClr"]), context);
787
1261
  const bulletFont = stringValue(record(pPr?.["a:buFont"])?.["@_typeface"]);
788
1262
  const bulletSize = Number(record(pPr?.["a:buSzPts"])?.["@_val"]);
789
- const lineSpacing = parseParagraphSpacing(pPr?.["a:lnSpc"]);
1263
+ const lineSpacing = parseParagraphSpacing(pPr?.["a:lnSpc"], runs[0]?.fontSize);
790
1264
  const spaceBeforePt = parsePoints(pPr?.["a:spcBef"]);
791
1265
  const spaceAfterPt = parsePoints(pPr?.["a:spcAft"]);
792
1266
  const alignment = mapAlignment(stringValue(pPr?.["@_algn"]));
@@ -831,9 +1305,42 @@ function parseTextBodyParagraphs(txBody, context) {
831
1305
  ...(lineSpacing !== undefined ? { lineSpacing } : {}),
832
1306
  ...(spaceBeforePt !== undefined ? { spaceBeforePt } : {}),
833
1307
  ...(spaceAfterPt !== undefined ? { spaceAfterPt } : {}),
1308
+ ...(pPr?.["@_rtl"] !== undefined && xmlBoolean(pPr["@_rtl"])
1309
+ ? { rtl: true }
1310
+ : {}),
834
1311
  };
835
1312
  });
836
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
+ }
837
1344
  function parseTextBoxProperties(node) {
838
1345
  const bodyPr = record(record(node["p:txBody"])?.["a:bodyPr"]);
839
1346
  if (!bodyPr)
@@ -853,6 +1360,111 @@ function parseTextBoxProperties(node) {
853
1360
  : { verticalAlign: "top" }),
854
1361
  };
855
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
+ }
856
1468
  function parseShapeFill(shapeProperties, context) {
857
1469
  if (!shapeProperties)
858
1470
  return undefined;
@@ -861,11 +1473,7 @@ function parseShapeFill(shapeProperties, context) {
861
1473
  const solid = parseColor(record(shapeProperties["a:solidFill"]), context);
862
1474
  if (solid)
863
1475
  return solid;
864
- // A gradient fill (`<a:gradFill>`) has no `a:solidFill` at all — approximate
865
- // it with its first stop's resolved color rather than leaving the shape
866
- // unfilled, which silently turned every gradient-filled shape transparent.
867
- const stops = asArray(record(record(shapeProperties["a:gradFill"])?.["a:gsLst"])?.["a:gs"]);
868
- return parseColor(record(stops[0]), context);
1476
+ return parseGradientFill(record(shapeProperties["a:gradFill"]), context);
869
1477
  }
870
1478
  function parseShapeLine(shapeProperties, context) {
871
1479
  const line = record(shapeProperties?.["a:ln"]);
@@ -875,23 +1483,95 @@ function parseShapeLine(shapeProperties, context) {
875
1483
  if (!color)
876
1484
  return undefined;
877
1485
  const width = Number(line["@_w"]);
1486
+ const headEnd = parseLineEnd(line["a:headEnd"]);
1487
+ const tailEnd = parseLineEnd(line["a:tailEnd"]);
878
1488
  return {
879
1489
  color,
880
1490
  ...(Number.isFinite(width) && width > 0 ? { width } : {}),
1491
+ ...(headEnd ? { headEnd } : {}),
1492
+ ...(tailEnd ? { tailEnd } : {}),
881
1493
  };
882
1494
  }
883
- /** 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). */
884
- function applyPlaceholderDefaultColor(paragraphs, colorsByLevel) {
885
- 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)
886
1565
  return paragraphs;
887
1566
  return paragraphs.map((paragraph) => {
888
1567
  const level = paragraph.level ?? 0;
889
- const color = colorsByLevel[level] ?? colorsByLevel[0];
890
- 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)
891
1571
  return paragraph;
892
1572
  return {
893
1573
  ...paragraph,
894
- runs: paragraph.runs.map((run) => (run.color ? run : { ...run, color })),
1574
+ runs: paragraph.runs.map((run) => ({ ...defaults, ...run })),
895
1575
  };
896
1576
  });
897
1577
  }
@@ -1064,16 +1744,55 @@ function parseColor(value, context) {
1064
1744
  ? applyColorTransforms(fallbackColor, transforms)
1065
1745
  : undefined;
1066
1746
  }
1067
- 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) {
1068
1780
  const node = record(value);
1069
1781
  const percent = Number(record(node?.["a:spcPct"])?.["@_val"]);
1070
- if (Number.isFinite(percent) && percent > 0)
1071
- return percent / 100000;
1072
- 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));
1073
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. */
1074
1792
  function parsePoints(value) {
1075
1793
  const node = record(value);
1076
- const points = Number(node?.["@_val"]);
1794
+ const target = record(node?.["a:spcPts"]) ?? node;
1795
+ const points = Number(target?.["@_val"]);
1077
1796
  return Number.isFinite(points) && points >= 0 ? points / 100 : undefined;
1078
1797
  }
1079
1798
  function mapAlignment(value) {
@@ -1160,11 +1879,104 @@ function flattenElementText(elements) {
1160
1879
  }
1161
1880
  return output;
1162
1881
  }
1163
- function extractSlideBackgroundColor(value, context) {
1882
+ function extractSlideBackground(value, context) {
1164
1883
  const root = record(value);
1165
1884
  const cSld = record(record(root?.["p:sld"])?.["p:cSld"] ?? root?.["p:cSld"]);
1166
- const bgPr = record(record(cSld?.["p:bg"])?.["p:bgPr"]);
1167
- 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;
1168
1980
  }
1169
1981
  export function parsePptxSlideMetadata(value) {
1170
1982
  const slide = record(value)?.["p:sld"] ?? value;
@@ -1174,8 +1986,30 @@ export function parsePptxSlideMetadata(value) {
1174
1986
  ...(detectSplitByParagraph(slide) ? { splitByParagraph: true } : {}),
1175
1987
  };
1176
1988
  }
1177
- async function parseTheme(zip, parseXml) {
1178
- 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");
1179
2013
  }
1180
2014
  async function parseThemeFromPath(zip, parseXml, themePath) {
1181
2015
  const xml = await zip.file(themePath)?.async("string");
@@ -1301,10 +2135,15 @@ function extractBackgroundFillEmbedId(slide) {
1301
2135
  const blip = record(record(bgPr?.["a:blipFill"])?.["a:blip"]);
1302
2136
  return stringValue(blip?.["@_r:embed"]);
1303
2137
  }
1304
- function runProperties(value, inherited, context) {
2138
+ function runProperties(value, inherited, context, relationships) {
1305
2139
  if (!value)
1306
2140
  return inherited;
1307
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;
1308
2147
  const color = parseColor(record(value["a:solidFill"]), context);
1309
2148
  const fontFamily = stringValue(record(value["a:latin"])?.["@_typeface"]) ??
1310
2149
  stringValue(record(value["a:ea"])?.["@_typeface"]) ??
@@ -1321,6 +2160,7 @@ function runProperties(value, inherited, context) {
1321
2160
  ...(color ? { color } : {}),
1322
2161
  ...(fontFamily ? { fontFamily } : {}),
1323
2162
  ...(value["@_u"] && value["@_u"] !== "none" ? { underline: true } : {}),
2163
+ ...(href ? { href } : {}),
1324
2164
  };
1325
2165
  }
1326
2166
  function parseRelationships(value) {
@@ -1375,16 +2215,47 @@ async function loadPptxDependencies() {
1375
2215
  ignoreAttributes: false,
1376
2216
  attributeNamePrefix: "@_",
1377
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,
1378
2223
  });
1379
2224
  return {
1380
2225
  loadZip: (data) => zipModule.default.loadAsync(data),
1381
- parseXml: (xml) => parser.parse(xml),
2226
+ parseXml: (xml) => parser.parse(annotatePathCommandOrder(normalizeHardLineBreaks(xml))),
1382
2227
  };
1383
2228
  }
1384
2229
  catch {
1385
2230
  throw new Error("Structured PPTX parsing requires the optional jszip and fast-xml-parser dependencies.");
1386
2231
  }
1387
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
+ }
1388
2259
  function slideNumber(value) {
1389
2260
  return Number(value.match(/slide(\d+)/)?.[1] ?? 0);
1390
2261
  }
@@ -1408,3 +2279,16 @@ function record(value) {
1408
2279
  function stringValue(value) {
1409
2280
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
1410
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
+ }