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