@textcortex/slidewise 1.8.0 → 1.10.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/dist/index.mjs +6287 -5264
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/editor/ElementView.tsx +97 -9
- package/src/lib/pptx/__tests__/roundtrip.test.ts +83 -0
- package/src/lib/pptx/deckToPptx.ts +280 -11
- package/src/lib/pptx/pptxToDeck.ts +1668 -129
- package/src/lib/types.ts +52 -0
package/package.json
CHANGED
|
@@ -65,12 +65,17 @@ function TextView({
|
|
|
65
65
|
: el.vAlign === "middle"
|
|
66
66
|
? "center"
|
|
67
67
|
: "flex-end",
|
|
68
|
+
background: el.background,
|
|
69
|
+
padding: el.padding
|
|
70
|
+
? `${el.padding.t}px ${el.padding.r}px ${el.padding.b}px ${el.padding.l}px`
|
|
71
|
+
: undefined,
|
|
72
|
+
boxSizing: el.padding ? "border-box" : undefined,
|
|
68
73
|
cursor: editing ? "text" : "inherit",
|
|
69
74
|
};
|
|
70
75
|
const inner: React.CSSProperties = {
|
|
71
76
|
width: "100%",
|
|
72
77
|
color: el.color,
|
|
73
|
-
fontFamily: el.fontFamily,
|
|
78
|
+
fontFamily: withGenericFallback(el.fontFamily),
|
|
74
79
|
fontSize: el.fontSize,
|
|
75
80
|
fontWeight: el.fontWeight,
|
|
76
81
|
fontStyle: el.italic ? "italic" : "normal",
|
|
@@ -85,11 +90,40 @@ function TextView({
|
|
|
85
90
|
outline: "none",
|
|
86
91
|
};
|
|
87
92
|
|
|
93
|
+
const backingPath = el.backingPath;
|
|
94
|
+
const positionedOuter: React.CSSProperties = backingPath
|
|
95
|
+
? { ...outer, position: "relative" }
|
|
96
|
+
: outer;
|
|
97
|
+
const innerStacked: React.CSSProperties = backingPath
|
|
98
|
+
? { ...inner, position: "relative", zIndex: 1 }
|
|
99
|
+
: inner;
|
|
100
|
+
const backingSvg = backingPath ? (
|
|
101
|
+
<svg
|
|
102
|
+
viewBox={`0 0 ${backingPath.viewW} ${backingPath.viewH}`}
|
|
103
|
+
preserveAspectRatio="none"
|
|
104
|
+
style={{
|
|
105
|
+
position: "absolute",
|
|
106
|
+
inset: 0,
|
|
107
|
+
width: "100%",
|
|
108
|
+
height: "100%",
|
|
109
|
+
pointerEvents: "none",
|
|
110
|
+
zIndex: 0,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<path
|
|
114
|
+
d={backingPath.d}
|
|
115
|
+
fill={backingPath.fill}
|
|
116
|
+
fillRule={backingPath.fillRule ?? "nonzero"}
|
|
117
|
+
/>
|
|
118
|
+
</svg>
|
|
119
|
+
) : null;
|
|
120
|
+
|
|
88
121
|
if (editing) {
|
|
89
122
|
return (
|
|
90
|
-
<div style={
|
|
123
|
+
<div style={positionedOuter}>
|
|
124
|
+
{backingSvg}
|
|
91
125
|
<EditableText
|
|
92
|
-
style={
|
|
126
|
+
style={innerStacked}
|
|
93
127
|
initialText={el.text}
|
|
94
128
|
initialRuns={el.runs}
|
|
95
129
|
onCommit={(t, r) => onCommit?.(t, r)}
|
|
@@ -100,8 +134,9 @@ function TextView({
|
|
|
100
134
|
|
|
101
135
|
if (el.runs && el.runs.length) {
|
|
102
136
|
return (
|
|
103
|
-
<div style={
|
|
104
|
-
|
|
137
|
+
<div style={positionedOuter}>
|
|
138
|
+
{backingSvg}
|
|
139
|
+
<div style={innerStacked}>
|
|
105
140
|
{el.runs.map((r, i) => (
|
|
106
141
|
<span key={i} style={runCssStyle(r)}>
|
|
107
142
|
{r.text}
|
|
@@ -113,15 +148,40 @@ function TextView({
|
|
|
113
148
|
}
|
|
114
149
|
|
|
115
150
|
return (
|
|
116
|
-
<div style={
|
|
117
|
-
|
|
151
|
+
<div style={positionedOuter}>
|
|
152
|
+
{backingSvg}
|
|
153
|
+
<div style={innerStacked}>{el.text}</div>
|
|
118
154
|
</div>
|
|
119
155
|
);
|
|
120
156
|
}
|
|
121
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Append a `sans-serif` generic so brand families imported from PPTX
|
|
160
|
+
* (e.g. "EON Office Head") degrade gracefully when the typeface isn't
|
|
161
|
+
* installed locally — without the generic the browser silently picks
|
|
162
|
+
* its default serif. Already-qualified stacks (containing a comma) and
|
|
163
|
+
* plain generics ("serif"/"monospace") pass through untouched.
|
|
164
|
+
*/
|
|
165
|
+
function withGenericFallback(family: string | undefined): string | undefined {
|
|
166
|
+
if (!family) return family;
|
|
167
|
+
if (family.includes(",")) return family;
|
|
168
|
+
const lower = family.trim().toLowerCase();
|
|
169
|
+
if (
|
|
170
|
+
lower === "serif" ||
|
|
171
|
+
lower === "sans-serif" ||
|
|
172
|
+
lower === "monospace" ||
|
|
173
|
+
lower === "cursive" ||
|
|
174
|
+
lower === "fantasy" ||
|
|
175
|
+
lower === "system-ui"
|
|
176
|
+
) {
|
|
177
|
+
return family;
|
|
178
|
+
}
|
|
179
|
+
return `${family}, sans-serif`;
|
|
180
|
+
}
|
|
181
|
+
|
|
122
182
|
function runCssStyle(r: TextRun): React.CSSProperties {
|
|
123
183
|
const s: React.CSSProperties = {};
|
|
124
|
-
if (r.fontFamily) s.fontFamily = r.fontFamily;
|
|
184
|
+
if (r.fontFamily) s.fontFamily = withGenericFallback(r.fontFamily);
|
|
125
185
|
if (r.fontSize) s.fontSize = r.fontSize;
|
|
126
186
|
if (r.fontWeight) s.fontWeight = r.fontWeight;
|
|
127
187
|
if (r.color) s.color = r.color;
|
|
@@ -315,6 +375,27 @@ function sameStyle(a: TextRun, b: TextRun): boolean {
|
|
|
315
375
|
function ShapeView({ el }: { el: ShapeElement }) {
|
|
316
376
|
const stroke = el.stroke ?? "transparent";
|
|
317
377
|
const sw = el.strokeWidth ?? 0;
|
|
378
|
+
// Custom vector path (PPTX <a:custGeom>) takes precedence over the preset
|
|
379
|
+
// kind — the path coordinates already encode the actual silhouette.
|
|
380
|
+
if (el.path) {
|
|
381
|
+
return (
|
|
382
|
+
<svg
|
|
383
|
+
viewBox={`0 0 ${el.path.viewW} ${el.path.viewH}`}
|
|
384
|
+
preserveAspectRatio="none"
|
|
385
|
+
width="100%"
|
|
386
|
+
height="100%"
|
|
387
|
+
>
|
|
388
|
+
<path
|
|
389
|
+
d={el.path.d}
|
|
390
|
+
fill={el.fill}
|
|
391
|
+
fillRule={el.path.fillRule ?? "nonzero"}
|
|
392
|
+
stroke={stroke}
|
|
393
|
+
strokeWidth={sw || undefined}
|
|
394
|
+
vectorEffect="non-scaling-stroke"
|
|
395
|
+
/>
|
|
396
|
+
</svg>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
318
399
|
if (el.shape === "rect" || el.shape === "rounded") {
|
|
319
400
|
return (
|
|
320
401
|
<div
|
|
@@ -474,7 +555,9 @@ function LineView({ el }: { el: LineElement }) {
|
|
|
474
555
|
function TableView({ el }: { el: TableElement }) {
|
|
475
556
|
const cols = el.rows[0]?.length ?? 1;
|
|
476
557
|
// PPTX-faithful: contiguous cells, no inter-cell gap, no rounded corners.
|
|
477
|
-
//
|
|
558
|
+
// Cells share their dividers via inset box-shadows so we draw a single
|
|
559
|
+
// grid line between adjacent cells instead of doubling-up borders.
|
|
560
|
+
const stroke = el.borderColor ?? "rgba(0, 0, 0, 0.12)";
|
|
478
561
|
return (
|
|
479
562
|
<div
|
|
480
563
|
style={{
|
|
@@ -485,6 +568,7 @@ function TableView({ el }: { el: TableElement }) {
|
|
|
485
568
|
height: "100%",
|
|
486
569
|
gap: 0,
|
|
487
570
|
background: "transparent",
|
|
571
|
+
boxShadow: `inset 0 0 0 1px ${stroke}`,
|
|
488
572
|
}}
|
|
489
573
|
>
|
|
490
574
|
{el.rows.flatMap((row, ri) =>
|
|
@@ -504,6 +588,10 @@ function TableView({ el }: { el: TableElement }) {
|
|
|
504
588
|
minHeight: 0,
|
|
505
589
|
overflow: "hidden",
|
|
506
590
|
wordBreak: "break-word",
|
|
591
|
+
borderRight:
|
|
592
|
+
ci < cols - 1 ? `1px solid ${stroke}` : undefined,
|
|
593
|
+
borderBottom:
|
|
594
|
+
ri < el.rows.length - 1 ? `1px solid ${stroke}` : undefined,
|
|
507
595
|
}}
|
|
508
596
|
>
|
|
509
597
|
{cell}
|
|
@@ -237,4 +237,87 @@ describe("pptx round-trip", () => {
|
|
|
237
237
|
expect(colors).toContain("#FFFFFF");
|
|
238
238
|
expect(colors).toContain("#0F1B3D");
|
|
239
239
|
});
|
|
240
|
+
|
|
241
|
+
it("preserves UnknownElement OOXML and its rels across a round-trip", async () => {
|
|
242
|
+
// Build a deck with a single hand-crafted UnknownElement carrying a raw
|
|
243
|
+
// OOXML fragment that references rId7. parsePptx then attaches a fake
|
|
244
|
+
// source archive providing that rId; serializeDeck has to renumber the
|
|
245
|
+
// rId, write the matching <Relationship>, and copy the referenced
|
|
246
|
+
// media into the output zip so the fragment resolves on re-parse.
|
|
247
|
+
const JSZip = (await import("jszip")).default;
|
|
248
|
+
const sourceZip = new JSZip();
|
|
249
|
+
sourceZip.file(
|
|
250
|
+
"[Content_Types].xml",
|
|
251
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
252
|
+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
253
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
254
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
255
|
+
<Default Extension="png" ContentType="image/png"/>
|
|
256
|
+
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
|
|
257
|
+
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
|
|
258
|
+
</Types>`
|
|
259
|
+
);
|
|
260
|
+
sourceZip.file(
|
|
261
|
+
"_rels/.rels",
|
|
262
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>`
|
|
263
|
+
);
|
|
264
|
+
sourceZip.file(
|
|
265
|
+
"ppt/_rels/presentation.xml.rels",
|
|
266
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
|
|
267
|
+
);
|
|
268
|
+
sourceZip.file(
|
|
269
|
+
"ppt/presentation.xml",
|
|
270
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="12192000" cy="6858000"/></p:presentation>`
|
|
271
|
+
);
|
|
272
|
+
sourceZip.file(
|
|
273
|
+
"ppt/slides/slide1.xml",
|
|
274
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree><p:graphicFrame><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:relIds xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" r:dm="rId7"/></a:graphicData></a:graphic></p:graphicFrame></p:spTree></p:cSld></p:sld>`
|
|
275
|
+
);
|
|
276
|
+
sourceZip.file(
|
|
277
|
+
"ppt/slides/_rels/slide1.xml.rels",
|
|
278
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/preserved.png"/></Relationships>`
|
|
279
|
+
);
|
|
280
|
+
// Smallest valid PNG (1×1 transparent) so JSZip + serializer have real
|
|
281
|
+
// bytes to copy.
|
|
282
|
+
const onePxPng = Uint8Array.from([
|
|
283
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
|
284
|
+
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
285
|
+
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
|
|
286
|
+
0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
|
287
|
+
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
|
288
|
+
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
|
289
|
+
]);
|
|
290
|
+
sourceZip.file("ppt/media/preserved.png", onePxPng);
|
|
291
|
+
|
|
292
|
+
const sourceBuffer = await sourceZip.generateAsync({ type: "arraybuffer" });
|
|
293
|
+
const parsed = await parsePptx(sourceBuffer);
|
|
294
|
+
const unknowns = parsed.slides[0].elements.filter(
|
|
295
|
+
(e) => e.type === "unknown"
|
|
296
|
+
);
|
|
297
|
+
expect(unknowns.length).toBe(1);
|
|
298
|
+
expect((unknowns[0] as { ooxmlXml: string }).ooxmlXml).toContain(
|
|
299
|
+
"diagram"
|
|
300
|
+
);
|
|
301
|
+
expect((unknowns[0] as { ooxmlXml: string }).ooxmlXml).toMatch(
|
|
302
|
+
/r:dm="rId7"/
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const blob = await serializeDeck(parsed);
|
|
306
|
+
const out = await blob.arrayBuffer();
|
|
307
|
+
const reZip = await JSZip.loadAsync(out);
|
|
308
|
+
// The preserved diagram fragment landed in the generated slide1 xml.
|
|
309
|
+
const slide1 = await reZip.file("ppt/slides/slide1.xml")?.async("string");
|
|
310
|
+
expect(slide1).toContain("dgm:relIds");
|
|
311
|
+
// The original rId7 was renumbered; the slide rels now expose the new
|
|
312
|
+
// rId pointing at a preserved-prefixed media path.
|
|
313
|
+
const slide1Rels = await reZip
|
|
314
|
+
.file("ppt/slides/_rels/slide1.xml.rels")
|
|
315
|
+
?.async("string");
|
|
316
|
+
expect(slide1Rels).toMatch(/slidewise_preserved_\d+_preserved\.png/);
|
|
317
|
+
// The actual PNG bytes were copied into the output archive.
|
|
318
|
+
const preservedFiles = Object.keys(reZip.files).filter((p) =>
|
|
319
|
+
/slidewise_preserved_\d+_preserved\.png$/.test(p)
|
|
320
|
+
);
|
|
321
|
+
expect(preservedFiles.length).toBe(1);
|
|
322
|
+
});
|
|
240
323
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pptxgen from "pptxgenjs";
|
|
2
|
+
import JSZip from "jszip";
|
|
2
3
|
import type {
|
|
3
4
|
Deck,
|
|
4
5
|
Slide,
|
|
@@ -11,15 +12,25 @@ import type {
|
|
|
11
12
|
TableElement,
|
|
12
13
|
IconElement,
|
|
13
14
|
EmbedElement,
|
|
15
|
+
UnknownElement,
|
|
14
16
|
} from "@/lib/types";
|
|
15
17
|
import { pxToInches, pxToPoints } from "./units";
|
|
18
|
+
import { SOURCE_PPTX, SOURCE_SLIDE_PATH } from "./pptxToDeck";
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
|
-
* Serialize a Slidewise Deck to a real PPTX blob.
|
|
19
|
-
*
|
|
20
|
-
* table, icon, embed)
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* Serialize a Slidewise Deck to a real PPTX blob.
|
|
22
|
+
*
|
|
23
|
+
* Native element types (text, shape, image, line, table, icon, embed)
|
|
24
|
+
* are written through pptxgenjs. UnknownElement (charts, SmartArt,
|
|
25
|
+
* group shapes, OLE, math, anything else the importer couldn't model)
|
|
26
|
+
* is preserved verbatim: we keep the original PPTX bytes on the Deck
|
|
27
|
+
* during parse, and after pptxgenjs finishes, we post-process the
|
|
28
|
+
* generated zip to inject the preserved OOXML — plus any media those
|
|
29
|
+
* fragments referenced — into the matching slides. The fragments
|
|
30
|
+
* inside an UnknownElement keep their original rIds; we copy the
|
|
31
|
+
* corresponding rels entries (and media payloads) from the source
|
|
32
|
+
* zip, renumbering rIds as needed to avoid clashes with what
|
|
33
|
+
* pptxgenjs already wrote.
|
|
23
34
|
*/
|
|
24
35
|
export async function serializeDeck(deck: Deck): Promise<Blob> {
|
|
25
36
|
const pptx = new pptxgen();
|
|
@@ -30,9 +41,12 @@ export async function serializeDeck(deck: Deck): Promise<Blob> {
|
|
|
30
41
|
addSlide(pptx, slide);
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
// Use arraybuffer (universal: works in Node + browser, accepted by JSZip
|
|
45
|
+
// directly) and wrap to Blob only when we're done post-processing.
|
|
46
|
+
const generated = (await pptx.write({
|
|
47
|
+
outputType: "arraybuffer",
|
|
48
|
+
})) as ArrayBuffer;
|
|
49
|
+
return preserveUnknowns(generated, deck);
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
function addSlide(pptx: pptxgen, slide: Slide): void {
|
|
@@ -76,9 +90,9 @@ function addElement(s: pptxgen.Slide, el: SlideElement): void {
|
|
|
76
90
|
addEmbed(s, el);
|
|
77
91
|
return;
|
|
78
92
|
case "unknown":
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
93
|
+
// Preserved by preserveUnknowns() after pptxgenjs writes the zip.
|
|
94
|
+
// The post-process step injects el.ooxmlXml into the matching
|
|
95
|
+
// slide's <p:spTree> and copies any media the fragment referenced.
|
|
82
96
|
return;
|
|
83
97
|
}
|
|
84
98
|
}
|
|
@@ -270,6 +284,261 @@ function addEmbed(s: pptxgen.Slide, el: EmbedElement): void {
|
|
|
270
284
|
);
|
|
271
285
|
}
|
|
272
286
|
|
|
287
|
+
// -- UnknownElement preservation -------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Post-process the zip pptxgenjs produced: for every slide that carries
|
|
291
|
+
* UnknownElement payloads, inject the preserved OOXML back into the
|
|
292
|
+
* generated `<p:spTree>` and pull along the rels + media those fragments
|
|
293
|
+
* referenced from the original archive.
|
|
294
|
+
*
|
|
295
|
+
* No-ops cleanly when the deck has no UnknownElements, when no source
|
|
296
|
+
* zip is attached (deck wasn't created via parsePptx), or when a slide
|
|
297
|
+
* the editor added doesn't have a source path.
|
|
298
|
+
*/
|
|
299
|
+
async function preserveUnknowns(
|
|
300
|
+
generated: ArrayBuffer,
|
|
301
|
+
deck: Deck
|
|
302
|
+
): Promise<Blob> {
|
|
303
|
+
const wrapBlob = () => new Blob([generated], { type: PPTX_MIME });
|
|
304
|
+
const unknownsBySlide = collectUnknowns(deck);
|
|
305
|
+
if (!unknownsBySlide.size) return wrapBlob();
|
|
306
|
+
const sourceBuffer = (deck as unknown as Record<string, unknown>)[SOURCE_PPTX];
|
|
307
|
+
if (!(sourceBuffer instanceof ArrayBuffer)) return wrapBlob();
|
|
308
|
+
|
|
309
|
+
const [outZip, srcZip] = await Promise.all([
|
|
310
|
+
JSZip.loadAsync(generated),
|
|
311
|
+
JSZip.loadAsync(sourceBuffer),
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
for (const [slideIndex, group] of unknownsBySlide) {
|
|
315
|
+
const generatedSlidePath = `ppt/slides/slide${slideIndex + 1}.xml`;
|
|
316
|
+
const generatedRelsPath = `ppt/slides/_rels/slide${slideIndex + 1}.xml.rels`;
|
|
317
|
+
if (!outZip.file(generatedSlidePath)) continue;
|
|
318
|
+
if (!group.sourcePath) continue;
|
|
319
|
+
const sourceRelsPath = relsPathFor(group.sourcePath);
|
|
320
|
+
|
|
321
|
+
await injectUnknownsIntoSlide(
|
|
322
|
+
outZip,
|
|
323
|
+
srcZip,
|
|
324
|
+
generatedSlidePath,
|
|
325
|
+
generatedRelsPath,
|
|
326
|
+
sourceRelsPath,
|
|
327
|
+
group.unknowns
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// JSZip's blob output preserves the OOXML mime type set by pptxgenjs.
|
|
332
|
+
return outZip.generateAsync({ type: "blob", mimeType: PPTX_MIME });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
interface UnknownGroup {
|
|
336
|
+
unknowns: UnknownElement[];
|
|
337
|
+
sourcePath: string | undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function collectUnknowns(deck: Deck): Map<number, UnknownGroup> {
|
|
341
|
+
const out = new Map<number, UnknownGroup>();
|
|
342
|
+
for (let i = 0; i < deck.slides.length; i++) {
|
|
343
|
+
const slide = deck.slides[i];
|
|
344
|
+
const unknowns = slide.elements.filter(
|
|
345
|
+
(e): e is UnknownElement => e.type === "unknown" && !!e.ooxmlXml
|
|
346
|
+
);
|
|
347
|
+
if (!unknowns.length) continue;
|
|
348
|
+
const sourcePath = (slide as unknown as Record<string, unknown>)[
|
|
349
|
+
SOURCE_SLIDE_PATH
|
|
350
|
+
];
|
|
351
|
+
out.set(i, {
|
|
352
|
+
unknowns,
|
|
353
|
+
sourcePath: typeof sourcePath === "string" ? sourcePath : undefined,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* For one slide: rewrite the preserved fragments so their rIds don't
|
|
361
|
+
* collide with whatever pptxgenjs already allocated, copy the
|
|
362
|
+
* referenced rels + media from the source zip, and splice the
|
|
363
|
+
* fragments in before the closing `</p:spTree>`.
|
|
364
|
+
*/
|
|
365
|
+
async function injectUnknownsIntoSlide(
|
|
366
|
+
outZip: JSZip,
|
|
367
|
+
srcZip: JSZip,
|
|
368
|
+
generatedSlidePath: string,
|
|
369
|
+
generatedRelsPath: string,
|
|
370
|
+
sourceRelsPath: string,
|
|
371
|
+
unknowns: UnknownElement[]
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
const slideXml = await outZip.file(generatedSlidePath)!.async("string");
|
|
374
|
+
const closeIdx = slideXml.lastIndexOf("</p:spTree>");
|
|
375
|
+
if (closeIdx < 0) return;
|
|
376
|
+
|
|
377
|
+
const srcRelsXml = (await srcZip.file(sourceRelsPath)?.async("string")) ?? null;
|
|
378
|
+
const outRelsXml =
|
|
379
|
+
(await outZip.file(generatedRelsPath)?.async("string")) ??
|
|
380
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`;
|
|
381
|
+
|
|
382
|
+
const srcRels = parseRels(srcRelsXml);
|
|
383
|
+
const outRels = parseRels(outRelsXml);
|
|
384
|
+
let nextRid = highestRid(outRels) + 1;
|
|
385
|
+
const newRelLines: string[] = [];
|
|
386
|
+
const ridMap = new Map<string, string>();
|
|
387
|
+
// Source slide's directory (used to resolve relative rel targets like
|
|
388
|
+
// "../media/imageN.png" against the source archive).
|
|
389
|
+
const sourceSlidePath = sourceRelsPath.replace(/_rels\/([^/]+)\.rels$/, "$1");
|
|
390
|
+
const sourceDir = dirOf(sourceSlidePath);
|
|
391
|
+
const outDir = dirOf(generatedSlidePath);
|
|
392
|
+
const rewritten: string[] = [];
|
|
393
|
+
|
|
394
|
+
for (const u of unknowns) {
|
|
395
|
+
// Every r:id / r:embed / r:link inside the preserved fragment refers
|
|
396
|
+
// to a relationship in the SOURCE slide's rels. Renumber to fresh
|
|
397
|
+
// rIds, copy the matching source rel into the generated rels, and
|
|
398
|
+
// copy the media payload into the generated zip (at a fresh path so
|
|
399
|
+
// pptxgenjs-allocated media doesn't clash with the preserved media).
|
|
400
|
+
// Match every `r:*="rIdN"` attribute. The relationship-namespaced
|
|
401
|
+
// attribute names depend on the schema: `r:id` / `r:embed` / `r:link`
|
|
402
|
+
// for slides + drawings, but charts use `r:id`, SmartArt uses `r:dm`
|
|
403
|
+
// (data model) / `r:cs` (colors) / `r:qs` (quick styles) / `r:lo`
|
|
404
|
+
// (layout), and embedded objects use `r:id`/`r:image`. Restricting to
|
|
405
|
+
// the value pattern `rId\d+` keeps unrelated `r:*` attributes
|
|
406
|
+
// untouched.
|
|
407
|
+
const xml = u.ooxmlXml.replace(
|
|
408
|
+
/\b(r:[a-zA-Z]+)="(rId\d+)"/g,
|
|
409
|
+
(_match, attr, srcRid) => {
|
|
410
|
+
const cached = ridMap.get(srcRid);
|
|
411
|
+
if (cached) return `${attr}="${cached}"`;
|
|
412
|
+
const srcRel = srcRels.get(srcRid);
|
|
413
|
+
if (!srcRel) return `${attr}="${srcRid}"`;
|
|
414
|
+
|
|
415
|
+
const newRid = `rId${nextRid++}`;
|
|
416
|
+
ridMap.set(srcRid, newRid);
|
|
417
|
+
|
|
418
|
+
let target = srcRel.target;
|
|
419
|
+
const isExternal = /^https?:\/\//i.test(target);
|
|
420
|
+
const isInternalPart = !isExternal && !target.startsWith("/");
|
|
421
|
+
if (isInternalPart) {
|
|
422
|
+
const srcFullTarget = normalisePath(target, sourceDir);
|
|
423
|
+
const srcFile = srcZip.file(srcFullTarget);
|
|
424
|
+
if (srcFile) {
|
|
425
|
+
// Always copy to a uniquely-prefixed path so we never collide
|
|
426
|
+
// with media pptxgenjs already wrote.
|
|
427
|
+
const newTarget = uniqueTarget(target, outZip, outDir);
|
|
428
|
+
const newFullTarget = normalisePath(newTarget, outDir);
|
|
429
|
+
outZip.file(newFullTarget, srcFile.async("uint8array"), {
|
|
430
|
+
binary: true,
|
|
431
|
+
});
|
|
432
|
+
target = newTarget;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
newRelLines.push(buildRelXml(newRid, srcRel.type, target));
|
|
437
|
+
return `${attr}="${newRid}"`;
|
|
438
|
+
}
|
|
439
|
+
);
|
|
440
|
+
rewritten.push(xml);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (rewritten.length) {
|
|
444
|
+
const inject = rewritten.join("");
|
|
445
|
+
const updatedSlide = slideXml.slice(0, closeIdx) + inject + slideXml.slice(closeIdx);
|
|
446
|
+
outZip.file(generatedSlidePath, updatedSlide);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (newRelLines.length) {
|
|
450
|
+
const insertAt = outRelsXml.lastIndexOf("</Relationships>");
|
|
451
|
+
const updatedRels =
|
|
452
|
+
insertAt >= 0
|
|
453
|
+
? outRelsXml.slice(0, insertAt) +
|
|
454
|
+
newRelLines.join("") +
|
|
455
|
+
outRelsXml.slice(insertAt)
|
|
456
|
+
: outRelsXml.replace(
|
|
457
|
+
/<Relationships[^>]*>/,
|
|
458
|
+
(m) => `${m}${newRelLines.join("")}`
|
|
459
|
+
);
|
|
460
|
+
outZip.file(generatedRelsPath, updatedRels);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function parseRels(xml: string | null): Map<string, { type: string; target: string }> {
|
|
465
|
+
const map = new Map<string, { type: string; target: string }>();
|
|
466
|
+
if (!xml) return map;
|
|
467
|
+
// Match each <Relationship .../> tag. Use a non-greedy scan up to the
|
|
468
|
+
// self-closing `/>` rather than a `[^/]` class — relationship targets
|
|
469
|
+
// routinely contain `/` (e.g. `Target="../charts/chart1.xml"`).
|
|
470
|
+
const re = /<Relationship\b([\s\S]*?)\/>/g;
|
|
471
|
+
let m: RegExpExecArray | null;
|
|
472
|
+
while ((m = re.exec(xml))) {
|
|
473
|
+
const attrs = m[1];
|
|
474
|
+
const id = /\bId="([^"]+)"/.exec(attrs)?.[1];
|
|
475
|
+
const type = /\bType="([^"]+)"/.exec(attrs)?.[1];
|
|
476
|
+
const target = /\bTarget="([^"]+)"/.exec(attrs)?.[1];
|
|
477
|
+
if (id && type && target) map.set(id, { type, target });
|
|
478
|
+
}
|
|
479
|
+
return map;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function highestRid(rels: Map<string, unknown>): number {
|
|
483
|
+
let max = 0;
|
|
484
|
+
for (const id of rels.keys()) {
|
|
485
|
+
const m = /^rId(\d+)$/.exec(id);
|
|
486
|
+
if (m) {
|
|
487
|
+
const n = Number(m[1]);
|
|
488
|
+
if (n > max) max = n;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return max;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function buildRelXml(id: string, type: string, target: string): string {
|
|
495
|
+
return `<Relationship Id="${id}" Type="${type}" Target="${target}"/>`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function relsPathFor(xmlPath: string): string {
|
|
499
|
+
return xmlPath.replace(/([^/]+)\.xml$/, "_rels/$1.xml.rels");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Pick a target path that doesn't collide with anything pptxgenjs already
|
|
504
|
+
* wrote into the zip. We keep the original target's directory and
|
|
505
|
+
* extension so the file stays in `ppt/media/`, `ppt/charts/`, etc., but
|
|
506
|
+
* prefix the basename with `slidewise_preserved_N_` until the resolved
|
|
507
|
+
* full path is unique.
|
|
508
|
+
*/
|
|
509
|
+
function uniqueTarget(originalTarget: string, outZip: JSZip, baseDir: string): string {
|
|
510
|
+
const slash = originalTarget.lastIndexOf("/");
|
|
511
|
+
const dir = slash >= 0 ? originalTarget.slice(0, slash + 1) : "";
|
|
512
|
+
const file = slash >= 0 ? originalTarget.slice(slash + 1) : originalTarget;
|
|
513
|
+
let i = 0;
|
|
514
|
+
let candidate = `${dir}slidewise_preserved_${i}_${file}`;
|
|
515
|
+
while (outZip.file(normalisePath(candidate, baseDir))) {
|
|
516
|
+
i++;
|
|
517
|
+
candidate = `${dir}slidewise_preserved_${i}_${file}`;
|
|
518
|
+
}
|
|
519
|
+
return candidate;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function dirOf(path: string): string {
|
|
523
|
+
const i = path.lastIndexOf("/");
|
|
524
|
+
return i >= 0 ? path.slice(0, i) : "";
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function normalisePath(target: string, base: string): string {
|
|
528
|
+
if (/^https?:\/\//i.test(target)) return target;
|
|
529
|
+
if (target.startsWith("/")) return target.slice(1);
|
|
530
|
+
let t = target;
|
|
531
|
+
const segments = base.split("/").filter(Boolean);
|
|
532
|
+
while (t.startsWith("../")) {
|
|
533
|
+
segments.pop();
|
|
534
|
+
t = t.slice(3);
|
|
535
|
+
}
|
|
536
|
+
return [...segments, t].filter(Boolean).join("/");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const PPTX_MIME =
|
|
540
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
541
|
+
|
|
273
542
|
// -- helpers ----------------------------------------------------------------
|
|
274
543
|
|
|
275
544
|
function hexNoHash(color: string): string {
|