@textcortex/slidewise 1.0.0
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/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/__vite-browser-external-DYxpcVy9.js +5 -0
- package/dist/__vite-browser-external-DYxpcVy9.js.map +1 -0
- package/dist/file.svg +1 -0
- package/dist/globe.svg +1 -0
- package/dist/index.mjs +16697 -0
- package/dist/index.mjs.map +1 -0
- package/dist/slidewise.css +1 -0
- package/dist/types/SlidewiseEditor.d.ts +47 -0
- package/dist/types/SlidewiseFileEditor.d.ts +54 -0
- package/dist/types/components/editor/BottomToolbar.d.ts +1 -0
- package/dist/types/components/editor/Canvas.d.ts +1 -0
- package/dist/types/components/editor/Editor.d.ts +8 -0
- package/dist/types/components/editor/ElementView.d.ts +6 -0
- package/dist/types/components/editor/FloatingToolbar.d.ts +6 -0
- package/dist/types/components/editor/GridView.d.ts +1 -0
- package/dist/types/components/editor/PlayMode.d.ts +1 -0
- package/dist/types/components/editor/SelectionFrame.d.ts +8 -0
- package/dist/types/components/editor/SlideRail.d.ts +1 -0
- package/dist/types/components/editor/SlideView.d.ts +5 -0
- package/dist/types/components/editor/TopBar.d.ts +7 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/lib/StoreProvider.d.ts +8 -0
- package/dist/types/lib/fonts.d.ts +9 -0
- package/dist/types/lib/pptx/deckToPptx.d.ts +9 -0
- package/dist/types/lib/pptx/index.d.ts +3 -0
- package/dist/types/lib/pptx/pptxToDeck.d.ts +18 -0
- package/dist/types/lib/pptx/types.d.ts +15 -0
- package/dist/types/lib/pptx/units.d.ts +25 -0
- package/dist/types/lib/schema/migrate.d.ts +25 -0
- package/dist/types/lib/seed.d.ts +2 -0
- package/dist/types/lib/store.d.ts +55 -0
- package/dist/types/lib/types.d.ts +141 -0
- package/dist/window.svg +1 -0
- package/package.json +86 -0
- package/src/App.tsx +261 -0
- package/src/SlidewiseEditor.css +146 -0
- package/src/SlidewiseEditor.tsx +214 -0
- package/src/SlidewiseFileEditor.tsx +242 -0
- package/src/components/editor/BottomToolbar.tsx +216 -0
- package/src/components/editor/Canvas.tsx +467 -0
- package/src/components/editor/Editor.tsx +53 -0
- package/src/components/editor/ElementView.tsx +588 -0
- package/src/components/editor/FloatingToolbar.tsx +729 -0
- package/src/components/editor/GridView.tsx +232 -0
- package/src/components/editor/PlayMode.tsx +260 -0
- package/src/components/editor/SelectionFrame.tsx +241 -0
- package/src/components/editor/SlideRail.tsx +285 -0
- package/src/components/editor/SlideView.tsx +55 -0
- package/src/components/editor/TopBar.tsx +240 -0
- package/src/fonts.css +2 -0
- package/src/index.css +13 -0
- package/src/index.ts +36 -0
- package/src/lib/StoreProvider.tsx +43 -0
- package/src/lib/__tests__/css-scope.test.ts +133 -0
- package/src/lib/fonts.ts +104 -0
- package/src/lib/pptx/__tests__/roundtrip.test.ts +240 -0
- package/src/lib/pptx/deckToPptx.ts +300 -0
- package/src/lib/pptx/index.ts +3 -0
- package/src/lib/pptx/pptxToDeck.ts +1515 -0
- package/src/lib/pptx/types.ts +17 -0
- package/src/lib/pptx/units.ts +32 -0
- package/src/lib/schema/__tests__/migrate.test.ts +70 -0
- package/src/lib/schema/migrate.ts +102 -0
- package/src/lib/seed.ts +777 -0
- package/src/lib/store.ts +384 -0
- package/src/lib/types.ts +185 -0
- package/src/main.tsx +10 -0
- package/src/vite-env.d.ts +3 -0
|
@@ -0,0 +1,1515 @@
|
|
|
1
|
+
import JSZip from "jszip";
|
|
2
|
+
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
|
3
|
+
import { nanoid } from "nanoid";
|
|
4
|
+
import type {
|
|
5
|
+
Deck,
|
|
6
|
+
Slide,
|
|
7
|
+
SlideElement,
|
|
8
|
+
TextElement,
|
|
9
|
+
TextRun,
|
|
10
|
+
ShapeElement,
|
|
11
|
+
ShapeKind,
|
|
12
|
+
ImageElement,
|
|
13
|
+
LineElement,
|
|
14
|
+
TableElement,
|
|
15
|
+
UnknownElement,
|
|
16
|
+
} from "@/lib/types";
|
|
17
|
+
import { SLIDE_W, SLIDE_H } from "@/lib/types";
|
|
18
|
+
import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate";
|
|
19
|
+
import { emuToPx, pointsToPx } from "./units";
|
|
20
|
+
import type { ParseDiagnostics } from "./types";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Linear transform from raw source-PPTX pixels (EMU/EMU_PER_PX) into
|
|
24
|
+
* Slidewise's fixed 1920×1080 canvas. We pick a uniform scale that fits the
|
|
25
|
+
* source slide entirely, then center it — preserves aspect, letterboxes when
|
|
26
|
+
* source is 4:3 and target is 16:9.
|
|
27
|
+
*/
|
|
28
|
+
interface Fit {
|
|
29
|
+
scale: number;
|
|
30
|
+
offsetX: number;
|
|
31
|
+
offsetY: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Rels {
|
|
35
|
+
byId: Map<string, { target: string; type: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ThemeColors {
|
|
39
|
+
// Theme color scheme. Keys match OOXML schemeClr @val tokens.
|
|
40
|
+
dk1: string;
|
|
41
|
+
lt1: string;
|
|
42
|
+
dk2: string;
|
|
43
|
+
lt2: string;
|
|
44
|
+
accent1: string;
|
|
45
|
+
accent2: string;
|
|
46
|
+
accent3: string;
|
|
47
|
+
accent4: string;
|
|
48
|
+
accent5: string;
|
|
49
|
+
accent6: string;
|
|
50
|
+
hlink: string;
|
|
51
|
+
folHlink: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PlaceholderInfo {
|
|
55
|
+
/** Geometry from layout/master in raw px (pre-fit). */
|
|
56
|
+
rawX?: number;
|
|
57
|
+
rawY?: number;
|
|
58
|
+
rawW?: number;
|
|
59
|
+
rawH?: number;
|
|
60
|
+
rotation?: number;
|
|
61
|
+
/** Default text style inherited when slide-level rPr is absent. */
|
|
62
|
+
rPr?: any;
|
|
63
|
+
pPr?: any;
|
|
64
|
+
bodyPr?: any;
|
|
65
|
+
/** Fallback paragraphs (used when the slide placeholder has no text). */
|
|
66
|
+
paragraphs?: any[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface MasterTextDefaults {
|
|
70
|
+
title?: any; // a:lvl1pPr (and friends) — we only use lvl1.
|
|
71
|
+
body?: any;
|
|
72
|
+
other?: any;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ParseContext {
|
|
76
|
+
diagnostics: ParseDiagnostics;
|
|
77
|
+
zip: JSZip;
|
|
78
|
+
slidePath: string;
|
|
79
|
+
slideRels: Rels;
|
|
80
|
+
fit: Fit;
|
|
81
|
+
theme: ThemeColors;
|
|
82
|
+
layoutPh: Map<string, PlaceholderInfo>;
|
|
83
|
+
masterPh: Map<string, PlaceholderInfo>;
|
|
84
|
+
masterTextDefaults: MasterTextDefaults;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const xmlParser = new XMLParser({
|
|
88
|
+
ignoreAttributes: false,
|
|
89
|
+
attributeNamePrefix: "@_",
|
|
90
|
+
parseTagValue: false,
|
|
91
|
+
parseAttributeValue: false,
|
|
92
|
+
trimValues: false,
|
|
93
|
+
preserveOrder: false,
|
|
94
|
+
isArray: (name) => ARRAY_TAGS.has(name),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const xmlBuilder = new XMLBuilder({
|
|
98
|
+
ignoreAttributes: false,
|
|
99
|
+
attributeNamePrefix: "@_",
|
|
100
|
+
format: false,
|
|
101
|
+
suppressEmptyNode: true,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Tags that should always be arrays even when only one occurs.
|
|
105
|
+
const ARRAY_TAGS = new Set([
|
|
106
|
+
"p:sp",
|
|
107
|
+
"p:pic",
|
|
108
|
+
"p:cxnSp",
|
|
109
|
+
"p:graphicFrame",
|
|
110
|
+
"p:grpSp",
|
|
111
|
+
"a:p",
|
|
112
|
+
"a:r",
|
|
113
|
+
"a:tr",
|
|
114
|
+
"a:tc",
|
|
115
|
+
"Relationship",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
const DEFAULT_THEME: ThemeColors = {
|
|
119
|
+
dk1: "#000000",
|
|
120
|
+
lt1: "#FFFFFF",
|
|
121
|
+
dk2: "#1F497D",
|
|
122
|
+
lt2: "#EEECE1",
|
|
123
|
+
accent1: "#4F81BD",
|
|
124
|
+
accent2: "#C0504D",
|
|
125
|
+
accent3: "#9BBB59",
|
|
126
|
+
accent4: "#8064A2",
|
|
127
|
+
accent5: "#4BACC6",
|
|
128
|
+
accent6: "#F79646",
|
|
129
|
+
hlink: "#0000FF",
|
|
130
|
+
folHlink: "#800080",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse a PPTX blob into a Slidewise Deck. Coverage:
|
|
135
|
+
* - Slide background (solid + theme color)
|
|
136
|
+
* - Text boxes with placeholder inheritance from layout/master, theme-color
|
|
137
|
+
* resolution, multi-run formatting, paragraph alignment, lineHeight
|
|
138
|
+
* - Preset shapes (rect, roundRect, ellipse, triangle, diamond, star — and
|
|
139
|
+
* many other prsts mapped to the closest available kind so they at least
|
|
140
|
+
* render with correct fill/position)
|
|
141
|
+
* - Images (embedded media → data URLs, srcRect crop preserved)
|
|
142
|
+
* - Connector lines (cxnSp) and shapes authored as prst="line"
|
|
143
|
+
* - Tables (basic row/cell content + header/body fills)
|
|
144
|
+
* - Group shapes (recursed and flattened with the group transform applied)
|
|
145
|
+
* - Anything else (charts, SmartArt, embedded video) is preserved as
|
|
146
|
+
* UnknownElement carrying its raw OOXML so a save round-trip can re-emit
|
|
147
|
+
* it without data loss.
|
|
148
|
+
*/
|
|
149
|
+
export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
|
|
150
|
+
const zip = await JSZip.loadAsync(blob);
|
|
151
|
+
const diagnostics: ParseDiagnostics = {
|
|
152
|
+
unknownElementCount: 0,
|
|
153
|
+
droppedAnimations: 0,
|
|
154
|
+
warnings: [],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const presentationXml = await readXml(zip, "ppt/presentation.xml");
|
|
158
|
+
const presentationRels = await readRels(zip, "ppt/_rels/presentation.xml.rels");
|
|
159
|
+
|
|
160
|
+
const fit = computeFit(presentationXml);
|
|
161
|
+
|
|
162
|
+
const slideIdList = asArray<{ "@_r:id": string }>(
|
|
163
|
+
presentationXml?.["p:presentation"]?.["p:sldIdLst"]?.["p:sldId"]
|
|
164
|
+
);
|
|
165
|
+
const slidePaths = slideIdList
|
|
166
|
+
.map((entry) => presentationRels.byId.get(entry["@_r:id"])?.target)
|
|
167
|
+
.filter((p): p is string => Boolean(p))
|
|
168
|
+
.map((p) => normalisePath(p, "ppt"));
|
|
169
|
+
|
|
170
|
+
const title = await readTitle(zip);
|
|
171
|
+
|
|
172
|
+
const slides: Slide[] = [];
|
|
173
|
+
for (const slidePath of slidePaths) {
|
|
174
|
+
const slide = await parseSlide(zip, slidePath, diagnostics, fit);
|
|
175
|
+
if (slide) slides.push(slide);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!slides.length) {
|
|
179
|
+
slides.push({ id: nanoid(8), background: "#FFFFFF", elements: [] });
|
|
180
|
+
diagnostics.warnings.push("PPTX contained no slides; created an empty one.");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const deck: Deck = { version: CURRENT_DECK_VERSION, title, slides };
|
|
184
|
+
if (diagnostics.warnings.length) {
|
|
185
|
+
console.info("[slidewise/pptx] parse diagnostics:", diagnostics);
|
|
186
|
+
}
|
|
187
|
+
return deck;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function parseSlide(
|
|
191
|
+
zip: JSZip,
|
|
192
|
+
slidePath: string,
|
|
193
|
+
diagnostics: ParseDiagnostics,
|
|
194
|
+
fit: Fit
|
|
195
|
+
): Promise<Slide | null> {
|
|
196
|
+
const xml = await readXml(zip, slidePath);
|
|
197
|
+
if (!xml) return null;
|
|
198
|
+
const slideRelsPath = relsPathFor(slidePath);
|
|
199
|
+
const slideRels = await readRels(zip, slideRelsPath);
|
|
200
|
+
|
|
201
|
+
// Walk the rels chain: slide → slideLayout → slideMaster → theme.
|
|
202
|
+
const layoutTarget = firstByType(slideRels, "slideLayout");
|
|
203
|
+
const layoutPath = layoutTarget
|
|
204
|
+
? normalisePath(layoutTarget, dirOf(slidePath))
|
|
205
|
+
: null;
|
|
206
|
+
const layoutXml = layoutPath ? await readXml(zip, layoutPath) : null;
|
|
207
|
+
const layoutRels = layoutPath
|
|
208
|
+
? await readRels(zip, relsPathFor(layoutPath))
|
|
209
|
+
: { byId: new Map() };
|
|
210
|
+
|
|
211
|
+
const masterTarget = firstByType(layoutRels, "slideMaster");
|
|
212
|
+
const masterPath =
|
|
213
|
+
layoutPath && masterTarget
|
|
214
|
+
? normalisePath(masterTarget, dirOf(layoutPath))
|
|
215
|
+
: null;
|
|
216
|
+
const masterXml = masterPath ? await readXml(zip, masterPath) : null;
|
|
217
|
+
const masterRels = masterPath
|
|
218
|
+
? await readRels(zip, relsPathFor(masterPath))
|
|
219
|
+
: { byId: new Map() };
|
|
220
|
+
|
|
221
|
+
const themeTarget = firstByType(masterRels, "theme");
|
|
222
|
+
const themePath =
|
|
223
|
+
masterPath && themeTarget
|
|
224
|
+
? normalisePath(themeTarget, dirOf(masterPath))
|
|
225
|
+
: null;
|
|
226
|
+
const themeXml = themePath ? await readXml(zip, themePath) : null;
|
|
227
|
+
const theme = themeXml ? extractTheme(themeXml) : DEFAULT_THEME;
|
|
228
|
+
|
|
229
|
+
const layoutPh = layoutXml ? extractPlaceholders(layoutXml) : new Map();
|
|
230
|
+
const masterPh = masterXml ? extractPlaceholders(masterXml) : new Map();
|
|
231
|
+
const masterTextDefaults: MasterTextDefaults = masterXml
|
|
232
|
+
? extractMasterTextDefaults(masterXml)
|
|
233
|
+
: {};
|
|
234
|
+
|
|
235
|
+
const ctx: ParseContext = {
|
|
236
|
+
diagnostics,
|
|
237
|
+
zip,
|
|
238
|
+
slidePath,
|
|
239
|
+
slideRels,
|
|
240
|
+
fit,
|
|
241
|
+
theme,
|
|
242
|
+
layoutPh,
|
|
243
|
+
masterPh,
|
|
244
|
+
masterTextDefaults,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const sld = xml["p:sld"];
|
|
248
|
+
const cSld = sld?.["p:cSld"];
|
|
249
|
+
const slideBg = extractBackgroundColor(cSld?.["p:bg"], theme);
|
|
250
|
+
const layoutBg = layoutXml
|
|
251
|
+
? extractBackgroundColor(
|
|
252
|
+
layoutXml?.["p:sldLayout"]?.["p:cSld"]?.["p:bg"],
|
|
253
|
+
theme
|
|
254
|
+
)
|
|
255
|
+
: undefined;
|
|
256
|
+
const masterBg = masterXml
|
|
257
|
+
? extractBackgroundColor(
|
|
258
|
+
masterXml?.["p:sldMaster"]?.["p:cSld"]?.["p:bg"],
|
|
259
|
+
theme
|
|
260
|
+
)
|
|
261
|
+
: undefined;
|
|
262
|
+
const background = slideBg ?? layoutBg ?? masterBg ?? "#FFFFFF";
|
|
263
|
+
|
|
264
|
+
const spTree = cSld?.["p:spTree"];
|
|
265
|
+
const elements: SlideElement[] = [];
|
|
266
|
+
|
|
267
|
+
if (spTree) {
|
|
268
|
+
const collected = await parseSpTree(spTree, ctx, identityTransform());
|
|
269
|
+
let z = 1;
|
|
270
|
+
for (const el of collected) {
|
|
271
|
+
elements.push({ ...el, z: z++ });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
id: nanoid(8),
|
|
277
|
+
background,
|
|
278
|
+
elements,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface GroupTransform {
|
|
283
|
+
/** Linear transform for child raw-px coordinates: x' = a*x + c, y' = b*y + d. */
|
|
284
|
+
a: number;
|
|
285
|
+
b: number;
|
|
286
|
+
c: number;
|
|
287
|
+
d: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function identityTransform(): GroupTransform {
|
|
291
|
+
return { a: 1, b: 1, c: 0, d: 0 };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function parseSpTree(
|
|
295
|
+
spTree: any,
|
|
296
|
+
ctx: ParseContext,
|
|
297
|
+
outer: GroupTransform
|
|
298
|
+
): Promise<SlideElement[]> {
|
|
299
|
+
const out: SlideElement[] = [];
|
|
300
|
+
for (const sp of asArray(spTree["p:sp"])) {
|
|
301
|
+
const el = await parseSpOrText(sp, ctx, outer);
|
|
302
|
+
if (el) out.push(el);
|
|
303
|
+
}
|
|
304
|
+
for (const pic of asArray(spTree["p:pic"])) {
|
|
305
|
+
const el = await parsePic(pic, ctx, outer);
|
|
306
|
+
if (el) out.push(el);
|
|
307
|
+
}
|
|
308
|
+
for (const cxn of asArray(spTree["p:cxnSp"])) {
|
|
309
|
+
const el = parseCxn(cxn, ctx, outer);
|
|
310
|
+
if (el) out.push(el);
|
|
311
|
+
}
|
|
312
|
+
for (const gf of asArray(spTree["p:graphicFrame"])) {
|
|
313
|
+
const el = parseGraphicFrame(gf, ctx, outer);
|
|
314
|
+
if (el) out.push(el);
|
|
315
|
+
}
|
|
316
|
+
for (const grp of asArray(spTree["p:grpSp"])) {
|
|
317
|
+
const inner = composeGroupTransform(grp, outer);
|
|
318
|
+
const children = await parseSpTree(grp, ctx, inner);
|
|
319
|
+
out.push(...children);
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Compose the group transform. PPTX groups carry both an outer xfrm
|
|
326
|
+
* (off/ext, where the group sits on the slide) and chOff/chExt (the
|
|
327
|
+
* coordinate system its children author in). Mapping a child raw-px point
|
|
328
|
+
* (cx, cy) onto the slide is:
|
|
329
|
+
* x = (cx - chOffX) * (extX / chExtX) + offX
|
|
330
|
+
* y = (cy - chOffY) * (extY / chExtY) + offY
|
|
331
|
+
* Then the outer group's own transform is applied on top.
|
|
332
|
+
*/
|
|
333
|
+
function composeGroupTransform(grp: any, outer: GroupTransform): GroupTransform {
|
|
334
|
+
const xfrm = grp?.["p:grpSpPr"]?.["a:xfrm"];
|
|
335
|
+
if (!xfrm) return outer;
|
|
336
|
+
const off = xfrm["a:off"];
|
|
337
|
+
const ext = xfrm["a:ext"];
|
|
338
|
+
const chOff = xfrm["a:chOff"];
|
|
339
|
+
const chExt = xfrm["a:chExt"];
|
|
340
|
+
if (!off || !ext || !chOff || !chExt) return outer;
|
|
341
|
+
const offX = emuToPx(Number(off["@_x"] ?? 0));
|
|
342
|
+
const offY = emuToPx(Number(off["@_y"] ?? 0));
|
|
343
|
+
const extX = emuToPx(Number(ext["@_cx"] ?? 0)) || 1;
|
|
344
|
+
const extY = emuToPx(Number(ext["@_cy"] ?? 0)) || 1;
|
|
345
|
+
const cOffX = emuToPx(Number(chOff["@_x"] ?? 0));
|
|
346
|
+
const cOffY = emuToPx(Number(chOff["@_y"] ?? 0));
|
|
347
|
+
const cExtX = emuToPx(Number(chExt["@_cx"] ?? 0)) || extX;
|
|
348
|
+
const cExtY = emuToPx(Number(chExt["@_cy"] ?? 0)) || extY;
|
|
349
|
+
const ax = extX / cExtX;
|
|
350
|
+
const by = extY / cExtY;
|
|
351
|
+
const cx0 = offX - cOffX * ax;
|
|
352
|
+
const dy0 = offY - cOffY * by;
|
|
353
|
+
// Compose with outer: outer maps (x,y) -> (a*x+c, b*y+d). After local: (ax*x+cx0, by*y+dy0).
|
|
354
|
+
// Combined: outer(local(x,y)) = (a*(ax*x+cx0)+c, b*(by*y+dy0)+d)
|
|
355
|
+
return {
|
|
356
|
+
a: outer.a * ax,
|
|
357
|
+
b: outer.b * by,
|
|
358
|
+
c: outer.a * cx0 + outer.c,
|
|
359
|
+
d: outer.b * dy0 + outer.d,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// shape / text
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
async function parseSpOrText(
|
|
368
|
+
sp: any,
|
|
369
|
+
ctx: ParseContext,
|
|
370
|
+
outer: GroupTransform
|
|
371
|
+
): Promise<SlideElement | null> {
|
|
372
|
+
const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
|
|
373
|
+
const phKey = ph ? placeholderKey(ph) : null;
|
|
374
|
+
const layoutPh = phKey ? lookupPlaceholder(ctx.layoutPh, ph!) : undefined;
|
|
375
|
+
const masterPh = phKey ? lookupPlaceholder(ctx.masterPh, ph!) : undefined;
|
|
376
|
+
|
|
377
|
+
const xfrm = sp?.["p:spPr"]?.["a:xfrm"];
|
|
378
|
+
const geom = readGeometry(xfrm, ctx.fit, outer)
|
|
379
|
+
?? placeholderGeometry(layoutPh, ctx.fit, outer)
|
|
380
|
+
?? placeholderGeometry(masterPh, ctx.fit, outer);
|
|
381
|
+
|
|
382
|
+
if (!geom) {
|
|
383
|
+
return toUnknown(sp, "p:sp", ctx, outer);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const txBody = sp["p:txBody"];
|
|
387
|
+
const prstGeom = sp?.["p:spPr"]?.["a:prstGeom"];
|
|
388
|
+
const presetName = prstGeom?.["@_prst"];
|
|
389
|
+
|
|
390
|
+
// Lines are sometimes authored as <p:sp prst="line">.
|
|
391
|
+
if (presetName === "line" || presetName === "straightConnector1") {
|
|
392
|
+
const flipV = xfrm?.["@_flipV"] === "1";
|
|
393
|
+
return makeLineFromGeometry(
|
|
394
|
+
geom,
|
|
395
|
+
sp?.["p:spPr"]?.["a:ln"],
|
|
396
|
+
ctx,
|
|
397
|
+
flipV
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const phType = ph?.["@_type"];
|
|
402
|
+
const isPlaceholderTextHost = !!ph && phType !== "pic";
|
|
403
|
+
const hasText = !!txBody && hasAnyText(txBody);
|
|
404
|
+
const isText =
|
|
405
|
+
hasText ||
|
|
406
|
+
(isPlaceholderTextHost && !presetName) ||
|
|
407
|
+
(!!txBody && (!presetName || presetName === "rect"));
|
|
408
|
+
|
|
409
|
+
if (isText) {
|
|
410
|
+
return makeTextElement(sp, txBody, geom, ctx, ph, layoutPh, masterPh);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Fill / stroke. Use placeholder-inherited spPr if slide spPr is empty.
|
|
414
|
+
const spPr = sp?.["p:spPr"];
|
|
415
|
+
const fillColor = extractShapeFill(spPr, ctx.theme) ?? "transparent";
|
|
416
|
+
const lineProps = spPr?.["a:ln"];
|
|
417
|
+
const lineHasNoFill = lineProps?.["a:noFill"] !== undefined;
|
|
418
|
+
const stroke = lineHasNoFill
|
|
419
|
+
? undefined
|
|
420
|
+
: resolveColor(lineProps?.["a:solidFill"], ctx.theme);
|
|
421
|
+
const strokeWidthEmu =
|
|
422
|
+
!lineHasNoFill && lineProps?.["@_w"]
|
|
423
|
+
? Number(lineProps["@_w"])
|
|
424
|
+
: undefined;
|
|
425
|
+
|
|
426
|
+
const kind = mapPrstToKind(presetName);
|
|
427
|
+
if (!kind) {
|
|
428
|
+
// Fall back to a rect with the shape's fill so it remains visible at the
|
|
429
|
+
// correct position rather than dropping to an opaque "Imported content"
|
|
430
|
+
// tile.
|
|
431
|
+
const fallback: ShapeElement = {
|
|
432
|
+
id: nanoid(8),
|
|
433
|
+
type: "shape",
|
|
434
|
+
...geom,
|
|
435
|
+
z: 0,
|
|
436
|
+
shape: "rect",
|
|
437
|
+
fill: fillColor === "transparent" ? "rgba(0,0,0,0)" : fillColor,
|
|
438
|
+
stroke,
|
|
439
|
+
strokeWidth: strokeWidthEmu
|
|
440
|
+
? Math.max(1, Math.round(emuToPx(strokeWidthEmu) * ctx.fit.scale))
|
|
441
|
+
: undefined,
|
|
442
|
+
};
|
|
443
|
+
return fallback;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// PPTX `roundRect` carries the corner radius via <a:avLst><a:gd name="adj"
|
|
447
|
+
// fmla="val N"/></a:avLst>; N is in 1/100000ths of the shorter side.
|
|
448
|
+
let radius: number | undefined;
|
|
449
|
+
if (presetName === "roundRect") {
|
|
450
|
+
const adj = asArray(prstGeom?.["a:avLst"]?.["a:gd"]).find(
|
|
451
|
+
(g: any) => g?.["@_name"] === "adj"
|
|
452
|
+
);
|
|
453
|
+
const fmla: string | undefined = adj?.["@_fmla"];
|
|
454
|
+
const m = typeof fmla === "string" ? /val\s+(-?\d+)/.exec(fmla) : null;
|
|
455
|
+
const frac = m ? Number(m[1]) / 100000 : 0.16667;
|
|
456
|
+
radius = Math.round(Math.min(geom.w, geom.h) * frac);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const shape: ShapeElement = {
|
|
460
|
+
id: nanoid(8),
|
|
461
|
+
type: "shape",
|
|
462
|
+
...geom,
|
|
463
|
+
z: 0,
|
|
464
|
+
shape: kind,
|
|
465
|
+
fill: fillColor,
|
|
466
|
+
stroke,
|
|
467
|
+
strokeWidth: strokeWidthEmu
|
|
468
|
+
? Math.max(1, Math.round(emuToPx(strokeWidthEmu) * ctx.fit.scale))
|
|
469
|
+
: undefined,
|
|
470
|
+
radius,
|
|
471
|
+
};
|
|
472
|
+
return shape;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function makeTextElement(
|
|
476
|
+
_sp: any,
|
|
477
|
+
txBody: any,
|
|
478
|
+
geom: { x: number; y: number; w: number; h: number; rotation: number },
|
|
479
|
+
ctx: ParseContext,
|
|
480
|
+
ph: any,
|
|
481
|
+
layoutPh: PlaceholderInfo | undefined,
|
|
482
|
+
masterPh: PlaceholderInfo | undefined
|
|
483
|
+
): TextElement {
|
|
484
|
+
// Try the slide's own txBody first; if it has no actual runs, fall back to
|
|
485
|
+
// the layout/master placeholder's stub text so titles like "Click to edit
|
|
486
|
+
// title" don't render but real layout-supplied titles do.
|
|
487
|
+
const hasRealText = txBody && hasAnyText(txBody);
|
|
488
|
+
const effectiveTxBody = hasRealText
|
|
489
|
+
? txBody
|
|
490
|
+
: layoutPh?.paragraphs
|
|
491
|
+
? { "a:bodyPr": layoutPh.bodyPr, "a:p": layoutPh.paragraphs }
|
|
492
|
+
: masterPh?.paragraphs
|
|
493
|
+
? { "a:bodyPr": masterPh.bodyPr, "a:p": masterPh.paragraphs }
|
|
494
|
+
: txBody;
|
|
495
|
+
|
|
496
|
+
// Master defaults for the placeholder type (title vs body vs other).
|
|
497
|
+
const phType = ph?.["@_type"];
|
|
498
|
+
const masterDef =
|
|
499
|
+
phType === "title" || phType === "ctrTitle"
|
|
500
|
+
? ctx.masterTextDefaults.title
|
|
501
|
+
: phType === "body" || phType === "subTitle"
|
|
502
|
+
? ctx.masterTextDefaults.body
|
|
503
|
+
: ctx.masterTextDefaults.other;
|
|
504
|
+
|
|
505
|
+
// Accumulate inheritance: slide < layout < master < masterDefaults.
|
|
506
|
+
const fallbackRPr = mergeFirst(
|
|
507
|
+
layoutPh?.rPr,
|
|
508
|
+
masterPh?.rPr,
|
|
509
|
+
masterDef?.["a:defRPr"]
|
|
510
|
+
);
|
|
511
|
+
const fallbackPPr = mergeFirst(
|
|
512
|
+
layoutPh?.pPr,
|
|
513
|
+
masterPh?.pPr,
|
|
514
|
+
masterDef
|
|
515
|
+
);
|
|
516
|
+
const fallbackBodyPr = mergeFirst(layoutPh?.bodyPr, masterPh?.bodyPr);
|
|
517
|
+
|
|
518
|
+
const text = extractRuns(effectiveTxBody, ctx.theme, fallbackRPr, fallbackPPr);
|
|
519
|
+
const first = text.runs[0];
|
|
520
|
+
const align = text.align ?? readAlign(fallbackPPr) ?? "left";
|
|
521
|
+
const valign =
|
|
522
|
+
readBodyVAlign(effectiveTxBody?.["a:bodyPr"]) ??
|
|
523
|
+
readBodyVAlign(fallbackBodyPr) ??
|
|
524
|
+
"top";
|
|
525
|
+
|
|
526
|
+
const scale = ctx.fit.scale;
|
|
527
|
+
const fontSize = first?.fontSize
|
|
528
|
+
? Math.max(6, Math.round(first.fontSize * scale))
|
|
529
|
+
: Math.round(defaultFontSizePx(phType, ctx) * scale);
|
|
530
|
+
const fontFamily =
|
|
531
|
+
first?.fontFamily ??
|
|
532
|
+
fallbackRPr?.["a:latin"]?.["@_typeface"] ??
|
|
533
|
+
"Inter";
|
|
534
|
+
const fontWeight = first?.bold ? 700 : 400;
|
|
535
|
+
const color =
|
|
536
|
+
first?.color ??
|
|
537
|
+
resolveColor(fallbackRPr?.["a:solidFill"], ctx.theme) ??
|
|
538
|
+
"#0E1330";
|
|
539
|
+
|
|
540
|
+
const runs: TextRun[] = text.runs.map((r) => ({
|
|
541
|
+
text: r.text,
|
|
542
|
+
fontFamily: r.fontFamily,
|
|
543
|
+
fontSize: r.fontSize ? Math.max(6, Math.round(r.fontSize * scale)) : undefined,
|
|
544
|
+
fontWeight: r.bold ? 700 : r.bold === false ? 400 : undefined,
|
|
545
|
+
italic: r.italic,
|
|
546
|
+
underline: r.underline,
|
|
547
|
+
strike: r.strike,
|
|
548
|
+
color: r.color,
|
|
549
|
+
letterSpacing: r.letterSpacing
|
|
550
|
+
? Math.round(r.letterSpacing * scale)
|
|
551
|
+
: undefined,
|
|
552
|
+
}));
|
|
553
|
+
const hasMixedFormatting = runs.length > 1 && runs.some((r, i) => {
|
|
554
|
+
if (i === 0) return false;
|
|
555
|
+
const a = runs[0];
|
|
556
|
+
return (
|
|
557
|
+
a.color !== r.color ||
|
|
558
|
+
a.fontFamily !== r.fontFamily ||
|
|
559
|
+
a.fontSize !== r.fontSize ||
|
|
560
|
+
a.fontWeight !== r.fontWeight ||
|
|
561
|
+
a.italic !== r.italic ||
|
|
562
|
+
a.underline !== r.underline ||
|
|
563
|
+
a.strike !== r.strike
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const el: TextElement = {
|
|
568
|
+
id: nanoid(8),
|
|
569
|
+
type: "text",
|
|
570
|
+
...geom,
|
|
571
|
+
z: 0,
|
|
572
|
+
text: text.plain,
|
|
573
|
+
fontFamily,
|
|
574
|
+
fontSize,
|
|
575
|
+
fontWeight,
|
|
576
|
+
italic: !!first?.italic,
|
|
577
|
+
underline: !!first?.underline,
|
|
578
|
+
strike: !!first?.strike,
|
|
579
|
+
color,
|
|
580
|
+
align,
|
|
581
|
+
vAlign: valign,
|
|
582
|
+
lineHeight: text.lineHeightPct ?? 1.2,
|
|
583
|
+
letterSpacing: first?.letterSpacing
|
|
584
|
+
? Math.round(first.letterSpacing * scale)
|
|
585
|
+
: 0,
|
|
586
|
+
...(hasMixedFormatting ? { runs } : {}),
|
|
587
|
+
};
|
|
588
|
+
return el;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function defaultFontSizePx(phType: string | undefined, _ctx: ParseContext): number {
|
|
592
|
+
// Slidewise pixels (will be scaled by fit.scale by caller).
|
|
593
|
+
if (phType === "title" || phType === "ctrTitle") return pointsToPx(44);
|
|
594
|
+
if (phType === "body" || phType === "subTitle") return pointsToPx(24);
|
|
595
|
+
return pointsToPx(18);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function parsePic(
|
|
599
|
+
pic: any,
|
|
600
|
+
ctx: ParseContext,
|
|
601
|
+
outer: GroupTransform
|
|
602
|
+
): Promise<SlideElement | null> {
|
|
603
|
+
const xfrm = pic?.["p:spPr"]?.["a:xfrm"];
|
|
604
|
+
const geom = readGeometry(xfrm, ctx.fit, outer);
|
|
605
|
+
if (!geom) return toUnknown(pic, "p:pic", ctx, outer);
|
|
606
|
+
|
|
607
|
+
const blipRef = pic?.["p:blipFill"]?.["a:blip"]?.["@_r:embed"];
|
|
608
|
+
if (!blipRef) return toUnknown(pic, "p:pic", ctx, outer);
|
|
609
|
+
|
|
610
|
+
const mediaPath = ctx.slideRels.byId.get(blipRef)?.target;
|
|
611
|
+
if (!mediaPath) return toUnknown(pic, "p:pic", ctx, outer);
|
|
612
|
+
|
|
613
|
+
const fullPath = normalisePath(mediaPath, dirOf(ctx.slidePath));
|
|
614
|
+
const file = ctx.zip.file(fullPath);
|
|
615
|
+
if (!file) return toUnknown(pic, "p:pic", ctx, outer);
|
|
616
|
+
|
|
617
|
+
const base64 = await file.async("base64");
|
|
618
|
+
const ext = (fullPath.split(".").pop() || "png").toLowerCase();
|
|
619
|
+
const mime = mimeForExt(ext);
|
|
620
|
+
|
|
621
|
+
const blipFill = pic?.["p:blipFill"];
|
|
622
|
+
const hasStretch = !!blipFill?.["a:stretch"];
|
|
623
|
+
const fitMode: ImageElement["fit"] = hasStretch ? "fill" : "cover";
|
|
624
|
+
|
|
625
|
+
const sr = blipFill?.["a:srcRect"];
|
|
626
|
+
const crop = sr
|
|
627
|
+
? {
|
|
628
|
+
l: Number(sr["@_l"] ?? 0) / 100000,
|
|
629
|
+
r: Number(sr["@_r"] ?? 0) / 100000,
|
|
630
|
+
t: Number(sr["@_t"] ?? 0) / 100000,
|
|
631
|
+
b: Number(sr["@_b"] ?? 0) / 100000,
|
|
632
|
+
}
|
|
633
|
+
: undefined;
|
|
634
|
+
const hasCrop =
|
|
635
|
+
crop && (crop.l > 0 || crop.r > 0 || crop.t > 0 || crop.b > 0);
|
|
636
|
+
|
|
637
|
+
const image: ImageElement = {
|
|
638
|
+
id: nanoid(8),
|
|
639
|
+
type: "image",
|
|
640
|
+
...geom,
|
|
641
|
+
z: 0,
|
|
642
|
+
src: `data:${mime};base64,${base64}`,
|
|
643
|
+
fit: fitMode,
|
|
644
|
+
...(hasCrop ? { crop } : {}),
|
|
645
|
+
};
|
|
646
|
+
return image;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function parseCxn(
|
|
650
|
+
cxn: any,
|
|
651
|
+
ctx: ParseContext,
|
|
652
|
+
outer: GroupTransform
|
|
653
|
+
): SlideElement | null {
|
|
654
|
+
const xfrm = cxn?.["p:spPr"]?.["a:xfrm"];
|
|
655
|
+
const geom = readGeometry(xfrm, ctx.fit, outer);
|
|
656
|
+
if (!geom) return null;
|
|
657
|
+
const flipV = xfrm?.["@_flipV"] === "1";
|
|
658
|
+
return makeLineFromGeometry(geom, cxn?.["p:spPr"]?.["a:ln"], ctx, flipV);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function makeLineFromGeometry(
|
|
662
|
+
geom: { x: number; y: number; w: number; h: number; rotation: number },
|
|
663
|
+
lineProps: any,
|
|
664
|
+
ctx: ParseContext,
|
|
665
|
+
flipV: boolean
|
|
666
|
+
): LineElement {
|
|
667
|
+
const stroke = resolveColor(lineProps?.["a:solidFill"], ctx.theme) ?? "#0E1330";
|
|
668
|
+
const strokeWidth = lineProps?.["@_w"]
|
|
669
|
+
? Math.max(1, Math.round(emuToPx(Number(lineProps["@_w"])) * ctx.fit.scale))
|
|
670
|
+
: 4;
|
|
671
|
+
const dashVal = lineProps?.["a:prstDash"]?.["@_val"];
|
|
672
|
+
// <a:prstDash val="solid"/> is a valid explicit solid declaration. Only the
|
|
673
|
+
// patterned values are actually dashed; everything else (including absent
|
|
674
|
+
// and "solid") renders as a normal line.
|
|
675
|
+
const dashed =
|
|
676
|
+
typeof dashVal === "string" &&
|
|
677
|
+
dashVal !== "solid" &&
|
|
678
|
+
dashVal.length > 0;
|
|
679
|
+
// <a:headEnd type="none"/> is valid PPTX for an explicit "no arrowhead".
|
|
680
|
+
// Only mark as arrow when the type is one of the actual arrowhead presets.
|
|
681
|
+
const headType = lineProps?.["a:headEnd"]?.["@_type"];
|
|
682
|
+
const tailType = lineProps?.["a:tailEnd"]?.["@_type"];
|
|
683
|
+
const isArrowType = (t: unknown) =>
|
|
684
|
+
typeof t === "string" && t.length > 0 && t !== "none";
|
|
685
|
+
const arrow = isArrowType(headType) || isArrowType(tailType);
|
|
686
|
+
const rawH = flipV ? -geom.h : geom.h;
|
|
687
|
+
const w = geom.w === 0 ? 1 : geom.w;
|
|
688
|
+
const h = Math.abs(rawH) === 0 ? 1 : rawH;
|
|
689
|
+
const line: LineElement = {
|
|
690
|
+
id: nanoid(8),
|
|
691
|
+
type: "line",
|
|
692
|
+
x: geom.x,
|
|
693
|
+
y: geom.y,
|
|
694
|
+
w,
|
|
695
|
+
h,
|
|
696
|
+
rotation: geom.rotation,
|
|
697
|
+
z: 0,
|
|
698
|
+
stroke,
|
|
699
|
+
strokeWidth,
|
|
700
|
+
dashed,
|
|
701
|
+
arrow,
|
|
702
|
+
};
|
|
703
|
+
return line;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function parseGraphicFrame(
|
|
707
|
+
gf: any,
|
|
708
|
+
ctx: ParseContext,
|
|
709
|
+
outer: GroupTransform
|
|
710
|
+
): SlideElement | null {
|
|
711
|
+
const tbl = gf?.["a:graphic"]?.["a:graphicData"]?.["a:tbl"];
|
|
712
|
+
if (tbl) {
|
|
713
|
+
const parsed = parseTable(gf, tbl, ctx, outer);
|
|
714
|
+
if (parsed) return parsed;
|
|
715
|
+
}
|
|
716
|
+
return toUnknown(gf, "p:graphicFrame", ctx, outer);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function parseTable(
|
|
720
|
+
gf: any,
|
|
721
|
+
tbl: any,
|
|
722
|
+
ctx: ParseContext,
|
|
723
|
+
outer: GroupTransform
|
|
724
|
+
): TableElement | null {
|
|
725
|
+
const xfrm = gf?.["p:xfrm"] || gf?.["p:spPr"]?.["a:xfrm"];
|
|
726
|
+
const geom = readGeometry(xfrm, ctx.fit, outer);
|
|
727
|
+
if (!geom) return null;
|
|
728
|
+
|
|
729
|
+
const trs = asArray(tbl["a:tr"]);
|
|
730
|
+
if (!trs.length) return null;
|
|
731
|
+
|
|
732
|
+
const rows: string[][] = [];
|
|
733
|
+
let firstFontSizePx: number | undefined;
|
|
734
|
+
let firstColor: string | undefined;
|
|
735
|
+
let headerFill = "#0E1330";
|
|
736
|
+
let bodyFill = "#FFFFFF";
|
|
737
|
+
|
|
738
|
+
for (let ri = 0; ri < trs.length; ri++) {
|
|
739
|
+
const tr = trs[ri];
|
|
740
|
+
const tcs = asArray(tr["a:tc"]);
|
|
741
|
+
const cells: string[] = [];
|
|
742
|
+
for (const tc of tcs) {
|
|
743
|
+
if (tc?.["@_hMerge"] === "1" || tc?.["@_vMerge"] === "1") {
|
|
744
|
+
cells.push("");
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const txBody = tc["a:txBody"];
|
|
748
|
+
const text = txBody
|
|
749
|
+
? extractRuns(txBody, ctx.theme)
|
|
750
|
+
: { plain: "", runs: [] as RunInfo[] };
|
|
751
|
+
cells.push(text.plain);
|
|
752
|
+
|
|
753
|
+
const r0 = text.runs[0];
|
|
754
|
+
if (firstFontSizePx === undefined && r0?.fontSize) {
|
|
755
|
+
firstFontSizePx = Math.max(8, Math.round(r0.fontSize * ctx.fit.scale));
|
|
756
|
+
}
|
|
757
|
+
if (!firstColor && r0?.color) firstColor = r0.color;
|
|
758
|
+
|
|
759
|
+
const cellFill = resolveColor(tc?.["a:tcPr"]?.["a:solidFill"], ctx.theme);
|
|
760
|
+
if (cellFill) {
|
|
761
|
+
if (ri === 0) headerFill = cellFill;
|
|
762
|
+
else bodyFill = cellFill;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
rows.push(cells);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const table: TableElement = {
|
|
769
|
+
id: nanoid(8),
|
|
770
|
+
type: "table",
|
|
771
|
+
...geom,
|
|
772
|
+
z: 0,
|
|
773
|
+
rows,
|
|
774
|
+
headerFill,
|
|
775
|
+
rowFill: bodyFill,
|
|
776
|
+
textColor: firstColor ?? "#0E1330",
|
|
777
|
+
fontSize: firstFontSizePx ?? 18,
|
|
778
|
+
};
|
|
779
|
+
return table;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function toUnknown(
|
|
783
|
+
node: any,
|
|
784
|
+
tag: string,
|
|
785
|
+
ctx: ParseContext,
|
|
786
|
+
outer: GroupTransform
|
|
787
|
+
): UnknownElement {
|
|
788
|
+
ctx.diagnostics.unknownElementCount++;
|
|
789
|
+
const xfrm =
|
|
790
|
+
node?.["p:spPr"]?.["a:xfrm"] ||
|
|
791
|
+
node?.["p:grpSpPr"]?.["a:xfrm"] ||
|
|
792
|
+
node?.["a:xfrm"];
|
|
793
|
+
const geom = readGeometry(xfrm, ctx.fit, outer) ?? {
|
|
794
|
+
x: 0,
|
|
795
|
+
y: 0,
|
|
796
|
+
w: 200,
|
|
797
|
+
h: 100,
|
|
798
|
+
rotation: 0,
|
|
799
|
+
};
|
|
800
|
+
return {
|
|
801
|
+
id: nanoid(8),
|
|
802
|
+
type: "unknown",
|
|
803
|
+
...geom,
|
|
804
|
+
z: 0,
|
|
805
|
+
ooxmlTag: tag,
|
|
806
|
+
ooxmlXml: xmlBuilder.build({ [tag]: node }),
|
|
807
|
+
label: friendlyLabelForTag(tag),
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function friendlyLabelForTag(tag: string): string {
|
|
812
|
+
switch (tag) {
|
|
813
|
+
case "p:graphicFrame":
|
|
814
|
+
return "Chart / table / SmartArt";
|
|
815
|
+
case "p:grpSp":
|
|
816
|
+
return "Grouped shapes";
|
|
817
|
+
case "p:sp":
|
|
818
|
+
return "Imported shape";
|
|
819
|
+
case "p:pic":
|
|
820
|
+
return "Image";
|
|
821
|
+
default:
|
|
822
|
+
return "Imported content";
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// ---------------------------------------------------------------------------
|
|
827
|
+
// placeholders + masters
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
function placeholderKey(ph: any): string {
|
|
831
|
+
const type = ph?.["@_type"] ?? "";
|
|
832
|
+
const idx = ph?.["@_idx"] ?? "";
|
|
833
|
+
return `${type}|${idx}`;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function lookupPlaceholder(
|
|
837
|
+
map: Map<string, PlaceholderInfo>,
|
|
838
|
+
ph: any
|
|
839
|
+
): PlaceholderInfo | undefined {
|
|
840
|
+
const type = ph?.["@_type"] ?? "";
|
|
841
|
+
const idx = ph?.["@_idx"] ?? "";
|
|
842
|
+
// Try exact, then by idx alone, then by type alone.
|
|
843
|
+
return (
|
|
844
|
+
map.get(`${type}|${idx}`) ??
|
|
845
|
+
map.get(`|${idx}`) ??
|
|
846
|
+
map.get(`${type}|`) ??
|
|
847
|
+
(type === "ctrTitle" ? map.get("title|") : undefined) ??
|
|
848
|
+
(type === "subTitle" ? map.get("body|") : undefined)
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function placeholderGeometry(
|
|
853
|
+
ph: PlaceholderInfo | undefined,
|
|
854
|
+
fit: Fit,
|
|
855
|
+
outer: GroupTransform
|
|
856
|
+
): { x: number; y: number; w: number; h: number; rotation: number } | null {
|
|
857
|
+
if (!ph || ph.rawX === undefined) return null;
|
|
858
|
+
return applyFit(
|
|
859
|
+
{
|
|
860
|
+
rawX: ph.rawX!,
|
|
861
|
+
rawY: ph.rawY!,
|
|
862
|
+
rawW: ph.rawW!,
|
|
863
|
+
rawH: ph.rawH!,
|
|
864
|
+
rotation: ph.rotation ?? 0,
|
|
865
|
+
},
|
|
866
|
+
fit,
|
|
867
|
+
outer
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function extractPlaceholders(rootXml: any): Map<string, PlaceholderInfo> {
|
|
872
|
+
const out = new Map<string, PlaceholderInfo>();
|
|
873
|
+
const root =
|
|
874
|
+
rootXml?.["p:sldLayout"] ?? rootXml?.["p:sldMaster"] ?? rootXml;
|
|
875
|
+
const sps = asArray(root?.["p:cSld"]?.["p:spTree"]?.["p:sp"]);
|
|
876
|
+
for (const sp of sps) {
|
|
877
|
+
const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
|
|
878
|
+
if (!ph) continue;
|
|
879
|
+
const xfrm = sp?.["p:spPr"]?.["a:xfrm"];
|
|
880
|
+
const off = xfrm?.["a:off"];
|
|
881
|
+
const ext = xfrm?.["a:ext"];
|
|
882
|
+
const txBody = sp?.["p:txBody"];
|
|
883
|
+
const paragraphs = asArray(txBody?.["a:p"]);
|
|
884
|
+
const firstP = paragraphs[0];
|
|
885
|
+
const firstR = asArray(firstP?.["a:r"])[0];
|
|
886
|
+
const info: PlaceholderInfo = {
|
|
887
|
+
rawX: off ? emuToPx(Number(off["@_x"] ?? 0)) : undefined,
|
|
888
|
+
rawY: off ? emuToPx(Number(off["@_y"] ?? 0)) : undefined,
|
|
889
|
+
rawW: ext ? emuToPx(Number(ext["@_cx"] ?? 0)) : undefined,
|
|
890
|
+
rawH: ext ? emuToPx(Number(ext["@_cy"] ?? 0)) : undefined,
|
|
891
|
+
rotation: xfrm?.["@_rot"] ? Number(xfrm["@_rot"]) / 60000 : 0,
|
|
892
|
+
rPr: firstR?.["a:rPr"] ?? firstP?.["a:pPr"]?.["a:defRPr"],
|
|
893
|
+
pPr: firstP?.["a:pPr"],
|
|
894
|
+
bodyPr: txBody?.["a:bodyPr"],
|
|
895
|
+
paragraphs: hasAnyText(txBody) ? paragraphs : undefined,
|
|
896
|
+
};
|
|
897
|
+
out.set(placeholderKey(ph), info);
|
|
898
|
+
}
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function extractMasterTextDefaults(masterXml: any): MasterTextDefaults {
|
|
903
|
+
const txStyles = masterXml?.["p:sldMaster"]?.["p:txStyles"];
|
|
904
|
+
if (!txStyles) return {};
|
|
905
|
+
return {
|
|
906
|
+
title: txStyles?.["p:titleStyle"]?.["a:lvl1pPr"],
|
|
907
|
+
body: txStyles?.["p:bodyStyle"]?.["a:lvl1pPr"],
|
|
908
|
+
other: txStyles?.["p:otherStyle"]?.["a:lvl1pPr"],
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ---------------------------------------------------------------------------
|
|
913
|
+
// theme
|
|
914
|
+
// ---------------------------------------------------------------------------
|
|
915
|
+
|
|
916
|
+
function extractTheme(themeXml: any): ThemeColors {
|
|
917
|
+
const scheme =
|
|
918
|
+
themeXml?.["a:theme"]?.["a:themeElements"]?.["a:clrScheme"] ?? {};
|
|
919
|
+
const out: ThemeColors = { ...DEFAULT_THEME };
|
|
920
|
+
for (const key of [
|
|
921
|
+
"dk1",
|
|
922
|
+
"lt1",
|
|
923
|
+
"dk2",
|
|
924
|
+
"lt2",
|
|
925
|
+
"accent1",
|
|
926
|
+
"accent2",
|
|
927
|
+
"accent3",
|
|
928
|
+
"accent4",
|
|
929
|
+
"accent5",
|
|
930
|
+
"accent6",
|
|
931
|
+
"hlink",
|
|
932
|
+
"folHlink",
|
|
933
|
+
] as const) {
|
|
934
|
+
const node = scheme[`a:${key}`];
|
|
935
|
+
const color = node ? readSchemeBaseColor(node) : undefined;
|
|
936
|
+
if (color) out[key] = color;
|
|
937
|
+
}
|
|
938
|
+
return out;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function readSchemeBaseColor(node: any): string | undefined {
|
|
942
|
+
const srgb = node?.["a:srgbClr"]?.["@_val"];
|
|
943
|
+
if (srgb) return `#${String(srgb).toUpperCase()}`;
|
|
944
|
+
const sys = node?.["a:sysClr"]?.["@_lastClr"];
|
|
945
|
+
if (sys) return `#${String(sys).toUpperCase()}`;
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ---------------------------------------------------------------------------
|
|
950
|
+
// color resolution
|
|
951
|
+
// ---------------------------------------------------------------------------
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Resolve a color node into a CSS hex string. Handles solid fill envelopes
|
|
955
|
+
* (a:solidFill containing srgb/sys/scheme/prstClr) and bare color nodes
|
|
956
|
+
* (e.g. a gradient stop). Applies modifiers: lumMod, lumOff, shade, tint,
|
|
957
|
+
* alpha. Returns #RRGGBB or #RRGGBBAA.
|
|
958
|
+
*/
|
|
959
|
+
function resolveColor(node: any, theme: ThemeColors): string | undefined {
|
|
960
|
+
if (!node) return undefined;
|
|
961
|
+
// Allow caller to pass either a:solidFill or a bare color node.
|
|
962
|
+
const inner = pickColorChild(node) ?? node;
|
|
963
|
+
if (!inner) return undefined;
|
|
964
|
+
let base = readBaseHex(inner, theme);
|
|
965
|
+
if (!base) return undefined;
|
|
966
|
+
let { r, g, b, a } = hexToRgba(base);
|
|
967
|
+
let { h, s, l } = rgbToHsl(r, g, b);
|
|
968
|
+
|
|
969
|
+
const modParent = pickColorChildEnvelope(node) ?? inner;
|
|
970
|
+
const lumMod = numFromVal(modParent?.["a:lumMod"]);
|
|
971
|
+
const lumOff = numFromVal(modParent?.["a:lumOff"]);
|
|
972
|
+
const shade = numFromVal(modParent?.["a:shade"]);
|
|
973
|
+
const tint = numFromVal(modParent?.["a:tint"]);
|
|
974
|
+
const alphaN = numFromVal(modParent?.["a:alpha"]);
|
|
975
|
+
|
|
976
|
+
if (lumMod !== undefined) l = clamp(l * lumMod);
|
|
977
|
+
if (lumOff !== undefined) l = clamp(l + lumOff);
|
|
978
|
+
// shade/tint: per OOXML, val=100000 is no-op. shade darkens via L; tint lightens via L.
|
|
979
|
+
if (shade !== undefined) l = clamp(l * shade);
|
|
980
|
+
if (tint !== undefined) l = clamp(l + (1 - l) * (1 - tint));
|
|
981
|
+
|
|
982
|
+
({ r, g, b } = hslToRgb(h, s, l));
|
|
983
|
+
if (alphaN !== undefined) a = clamp(a * alphaN);
|
|
984
|
+
|
|
985
|
+
const hex = rgbToHex(r, g, b);
|
|
986
|
+
if (a >= 0.999) return hex;
|
|
987
|
+
const aa = Math.round(a * 255).toString(16).padStart(2, "0").toUpperCase();
|
|
988
|
+
return `${hex}${aa}`;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function pickColorChildEnvelope(node: any): any | undefined {
|
|
992
|
+
// Prefers the inner color child when called with a wrapping <a:solidFill>.
|
|
993
|
+
return (
|
|
994
|
+
node?.["a:srgbClr"] ??
|
|
995
|
+
node?.["a:sysClr"] ??
|
|
996
|
+
node?.["a:schemeClr"] ??
|
|
997
|
+
node?.["a:prstClr"] ??
|
|
998
|
+
undefined
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function pickColorChild(node: any): any | undefined {
|
|
1003
|
+
// Returns whichever <a:*Clr> child is present, normalising the envelope.
|
|
1004
|
+
if (node?.["a:srgbClr"] || node?.["a:sysClr"] || node?.["a:schemeClr"] || node?.["a:prstClr"]) {
|
|
1005
|
+
return node;
|
|
1006
|
+
}
|
|
1007
|
+
return undefined;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function readBaseHex(node: any, theme: ThemeColors): string | undefined {
|
|
1011
|
+
const srgb = node?.["a:srgbClr"]?.["@_val"];
|
|
1012
|
+
if (srgb) return `#${String(srgb).toUpperCase()}`;
|
|
1013
|
+
const sys = node?.["a:sysClr"]?.["@_lastClr"];
|
|
1014
|
+
if (sys) return `#${String(sys).toUpperCase()}`;
|
|
1015
|
+
const scheme = node?.["a:schemeClr"]?.["@_val"];
|
|
1016
|
+
if (scheme) return resolveSchemeToken(scheme, theme);
|
|
1017
|
+
const prst = node?.["a:prstClr"]?.["@_val"];
|
|
1018
|
+
if (prst) return resolvePresetColor(prst);
|
|
1019
|
+
return undefined;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function resolveSchemeToken(token: string, theme: ThemeColors): string {
|
|
1023
|
+
switch (token) {
|
|
1024
|
+
case "bg1":
|
|
1025
|
+
return theme.lt1;
|
|
1026
|
+
case "bg2":
|
|
1027
|
+
return theme.lt2;
|
|
1028
|
+
case "tx1":
|
|
1029
|
+
return theme.dk1;
|
|
1030
|
+
case "tx2":
|
|
1031
|
+
return theme.dk2;
|
|
1032
|
+
case "phClr":
|
|
1033
|
+
// Placeholder color sentinel — best-effort fallback.
|
|
1034
|
+
return theme.dk1;
|
|
1035
|
+
default: {
|
|
1036
|
+
const v = (theme as unknown as Record<string, string>)[token];
|
|
1037
|
+
return v ?? "#000000";
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function resolvePresetColor(name: string): string {
|
|
1043
|
+
// Very small subset of HTML4-like names; rarely used in modern PPTX.
|
|
1044
|
+
const map: Record<string, string> = {
|
|
1045
|
+
black: "#000000",
|
|
1046
|
+
white: "#FFFFFF",
|
|
1047
|
+
red: "#FF0000",
|
|
1048
|
+
green: "#008000",
|
|
1049
|
+
blue: "#0000FF",
|
|
1050
|
+
yellow: "#FFFF00",
|
|
1051
|
+
cyan: "#00FFFF",
|
|
1052
|
+
magenta: "#FF00FF",
|
|
1053
|
+
gray: "#808080",
|
|
1054
|
+
};
|
|
1055
|
+
return map[name.toLowerCase()] ?? "#000000";
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function numFromVal(node: any): number | undefined {
|
|
1059
|
+
const v = node?.["@_val"];
|
|
1060
|
+
if (v === undefined || v === null || v === "") return undefined;
|
|
1061
|
+
const n = Number(v) / 100000;
|
|
1062
|
+
return Number.isFinite(n) ? n : undefined;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function clamp(n: number): number {
|
|
1066
|
+
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function hexToRgba(hex: string): { r: number; g: number; b: number; a: number } {
|
|
1070
|
+
const h = hex.replace("#", "");
|
|
1071
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
1072
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
1073
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
1074
|
+
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
|
1075
|
+
return { r, g, b, a };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
1079
|
+
const to = (n: number) =>
|
|
1080
|
+
Math.max(0, Math.min(255, Math.round(n)))
|
|
1081
|
+
.toString(16)
|
|
1082
|
+
.padStart(2, "0")
|
|
1083
|
+
.toUpperCase();
|
|
1084
|
+
return `#${to(r)}${to(g)}${to(b)}`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
|
1088
|
+
const rn = r / 255;
|
|
1089
|
+
const gn = g / 255;
|
|
1090
|
+
const bn = b / 255;
|
|
1091
|
+
const max = Math.max(rn, gn, bn);
|
|
1092
|
+
const min = Math.min(rn, gn, bn);
|
|
1093
|
+
const l = (max + min) / 2;
|
|
1094
|
+
let h = 0;
|
|
1095
|
+
let s = 0;
|
|
1096
|
+
if (max !== min) {
|
|
1097
|
+
const d = max - min;
|
|
1098
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
1099
|
+
if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
|
|
1100
|
+
else if (max === gn) h = (bn - rn) / d + 2;
|
|
1101
|
+
else h = (rn - gn) / d + 4;
|
|
1102
|
+
h /= 6;
|
|
1103
|
+
}
|
|
1104
|
+
return { h, s, l };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
|
1108
|
+
if (s === 0) {
|
|
1109
|
+
const v = Math.round(l * 255);
|
|
1110
|
+
return { r: v, g: v, b: v };
|
|
1111
|
+
}
|
|
1112
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
1113
|
+
const p = 2 * l - q;
|
|
1114
|
+
const hue = (t: number) => {
|
|
1115
|
+
let tt = t;
|
|
1116
|
+
if (tt < 0) tt += 1;
|
|
1117
|
+
if (tt > 1) tt -= 1;
|
|
1118
|
+
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
|
1119
|
+
if (tt < 1 / 2) return q;
|
|
1120
|
+
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
|
1121
|
+
return p;
|
|
1122
|
+
};
|
|
1123
|
+
return {
|
|
1124
|
+
r: Math.round(hue(h + 1 / 3) * 255),
|
|
1125
|
+
g: Math.round(hue(h) * 255),
|
|
1126
|
+
b: Math.round(hue(h - 1 / 3) * 255),
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Extract a CSS background string from a shape's fill spec. Theme-aware.
|
|
1132
|
+
*/
|
|
1133
|
+
function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
|
|
1134
|
+
if (!spPr) return undefined;
|
|
1135
|
+
if (spPr["a:noFill"] !== undefined) return "transparent";
|
|
1136
|
+
if (spPr["a:solidFill"]) {
|
|
1137
|
+
return resolveColor(spPr["a:solidFill"], theme);
|
|
1138
|
+
}
|
|
1139
|
+
const gf = spPr["a:gradFill"];
|
|
1140
|
+
if (gf) {
|
|
1141
|
+
const stops = asArray(gf["a:gsLst"]?.["a:gs"])
|
|
1142
|
+
.map((g: any) => {
|
|
1143
|
+
const pos = Number(g?.["@_pos"] ?? 0) / 1000;
|
|
1144
|
+
const color = resolveColor(g, theme) ?? "#000000";
|
|
1145
|
+
return { pos, color };
|
|
1146
|
+
})
|
|
1147
|
+
.sort((a, b) => a.pos - b.pos);
|
|
1148
|
+
if (!stops.length) return undefined;
|
|
1149
|
+
const allTransparent = stops.every(
|
|
1150
|
+
(s) => s.color.length === 9 && s.color.endsWith("00")
|
|
1151
|
+
);
|
|
1152
|
+
if (allTransparent) return "transparent";
|
|
1153
|
+
const angDeg = gf["a:lin"]?.["@_ang"]
|
|
1154
|
+
? (Number(gf["a:lin"]["@_ang"]) / 60000 + 90) % 360
|
|
1155
|
+
: 90;
|
|
1156
|
+
const stopsCss = stops.map((s) => `${s.color} ${s.pos.toFixed(2)}%`).join(", ");
|
|
1157
|
+
return `linear-gradient(${angDeg}deg, ${stopsCss})`;
|
|
1158
|
+
}
|
|
1159
|
+
return undefined;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function extractBackgroundColor(bg: any, theme: ThemeColors): string | undefined {
|
|
1163
|
+
return (
|
|
1164
|
+
resolveColor(bg?.["p:bgPr"]?.["a:solidFill"], theme) ??
|
|
1165
|
+
(bg?.["p:bgPr"]?.["a:noFill"] !== undefined ? "transparent" : undefined)
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function readBodyVAlign(bodyPr: any): "top" | "middle" | "bottom" | undefined {
|
|
1170
|
+
const anchor = bodyPr?.["@_anchor"];
|
|
1171
|
+
if (anchor === "ctr") return "middle";
|
|
1172
|
+
if (anchor === "b") return "bottom";
|
|
1173
|
+
if (anchor === "t") return "top";
|
|
1174
|
+
return undefined;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function readAlign(pPr: any): "left" | "center" | "right" | undefined {
|
|
1178
|
+
const a = pPr?.["@_algn"];
|
|
1179
|
+
if (a === "ctr") return "center";
|
|
1180
|
+
if (a === "r") return "right";
|
|
1181
|
+
if (a === "l" || a === "just") return "left";
|
|
1182
|
+
return undefined;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
interface RunInfo {
|
|
1186
|
+
text: string;
|
|
1187
|
+
fontFamily?: string;
|
|
1188
|
+
fontSize?: number;
|
|
1189
|
+
bold?: boolean;
|
|
1190
|
+
italic?: boolean;
|
|
1191
|
+
underline?: boolean;
|
|
1192
|
+
strike?: boolean;
|
|
1193
|
+
color?: string;
|
|
1194
|
+
letterSpacing?: number;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function extractRuns(
|
|
1198
|
+
txBody: any,
|
|
1199
|
+
theme: ThemeColors,
|
|
1200
|
+
fallbackRPr?: any,
|
|
1201
|
+
fallbackPPr?: any
|
|
1202
|
+
): {
|
|
1203
|
+
runs: RunInfo[];
|
|
1204
|
+
plain: string;
|
|
1205
|
+
align?: "left" | "center" | "right";
|
|
1206
|
+
lineHeightPct?: number;
|
|
1207
|
+
} {
|
|
1208
|
+
const runs: RunInfo[] = [];
|
|
1209
|
+
let align: "left" | "center" | "right" | undefined;
|
|
1210
|
+
let lineHeightPct: number | undefined;
|
|
1211
|
+
const paragraphs = asArray(txBody?.["a:p"]);
|
|
1212
|
+
const pieces: string[] = [];
|
|
1213
|
+
|
|
1214
|
+
for (let pi = 0; pi < paragraphs.length; pi++) {
|
|
1215
|
+
const p = paragraphs[pi];
|
|
1216
|
+
const pPr = p?.["a:pPr"];
|
|
1217
|
+
if (!align) {
|
|
1218
|
+
align = readAlign(pPr) ?? readAlign(fallbackPPr);
|
|
1219
|
+
}
|
|
1220
|
+
if (lineHeightPct === undefined) {
|
|
1221
|
+
const lnPct =
|
|
1222
|
+
pPr?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"] ??
|
|
1223
|
+
fallbackPPr?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"];
|
|
1224
|
+
if (lnPct) lineHeightPct = Number(lnPct) / 100000;
|
|
1225
|
+
}
|
|
1226
|
+
const rs = asArray(p?.["a:r"]);
|
|
1227
|
+
const paragraphText: string[] = [];
|
|
1228
|
+
for (const r of rs) {
|
|
1229
|
+
const t = r?.["a:t"];
|
|
1230
|
+
const rPr = r?.["a:rPr"] ?? {};
|
|
1231
|
+
const text = typeof t === "string" ? t : t?.["#text"] ?? "";
|
|
1232
|
+
paragraphText.push(text);
|
|
1233
|
+
const spcRaw = rPr?.["@_spc"] ?? fallbackRPr?.["@_spc"];
|
|
1234
|
+
const letterSpacing =
|
|
1235
|
+
spcRaw !== undefined && spcRaw !== ""
|
|
1236
|
+
? pointsToPx(Number(spcRaw) / 100)
|
|
1237
|
+
: undefined;
|
|
1238
|
+
const fontSize =
|
|
1239
|
+
rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]
|
|
1240
|
+
? pointsToPx(Number(rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]) / 100)
|
|
1241
|
+
: undefined;
|
|
1242
|
+
const fontFamily =
|
|
1243
|
+
rPr?.["a:latin"]?.["@_typeface"] ??
|
|
1244
|
+
fallbackRPr?.["a:latin"]?.["@_typeface"];
|
|
1245
|
+
const color =
|
|
1246
|
+
resolveColor(rPr?.["a:solidFill"], theme) ??
|
|
1247
|
+
resolveColor(fallbackRPr?.["a:solidFill"], theme);
|
|
1248
|
+
const boldVal = rPr?.["@_b"] ?? fallbackRPr?.["@_b"];
|
|
1249
|
+
const italicVal = rPr?.["@_i"] ?? fallbackRPr?.["@_i"];
|
|
1250
|
+
const underlineVal = rPr?.["@_u"] ?? fallbackRPr?.["@_u"];
|
|
1251
|
+
const strikeVal = rPr?.["@_strike"] ?? fallbackRPr?.["@_strike"];
|
|
1252
|
+
runs.push({
|
|
1253
|
+
text,
|
|
1254
|
+
fontFamily,
|
|
1255
|
+
fontSize,
|
|
1256
|
+
bold: boldVal === "1" || boldVal === 1,
|
|
1257
|
+
italic: italicVal === "1" || italicVal === 1,
|
|
1258
|
+
underline: underlineVal && underlineVal !== "none",
|
|
1259
|
+
strike: strikeVal === "sngStrike",
|
|
1260
|
+
color,
|
|
1261
|
+
letterSpacing,
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
pieces.push(paragraphText.join(""));
|
|
1265
|
+
}
|
|
1266
|
+
return {
|
|
1267
|
+
runs,
|
|
1268
|
+
plain: pieces.join("\n"),
|
|
1269
|
+
align,
|
|
1270
|
+
lineHeightPct,
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function hasAnyText(txBody: any): boolean {
|
|
1275
|
+
const ps = asArray(txBody?.["a:p"]);
|
|
1276
|
+
for (const p of ps) {
|
|
1277
|
+
const rs = asArray(p?.["a:r"]);
|
|
1278
|
+
for (const r of rs) {
|
|
1279
|
+
const t = r?.["a:t"];
|
|
1280
|
+
const text = typeof t === "string" ? t : t?.["#text"] ?? "";
|
|
1281
|
+
if (text && String(text).length > 0) return true;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function mergeFirst<T>(...candidates: (T | undefined)[]): T | undefined {
|
|
1288
|
+
for (const c of candidates) {
|
|
1289
|
+
if (c !== undefined && c !== null) return c;
|
|
1290
|
+
}
|
|
1291
|
+
return undefined;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Map an OOXML preset shape name to the closest Slidewise ShapeKind. Returns
|
|
1296
|
+
* null only for shapes we genuinely cannot represent at all (the caller then
|
|
1297
|
+
* falls back to a colored rect to preserve visibility).
|
|
1298
|
+
*/
|
|
1299
|
+
function mapPrstToKind(prst?: string): ShapeKind | null {
|
|
1300
|
+
if (!prst) return null;
|
|
1301
|
+
switch (prst) {
|
|
1302
|
+
// Direct mappings.
|
|
1303
|
+
case "rect":
|
|
1304
|
+
case "snip1Rect":
|
|
1305
|
+
case "snip2SameRect":
|
|
1306
|
+
case "snip2DiagRect":
|
|
1307
|
+
case "snipRoundRect":
|
|
1308
|
+
case "round1Rect":
|
|
1309
|
+
case "round2DiagRect":
|
|
1310
|
+
case "round2SameRect":
|
|
1311
|
+
return "rect";
|
|
1312
|
+
case "roundRect":
|
|
1313
|
+
return "rounded";
|
|
1314
|
+
case "ellipse":
|
|
1315
|
+
case "circle":
|
|
1316
|
+
return "circle";
|
|
1317
|
+
case "triangle":
|
|
1318
|
+
case "rtTriangle":
|
|
1319
|
+
return "triangle";
|
|
1320
|
+
case "diamond":
|
|
1321
|
+
return "diamond";
|
|
1322
|
+
case "star4":
|
|
1323
|
+
case "star5":
|
|
1324
|
+
case "star6":
|
|
1325
|
+
case "star7":
|
|
1326
|
+
case "star8":
|
|
1327
|
+
case "star10":
|
|
1328
|
+
case "star12":
|
|
1329
|
+
case "star16":
|
|
1330
|
+
case "star24":
|
|
1331
|
+
case "star32":
|
|
1332
|
+
return "star";
|
|
1333
|
+
// Loose mappings — preserve visibility with the closest available kind.
|
|
1334
|
+
case "parallelogram":
|
|
1335
|
+
case "trapezoid":
|
|
1336
|
+
case "hexagon":
|
|
1337
|
+
case "pentagon":
|
|
1338
|
+
case "octagon":
|
|
1339
|
+
case "heptagon":
|
|
1340
|
+
case "decagon":
|
|
1341
|
+
case "dodecagon":
|
|
1342
|
+
case "plus":
|
|
1343
|
+
case "cube":
|
|
1344
|
+
case "can":
|
|
1345
|
+
case "leftArrow":
|
|
1346
|
+
case "rightArrow":
|
|
1347
|
+
case "upArrow":
|
|
1348
|
+
case "downArrow":
|
|
1349
|
+
case "leftRightArrow":
|
|
1350
|
+
case "upDownArrow":
|
|
1351
|
+
case "bentArrow":
|
|
1352
|
+
case "uturnArrow":
|
|
1353
|
+
case "callout1":
|
|
1354
|
+
case "callout2":
|
|
1355
|
+
case "callout3":
|
|
1356
|
+
case "wedgeRectCallout":
|
|
1357
|
+
case "wedgeRoundRectCallout":
|
|
1358
|
+
case "flowChartProcess":
|
|
1359
|
+
case "flowChartDecision":
|
|
1360
|
+
case "flowChartTerminator":
|
|
1361
|
+
case "flowChartConnector":
|
|
1362
|
+
return "rect";
|
|
1363
|
+
default:
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// ---------------------------------------------------------------------------
|
|
1369
|
+
// geometry
|
|
1370
|
+
// ---------------------------------------------------------------------------
|
|
1371
|
+
|
|
1372
|
+
function readGeometry(
|
|
1373
|
+
xfrm: any,
|
|
1374
|
+
fit: Fit,
|
|
1375
|
+
outer: GroupTransform
|
|
1376
|
+
): {
|
|
1377
|
+
x: number;
|
|
1378
|
+
y: number;
|
|
1379
|
+
w: number;
|
|
1380
|
+
h: number;
|
|
1381
|
+
rotation: number;
|
|
1382
|
+
} | null {
|
|
1383
|
+
if (!xfrm) return null;
|
|
1384
|
+
const off = xfrm["a:off"];
|
|
1385
|
+
const ext = xfrm["a:ext"];
|
|
1386
|
+
if (!off || !ext) return null;
|
|
1387
|
+
const rawX = emuToPx(Number(off["@_x"] ?? 0));
|
|
1388
|
+
const rawY = emuToPx(Number(off["@_y"] ?? 0));
|
|
1389
|
+
const rawW = emuToPx(Number(ext["@_cx"] ?? 0));
|
|
1390
|
+
const rawH = emuToPx(Number(ext["@_cy"] ?? 0));
|
|
1391
|
+
const rot = xfrm["@_rot"] ? Number(xfrm["@_rot"]) / 60000 : 0;
|
|
1392
|
+
return applyFit(
|
|
1393
|
+
{ rawX, rawY, rawW, rawH, rotation: rot },
|
|
1394
|
+
fit,
|
|
1395
|
+
outer
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function applyFit(
|
|
1400
|
+
raw: { rawX: number; rawY: number; rawW: number; rawH: number; rotation: number },
|
|
1401
|
+
fit: Fit,
|
|
1402
|
+
outer: GroupTransform
|
|
1403
|
+
): { x: number; y: number; w: number; h: number; rotation: number } {
|
|
1404
|
+
// Apply the group's local linear transform to map child raw coords to the
|
|
1405
|
+
// raw slide coordinate system, then apply the slide-to-canvas fit.
|
|
1406
|
+
const slideRawX = outer.a * raw.rawX + outer.c;
|
|
1407
|
+
const slideRawY = outer.b * raw.rawY + outer.d;
|
|
1408
|
+
const slideRawW = raw.rawW * outer.a;
|
|
1409
|
+
const slideRawH = raw.rawH * outer.b;
|
|
1410
|
+
return {
|
|
1411
|
+
x: Math.round(slideRawX * fit.scale + fit.offsetX),
|
|
1412
|
+
y: Math.round(slideRawY * fit.scale + fit.offsetY),
|
|
1413
|
+
w: Math.max(1, Math.round(slideRawW * fit.scale)),
|
|
1414
|
+
h: Math.max(1, Math.round(slideRawH * fit.scale)),
|
|
1415
|
+
rotation: Math.round(raw.rotation),
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function computeFit(presentationXml: any): Fit {
|
|
1420
|
+
const sldSz = presentationXml?.["p:presentation"]?.["p:sldSz"];
|
|
1421
|
+
const cxEmu = Number(sldSz?.["@_cx"]) || 12192000;
|
|
1422
|
+
const cyEmu = Number(sldSz?.["@_cy"]) || 6858000;
|
|
1423
|
+
const sourceW = emuToPx(cxEmu);
|
|
1424
|
+
const sourceH = emuToPx(cyEmu);
|
|
1425
|
+
const scale = Math.min(SLIDE_W / sourceW, SLIDE_H / sourceH);
|
|
1426
|
+
const offsetX = Math.round((SLIDE_W - sourceW * scale) / 2);
|
|
1427
|
+
const offsetY = Math.round((SLIDE_H - sourceH * scale) / 2);
|
|
1428
|
+
return { scale, offsetX, offsetY };
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// ---------------------------------------------------------------------------
|
|
1432
|
+
// zip + xml helpers
|
|
1433
|
+
// ---------------------------------------------------------------------------
|
|
1434
|
+
|
|
1435
|
+
async function readXml(zip: JSZip, path: string): Promise<any | null> {
|
|
1436
|
+
const file = zip.file(path);
|
|
1437
|
+
if (!file) return null;
|
|
1438
|
+
const text = await file.async("string");
|
|
1439
|
+
return xmlParser.parse(text);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function readRels(zip: JSZip, path: string): Promise<Rels> {
|
|
1443
|
+
const byId = new Map<string, { target: string; type: string }>();
|
|
1444
|
+
const xml = await readXml(zip, path);
|
|
1445
|
+
if (!xml) return { byId };
|
|
1446
|
+
const rels = asArray(xml?.["Relationships"]?.["Relationship"]);
|
|
1447
|
+
for (const r of rels) {
|
|
1448
|
+
const id = r?.["@_Id"];
|
|
1449
|
+
const target = r?.["@_Target"];
|
|
1450
|
+
const type = r?.["@_Type"] ?? "";
|
|
1451
|
+
if (id && target) byId.set(id, { target, type });
|
|
1452
|
+
}
|
|
1453
|
+
return { byId };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function firstByType(rels: Rels, suffix: string): string | undefined {
|
|
1457
|
+
for (const { target, type } of rels.byId.values()) {
|
|
1458
|
+
if (type.endsWith(`/${suffix}`) || type.endsWith(suffix)) return target;
|
|
1459
|
+
}
|
|
1460
|
+
return undefined;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function relsPathFor(xmlPath: string): string {
|
|
1464
|
+
return xmlPath.replace(/([^/]+)\.xml$/, "_rels/$1.xml.rels");
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async function readTitle(zip: JSZip): Promise<string> {
|
|
1468
|
+
const file = zip.file("docProps/core.xml");
|
|
1469
|
+
if (!file) return "Untitled";
|
|
1470
|
+
const text = await file.async("string");
|
|
1471
|
+
const m = text.match(/<dc:title[^>]*>([^<]*)<\/dc:title>/);
|
|
1472
|
+
return (m?.[1] || "Untitled").trim() || "Untitled";
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function asArray<T = any>(value: T | T[] | undefined | null): T[] {
|
|
1476
|
+
if (value == null) return [];
|
|
1477
|
+
return Array.isArray(value) ? value : [value];
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function dirOf(path: string): string {
|
|
1481
|
+
const i = path.lastIndexOf("/");
|
|
1482
|
+
return i >= 0 ? path.slice(0, i) : "";
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function normalisePath(target: string, base: string): string {
|
|
1486
|
+
if (target.startsWith("/")) return target.slice(1);
|
|
1487
|
+
if (target.startsWith("../")) {
|
|
1488
|
+
const segments = base.split("/").filter(Boolean);
|
|
1489
|
+
let t = target;
|
|
1490
|
+
while (t.startsWith("../")) {
|
|
1491
|
+
segments.pop();
|
|
1492
|
+
t = t.slice(3);
|
|
1493
|
+
}
|
|
1494
|
+
return [...segments, t].filter(Boolean).join("/");
|
|
1495
|
+
}
|
|
1496
|
+
return base ? `${base}/${target}` : target;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function mimeForExt(ext: string): string {
|
|
1500
|
+
switch (ext) {
|
|
1501
|
+
case "png":
|
|
1502
|
+
return "image/png";
|
|
1503
|
+
case "jpg":
|
|
1504
|
+
case "jpeg":
|
|
1505
|
+
return "image/jpeg";
|
|
1506
|
+
case "gif":
|
|
1507
|
+
return "image/gif";
|
|
1508
|
+
case "svg":
|
|
1509
|
+
return "image/svg+xml";
|
|
1510
|
+
case "webp":
|
|
1511
|
+
return "image/webp";
|
|
1512
|
+
default:
|
|
1513
|
+
return "application/octet-stream";
|
|
1514
|
+
}
|
|
1515
|
+
}
|