@agent-native/core 0.157.9 → 0.157.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/templates/analytics/scripts/first-party-analytics-postgres-retention-dry-run.sql +5 -2
- package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +3 -1
- package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +18 -6
- package/corpus/templates/analytics/server/plugins/db.ts +139 -0
- package/corpus/templates/slides/AGENTS.md +14 -10
- package/corpus/templates/slides/actions/export-pptx.ts +1215 -94
- package/corpus/templates/slides/actions/import-file.ts +16 -1
- package/corpus/templates/slides/actions/import-pptx.ts +61 -43
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +222 -29
- package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +71 -0
- package/corpus/templates/slides/app/global.css +19 -1
- package/corpus/templates/slides/app/lib/export-google-slides-client.ts +44 -5
- package/corpus/templates/slides/app/lib/export-pdf-client.ts +13 -6
- package/corpus/templates/slides/app/lib/export-pptx-client.ts +556 -25
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +18 -1
- package/corpus/templates/slides/server/handlers/import/html-converter.ts +1176 -26
- package/corpus/templates/slides/server/handlers/import/pdf-fidelity-parser.ts +13 -0
- package/corpus/templates/slides/server/handlers/import/pptx-assets.ts +24 -0
- package/corpus/templates/slides/server/routes/api/exports/google-slides.post.ts +12 -4
- package/corpus/templates/slides/shared/aspect-ratios.ts +13 -1
- package/dist/agent/harness/migrations.js +24 -3
- package/dist/agent/production-agent.js +11 -0
- package/dist/agent/run-loop-with-resume.js +21 -4
- package/dist/automations/service.d.ts +6 -0
- package/dist/automations/service.js +35 -0
- package/dist/cli/code-agent-connector.d.ts +4 -1
- package/dist/cli/code-agent-connector.js +154 -23
- package/dist/cli/code-agent-executor.js +255 -13
- package/dist/cli/code-agent-runs.d.ts +1 -0
- package/dist/cli/code-agent-runs.js +2 -1
- package/dist/cli/portal-workspace.d.ts +49 -0
- package/dist/cli/portal-workspace.js +320 -0
- package/dist/client/AssistantChat.d.ts +1 -1
- package/dist/client/AssistantChat.js +39 -61
- package/dist/client/ConnectBuilderCard.js +33 -5
- package/dist/client/agent-chat-adapter.js +4 -0
- package/dist/client/analytics-platform.d.ts +2 -0
- package/dist/client/analytics-platform.js +12 -0
- package/dist/client/analytics.d.ts +3 -0
- package/dist/client/analytics.js +10 -1
- package/dist/client/chat/index.d.ts +1 -0
- package/dist/client/chat/index.js +1 -0
- package/dist/client/chat/run-recovery.d.ts +2 -1
- package/dist/client/chat/run-recovery.js +2 -2
- package/dist/client/chat-first/apps-rail.d.ts +1 -1
- package/dist/client/chat-first/apps-rail.js +11 -11
- package/dist/client/chat-first/surface-tabs.d.ts +1 -1
- package/dist/client/chat-first/surface-tabs.js +38 -36
- package/dist/client/chat-first/types.d.ts +5 -0
- package/dist/client/chat-first.d.ts +1 -1
- package/dist/client/chat-first.js +4 -4
- package/dist/client/desktop-local-code-change.d.ts +10 -0
- package/dist/client/desktop-local-code-change.js +16 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/observability/ThumbsFeedback.js +21 -7
- package/dist/client/terminal/AgentTerminal.d.ts +9 -1
- package/dist/client/terminal/AgentTerminal.js +38 -3
- package/dist/client/terminal/index.d.ts +1 -1
- package/dist/client/terminal/index.js +1 -1
- package/dist/client/track.js +9 -1
- package/dist/client/use-action.js +3 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/client.js +1 -0
- package/dist/deploy/build.js +1 -1
- package/dist/ingestion/pptx.d.ts +78 -0
- package/dist/ingestion/pptx.js +991 -107
- package/dist/integrations/index.d.ts +3 -3
- package/dist/integrations/index.js +2 -2
- package/dist/integrations/plugin.js +95 -6
- package/dist/integrations/remote-commands-store.d.ts +5 -0
- package/dist/integrations/remote-commands-store.js +63 -22
- package/dist/integrations/remote-devices-store.d.ts +2 -1
- package/dist/integrations/remote-devices-store.js +46 -0
- package/dist/integrations/remote-types.d.ts +20 -0
- package/dist/jobs/actions/list-recurring-jobs.d.ts +3 -0
- package/dist/jobs/actions/list-recurring-jobs.js +5 -1
- package/dist/jobs/frontmatter.d.ts +16 -0
- package/dist/jobs/frontmatter.js +53 -0
- package/dist/jobs/remote-execution.d.ts +42 -0
- package/dist/jobs/remote-execution.js +287 -0
- package/dist/jobs/scheduler.js +102 -3
- package/dist/jobs/tools.js +50 -3
- package/dist/notifications/routes.d.ts +4 -4
- package/dist/observability/routes.d.ts +5 -5
- package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
- package/dist/provider-api/actions/provider-api.d.ts +14 -14
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/action-routes.js +4 -2
- package/dist/server/agent-chat-plugin.js +6 -0
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-run-context.d.ts +1 -0
- package/dist/server/agent-run-context.js +9 -0
- package/dist/server/core-routes-plugin.js +8 -1
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/analytics-platform.d.ts +6 -0
- package/dist/shared/analytics-platform.js +14 -0
- package/dist/shared/mcp-embed-headers.d.ts +1 -1
- package/dist/shared/mcp-embed-headers.js +1 -1
- package/dist/styles/agent-native.css +83 -103
- package/dist/terminal/cli-registry.js +5 -0
- package/dist/tracking/registry.js +9 -1
- package/dist/triggers/actions/list-automations.d.ts +3 -0
- package/dist/triggers/actions/list-automations.js +3 -0
- package/dist/triggers/actions/manage-automation.d.ts +9 -0
- package/dist/triggers/actions/manage-automation.js +14 -2
- package/dist/triggers/actions.js +82 -7
- package/docs/content/template-content-developers.mdx +48 -0
- package/docs/content/toolkit-ui.mdx +57 -0
- package/package.json +2 -2
package/dist/ingestion/pptx.js
CHANGED
|
@@ -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
|
-
: {
|
|
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:
|
|
65
|
-
body:
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
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:
|
|
315
|
-
body:
|
|
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 = {
|
|
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
|
-
|
|
336
|
-
|
|
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`)
|
|
340
|
-
function
|
|
341
|
-
const
|
|
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
|
-
|
|
344
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
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:
|
|
486
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
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
|
|
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
|
|
577
|
-
|
|
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
|
|
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"]
|
|
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
|
|
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
|
|
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
|
-
|
|
683
|
-
|
|
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:
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
|
890
|
-
|
|
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) => (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
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
|
+
}
|