@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,240 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parsePptx, serializeDeck } from "../index";
|
|
3
|
+
import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate";
|
|
4
|
+
import type { Deck } from "@/lib/types";
|
|
5
|
+
|
|
6
|
+
const baseElement = {
|
|
7
|
+
rotation: 0,
|
|
8
|
+
z: 1,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function makeDeck(slideElements: Deck["slides"][number]["elements"]): Deck {
|
|
12
|
+
return {
|
|
13
|
+
version: CURRENT_DECK_VERSION,
|
|
14
|
+
title: "Round-trip fixture",
|
|
15
|
+
slides: [
|
|
16
|
+
{
|
|
17
|
+
id: "slide-1",
|
|
18
|
+
background: "#FFFFFF",
|
|
19
|
+
elements: slideElements,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function roundtrip(deck: Deck): Promise<Deck> {
|
|
26
|
+
const blob = await serializeDeck(deck);
|
|
27
|
+
const buffer = await blob.arrayBuffer();
|
|
28
|
+
return parsePptx(buffer);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("pptx round-trip", () => {
|
|
32
|
+
it("preserves a deck with a single text element", async () => {
|
|
33
|
+
const deck = makeDeck([
|
|
34
|
+
{
|
|
35
|
+
...baseElement,
|
|
36
|
+
id: "t1",
|
|
37
|
+
type: "text",
|
|
38
|
+
x: 200,
|
|
39
|
+
y: 240,
|
|
40
|
+
w: 1200,
|
|
41
|
+
h: 200,
|
|
42
|
+
text: "Hello, Slidewise",
|
|
43
|
+
fontFamily: "Inter",
|
|
44
|
+
fontSize: 64,
|
|
45
|
+
fontWeight: 700,
|
|
46
|
+
italic: false,
|
|
47
|
+
underline: false,
|
|
48
|
+
strike: false,
|
|
49
|
+
color: "#0E1330",
|
|
50
|
+
align: "left",
|
|
51
|
+
vAlign: "top",
|
|
52
|
+
lineHeight: 1.2,
|
|
53
|
+
letterSpacing: 0,
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const out = await roundtrip(deck);
|
|
58
|
+
expect(out.slides.length).toBe(1);
|
|
59
|
+
expect(out.slides[0].elements.length).toBeGreaterThanOrEqual(1);
|
|
60
|
+
const text = out.slides[0].elements.find((e) => e.type === "text");
|
|
61
|
+
expect(text).toBeTruthy();
|
|
62
|
+
if (text && text.type === "text") {
|
|
63
|
+
expect(text.text).toBe("Hello, Slidewise");
|
|
64
|
+
expect(text.fontWeight).toBeGreaterThanOrEqual(600);
|
|
65
|
+
expect(text.color.toUpperCase()).toBe("#0E1330");
|
|
66
|
+
// Position survives within rounding tolerance (1 px).
|
|
67
|
+
expect(Math.abs(text.x - 200)).toBeLessThanOrEqual(2);
|
|
68
|
+
expect(Math.abs(text.y - 240)).toBeLessThanOrEqual(2);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("preserves shape kind, position, and fill", async () => {
|
|
73
|
+
const deck = makeDeck([
|
|
74
|
+
{
|
|
75
|
+
...baseElement,
|
|
76
|
+
id: "s1",
|
|
77
|
+
type: "shape",
|
|
78
|
+
x: 100,
|
|
79
|
+
y: 100,
|
|
80
|
+
w: 400,
|
|
81
|
+
h: 300,
|
|
82
|
+
shape: "rounded",
|
|
83
|
+
fill: "#4F5BD5",
|
|
84
|
+
radius: 24,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
...baseElement,
|
|
88
|
+
id: "s2",
|
|
89
|
+
type: "shape",
|
|
90
|
+
x: 600,
|
|
91
|
+
y: 100,
|
|
92
|
+
w: 300,
|
|
93
|
+
h: 300,
|
|
94
|
+
shape: "circle",
|
|
95
|
+
fill: "#F2B544",
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const out = await roundtrip(deck);
|
|
100
|
+
const shapes = out.slides[0].elements.filter((e) => e.type === "shape");
|
|
101
|
+
expect(shapes.length).toBe(2);
|
|
102
|
+
const rounded = shapes.find((e) => e.type === "shape" && e.shape === "rounded");
|
|
103
|
+
const circle = shapes.find((e) => e.type === "shape" && e.shape === "circle");
|
|
104
|
+
expect(rounded).toBeTruthy();
|
|
105
|
+
expect(circle).toBeTruthy();
|
|
106
|
+
if (rounded && rounded.type === "shape") {
|
|
107
|
+
expect(rounded.fill.toUpperCase()).toBe("#4F5BD5");
|
|
108
|
+
expect(Math.abs(rounded.w - 400)).toBeLessThanOrEqual(2);
|
|
109
|
+
expect(Math.abs(rounded.h - 300)).toBeLessThanOrEqual(2);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("preserves slide background colour", async () => {
|
|
114
|
+
const deck: Deck = {
|
|
115
|
+
version: CURRENT_DECK_VERSION,
|
|
116
|
+
title: "Bg",
|
|
117
|
+
slides: [
|
|
118
|
+
{ id: "s", background: "#FAEEDC", elements: [] },
|
|
119
|
+
{ id: "s2", background: "#0E1330", elements: [] },
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
const out = await roundtrip(deck);
|
|
123
|
+
expect(out.slides.length).toBe(2);
|
|
124
|
+
expect(out.slides[0].background.toUpperCase()).toBe("#FAEEDC");
|
|
125
|
+
expect(out.slides[1].background.toUpperCase()).toBe("#0E1330");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("preserves multiple slides with mixed elements", async () => {
|
|
129
|
+
const deck: Deck = {
|
|
130
|
+
version: CURRENT_DECK_VERSION,
|
|
131
|
+
title: "Multi",
|
|
132
|
+
slides: [
|
|
133
|
+
{
|
|
134
|
+
id: "s1",
|
|
135
|
+
background: "#FFFFFF",
|
|
136
|
+
elements: [
|
|
137
|
+
{
|
|
138
|
+
...baseElement,
|
|
139
|
+
id: "t",
|
|
140
|
+
type: "text",
|
|
141
|
+
x: 80,
|
|
142
|
+
y: 80,
|
|
143
|
+
w: 1200,
|
|
144
|
+
h: 100,
|
|
145
|
+
text: "Slide one",
|
|
146
|
+
fontFamily: "Inter",
|
|
147
|
+
fontSize: 48,
|
|
148
|
+
fontWeight: 700,
|
|
149
|
+
italic: false,
|
|
150
|
+
underline: false,
|
|
151
|
+
strike: false,
|
|
152
|
+
color: "#0E1330",
|
|
153
|
+
align: "left",
|
|
154
|
+
vAlign: "top",
|
|
155
|
+
lineHeight: 1.2,
|
|
156
|
+
letterSpacing: 0,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "s2",
|
|
162
|
+
background: "#0E1330",
|
|
163
|
+
elements: [
|
|
164
|
+
{
|
|
165
|
+
...baseElement,
|
|
166
|
+
id: "sh",
|
|
167
|
+
type: "shape",
|
|
168
|
+
x: 200,
|
|
169
|
+
y: 200,
|
|
170
|
+
w: 400,
|
|
171
|
+
h: 400,
|
|
172
|
+
shape: "rect",
|
|
173
|
+
fill: "#FFFFFF",
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
const out = await roundtrip(deck);
|
|
180
|
+
expect(out.slides.length).toBe(2);
|
|
181
|
+
expect(
|
|
182
|
+
out.slides[0].elements.find((e) => e.type === "text")
|
|
183
|
+
).toBeTruthy();
|
|
184
|
+
expect(
|
|
185
|
+
out.slides[1].elements.find((e) => e.type === "shape")
|
|
186
|
+
).toBeTruthy();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("preserves the deck title", async () => {
|
|
190
|
+
const deck = makeDeck([]);
|
|
191
|
+
deck.title = "My Wonderful Deck";
|
|
192
|
+
const out = await roundtrip(deck);
|
|
193
|
+
expect(out.title).toBe("My Wonderful Deck");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("round-trips multi-color text via runs[]", async () => {
|
|
197
|
+
const deck = makeDeck([
|
|
198
|
+
{
|
|
199
|
+
...baseElement,
|
|
200
|
+
id: "t1",
|
|
201
|
+
type: "text",
|
|
202
|
+
x: 100,
|
|
203
|
+
y: 100,
|
|
204
|
+
w: 1500,
|
|
205
|
+
h: 220,
|
|
206
|
+
text: "ELDORAUI",
|
|
207
|
+
fontFamily: "Inter",
|
|
208
|
+
fontSize: 120,
|
|
209
|
+
fontWeight: 700,
|
|
210
|
+
italic: false,
|
|
211
|
+
underline: false,
|
|
212
|
+
strike: false,
|
|
213
|
+
color: "#FFFFFF",
|
|
214
|
+
align: "left",
|
|
215
|
+
vAlign: "top",
|
|
216
|
+
lineHeight: 1,
|
|
217
|
+
letterSpacing: 0,
|
|
218
|
+
runs: [
|
|
219
|
+
{ text: "ELDORA", color: "#FFFFFF" },
|
|
220
|
+
{ text: "UI", color: "#0F1B3D" },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
const out = await roundtrip(deck);
|
|
226
|
+
const text = out.slides[0].elements.find((e) => e.type === "text");
|
|
227
|
+
expect(text?.type).toBe("text");
|
|
228
|
+
if (text?.type !== "text") return;
|
|
229
|
+
// Concatenated text survives
|
|
230
|
+
expect(text.text.replace(/\s+/g, "")).toBe("ELDORAUI");
|
|
231
|
+
// The two distinct colors come back as separate runs
|
|
232
|
+
expect(text.runs).toBeTruthy();
|
|
233
|
+
expect(text.runs!.length).toBeGreaterThanOrEqual(2);
|
|
234
|
+
const colors = (text.runs ?? [])
|
|
235
|
+
.map((r) => (r.color ?? "").toUpperCase())
|
|
236
|
+
.filter(Boolean);
|
|
237
|
+
expect(colors).toContain("#FFFFFF");
|
|
238
|
+
expect(colors).toContain("#0F1B3D");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import pptxgen from "pptxgenjs";
|
|
2
|
+
import type {
|
|
3
|
+
Deck,
|
|
4
|
+
Slide,
|
|
5
|
+
SlideElement,
|
|
6
|
+
TextElement,
|
|
7
|
+
ShapeElement,
|
|
8
|
+
ShapeKind,
|
|
9
|
+
ImageElement,
|
|
10
|
+
LineElement,
|
|
11
|
+
TableElement,
|
|
12
|
+
IconElement,
|
|
13
|
+
EmbedElement,
|
|
14
|
+
} from "@/lib/types";
|
|
15
|
+
import { pxToInches, pxToPoints } from "./units";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Serialize a Slidewise Deck to a real PPTX blob. Round-trips well for the
|
|
19
|
+
* element types Slidewise natively supports (text, shape, image, line,
|
|
20
|
+
* table, icon, embed). UnknownElement and entrance animations are dropped
|
|
21
|
+
* with a warning — proper preservation requires bypassing pptxgenjs and
|
|
22
|
+
* is out of scope for v1.
|
|
23
|
+
*/
|
|
24
|
+
export async function serializeDeck(deck: Deck): Promise<Blob> {
|
|
25
|
+
const pptx = new pptxgen();
|
|
26
|
+
pptx.title = deck.title || "Untitled";
|
|
27
|
+
pptx.layout = "LAYOUT_WIDE"; // 13.333 × 7.5 in
|
|
28
|
+
|
|
29
|
+
for (const slide of deck.slides) {
|
|
30
|
+
addSlide(pptx, slide);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// pptxgenjs returns the requested type; outputType: "blob" → Blob.
|
|
34
|
+
const result = (await pptx.write({ outputType: "blob" })) as Blob;
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addSlide(pptx: pptxgen, slide: Slide): void {
|
|
39
|
+
const s = pptx.addSlide();
|
|
40
|
+
s.background = { color: hexNoHash(slide.background) };
|
|
41
|
+
|
|
42
|
+
const sorted = [...slide.elements].sort((a, b) => a.z - b.z);
|
|
43
|
+
for (const el of sorted) {
|
|
44
|
+
try {
|
|
45
|
+
addElement(s, el);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.warn(
|
|
48
|
+
`[slidewise/pptx] failed to write element ${el.id} (${el.type}):`,
|
|
49
|
+
err
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function addElement(s: pptxgen.Slide, el: SlideElement): void {
|
|
56
|
+
switch (el.type) {
|
|
57
|
+
case "text":
|
|
58
|
+
addText(s, el);
|
|
59
|
+
return;
|
|
60
|
+
case "shape":
|
|
61
|
+
addShape(s, el);
|
|
62
|
+
return;
|
|
63
|
+
case "image":
|
|
64
|
+
addImage(s, el);
|
|
65
|
+
return;
|
|
66
|
+
case "line":
|
|
67
|
+
addLine(s, el);
|
|
68
|
+
return;
|
|
69
|
+
case "table":
|
|
70
|
+
addTable(s, el);
|
|
71
|
+
return;
|
|
72
|
+
case "icon":
|
|
73
|
+
addIcon(s, el);
|
|
74
|
+
return;
|
|
75
|
+
case "embed":
|
|
76
|
+
addEmbed(s, el);
|
|
77
|
+
return;
|
|
78
|
+
case "unknown":
|
|
79
|
+
// Lossy: pptxgenjs has no public API for raw OOXML injection.
|
|
80
|
+
// Future work: post-process the generated zip to re-inject UnknownElement
|
|
81
|
+
// XML into the appropriate slide files for true round-trip.
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function geometry(el: SlideElement): {
|
|
87
|
+
x: number;
|
|
88
|
+
y: number;
|
|
89
|
+
w: number;
|
|
90
|
+
h: number;
|
|
91
|
+
rotate?: number;
|
|
92
|
+
} {
|
|
93
|
+
return {
|
|
94
|
+
x: pxToInches(el.x),
|
|
95
|
+
y: pxToInches(el.y),
|
|
96
|
+
w: pxToInches(el.w),
|
|
97
|
+
h: pxToInches(el.h),
|
|
98
|
+
rotate: el.rotation || undefined,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function addText(s: pptxgen.Slide, el: TextElement): void {
|
|
103
|
+
const baseOpts = {
|
|
104
|
+
...geometry(el),
|
|
105
|
+
fontFace: el.fontFamily,
|
|
106
|
+
fontSize: pxToPoints(el.fontSize),
|
|
107
|
+
color: hexNoHash(el.color),
|
|
108
|
+
bold: el.fontWeight >= 600,
|
|
109
|
+
italic: el.italic,
|
|
110
|
+
underline: el.underline ? ({ style: "sng" } as const) : undefined,
|
|
111
|
+
strike: el.strike ? ("sngStrike" as const) : undefined,
|
|
112
|
+
align: el.align,
|
|
113
|
+
valign: el.vAlign,
|
|
114
|
+
charSpacing: el.letterSpacing
|
|
115
|
+
? Math.round(el.letterSpacing * 100)
|
|
116
|
+
: undefined,
|
|
117
|
+
paraSpaceBefore: 0,
|
|
118
|
+
paraSpaceAfter: 0,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (!el.runs || !el.runs.length) {
|
|
122
|
+
s.addText(el.text, baseOpts);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Multi-run: pptxgenjs accepts an array of {text, options} objects and emits
|
|
127
|
+
// them as separate <a:r> within the same <a:p>. A run whose text contains
|
|
128
|
+
// "\n" is split so each piece becomes its own paragraph (we use a per-run
|
|
129
|
+
// `breakLine` flag on the trailing pieces).
|
|
130
|
+
const items: pptxgen.TextProps[] = [];
|
|
131
|
+
for (const r of el.runs) {
|
|
132
|
+
const pieces = r.text.split("\n");
|
|
133
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
134
|
+
const isLast = i === pieces.length - 1;
|
|
135
|
+
items.push({
|
|
136
|
+
text: pieces[i],
|
|
137
|
+
options: {
|
|
138
|
+
fontFace: r.fontFamily ?? el.fontFamily,
|
|
139
|
+
fontSize: pxToPoints(r.fontSize ?? el.fontSize),
|
|
140
|
+
color: hexNoHash(r.color ?? el.color),
|
|
141
|
+
bold: (r.fontWeight ?? el.fontWeight) >= 600,
|
|
142
|
+
italic: r.italic ?? el.italic,
|
|
143
|
+
underline: (r.underline ?? el.underline)
|
|
144
|
+
? ({ style: "sng" } as const)
|
|
145
|
+
: undefined,
|
|
146
|
+
strike: (r.strike ?? el.strike) ? ("sngStrike" as const) : undefined,
|
|
147
|
+
charSpacing: r.letterSpacing ?? el.letterSpacing
|
|
148
|
+
? Math.round((r.letterSpacing ?? el.letterSpacing) * 100)
|
|
149
|
+
: undefined,
|
|
150
|
+
breakLine: !isLast,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
s.addText(items, baseOpts);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const SHAPE_MAP: Record<ShapeKind, string> = {
|
|
159
|
+
rect: "rect",
|
|
160
|
+
rounded: "roundRect",
|
|
161
|
+
circle: "ellipse",
|
|
162
|
+
triangle: "triangle",
|
|
163
|
+
diamond: "diamond",
|
|
164
|
+
star: "star5",
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
function addShape(s: pptxgen.Slide, el: ShapeElement): void {
|
|
168
|
+
const shapeName = SHAPE_MAP[el.shape] ?? "rect";
|
|
169
|
+
// pptxgenjs accepts shape names as strings; the typed ShapeType enum is
|
|
170
|
+
// also exposed. Pass via `as unknown as` to bypass strict enum typing.
|
|
171
|
+
s.addShape(shapeName as unknown as Parameters<typeof s.addShape>[0], {
|
|
172
|
+
...geometry(el),
|
|
173
|
+
fill: { color: hexNoHash(el.fill) },
|
|
174
|
+
line: el.stroke
|
|
175
|
+
? {
|
|
176
|
+
color: hexNoHash(el.stroke),
|
|
177
|
+
width: el.strokeWidth ?? 1,
|
|
178
|
+
}
|
|
179
|
+
: { type: "none" },
|
|
180
|
+
rectRadius:
|
|
181
|
+
el.shape === "rounded" && el.radius != null
|
|
182
|
+
? clamp01(el.radius / Math.min(el.w, el.h))
|
|
183
|
+
: undefined,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function addImage(s: pptxgen.Slide, el: ImageElement): void {
|
|
188
|
+
const opts: Parameters<typeof s.addImage>[0] = {
|
|
189
|
+
...geometry(el),
|
|
190
|
+
sizing:
|
|
191
|
+
el.fit === "cover"
|
|
192
|
+
? { type: "cover", w: pxToInches(el.w), h: pxToInches(el.h) }
|
|
193
|
+
: el.fit === "contain"
|
|
194
|
+
? { type: "contain", w: pxToInches(el.w), h: pxToInches(el.h) }
|
|
195
|
+
: undefined,
|
|
196
|
+
};
|
|
197
|
+
if (isDataUrl(el.src)) {
|
|
198
|
+
opts.data = el.src;
|
|
199
|
+
} else {
|
|
200
|
+
opts.path = el.src;
|
|
201
|
+
}
|
|
202
|
+
s.addImage(opts);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function addLine(s: pptxgen.Slide, el: LineElement): void {
|
|
206
|
+
s.addShape(
|
|
207
|
+
"line" as unknown as Parameters<typeof s.addShape>[0],
|
|
208
|
+
{
|
|
209
|
+
...geometry(el),
|
|
210
|
+
line: {
|
|
211
|
+
color: hexNoHash(el.stroke),
|
|
212
|
+
width: el.strokeWidth,
|
|
213
|
+
dashType: el.dashed ? "dash" : "solid",
|
|
214
|
+
endArrowType: el.arrow ? "triangle" : "none",
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function addTable(s: pptxgen.Slide, el: TableElement): void {
|
|
221
|
+
if (!el.rows.length) return;
|
|
222
|
+
const rows = el.rows.map((row, ri) =>
|
|
223
|
+
row.map((cell) => ({
|
|
224
|
+
text: cell,
|
|
225
|
+
options: {
|
|
226
|
+
bold: ri === 0,
|
|
227
|
+
fill: { color: hexNoHash(ri === 0 ? el.headerFill : el.rowFill) },
|
|
228
|
+
color: hexNoHash(el.textColor),
|
|
229
|
+
fontSize: pxToPoints(el.fontSize),
|
|
230
|
+
valign: "middle" as const,
|
|
231
|
+
},
|
|
232
|
+
}))
|
|
233
|
+
);
|
|
234
|
+
s.addTable(rows, {
|
|
235
|
+
...geometry(el),
|
|
236
|
+
border: { type: "none", pt: 0, color: "FFFFFF" },
|
|
237
|
+
fontFace: "Inter",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function addIcon(s: pptxgen.Slide, el: IconElement): void {
|
|
242
|
+
// Render the icon as a centered text box with the unicode glyph.
|
|
243
|
+
const fontSize = Math.min(el.w, el.h) * 0.7;
|
|
244
|
+
s.addText(el.icon, {
|
|
245
|
+
...geometry(el),
|
|
246
|
+
fontFace: "Segoe UI Symbol",
|
|
247
|
+
fontSize: pxToPoints(fontSize),
|
|
248
|
+
color: hexNoHash(el.color),
|
|
249
|
+
align: "center",
|
|
250
|
+
valign: "middle",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function addEmbed(s: pptxgen.Slide, el: EmbedElement): void {
|
|
255
|
+
// Render embed as a labelled placeholder. PPTX has no first-class equivalent
|
|
256
|
+
// for "an arbitrary URL embed"; we capture intent as text + URL.
|
|
257
|
+
s.addText(
|
|
258
|
+
[
|
|
259
|
+
{ text: "Embed\n", options: { fontSize: 10, color: "9CA3AF" } },
|
|
260
|
+
{ text: `${el.label}\n`, options: { bold: true, fontSize: 18 } },
|
|
261
|
+
{ text: el.url, options: { fontSize: 10, color: "9CA3AF" } },
|
|
262
|
+
],
|
|
263
|
+
{
|
|
264
|
+
...geometry(el),
|
|
265
|
+
fill: { color: "0E1330" },
|
|
266
|
+
color: "FFFFFF",
|
|
267
|
+
align: "center",
|
|
268
|
+
valign: "middle",
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// -- helpers ----------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
function hexNoHash(color: string): string {
|
|
276
|
+
if (!color) return "000000";
|
|
277
|
+
const c = color.trim();
|
|
278
|
+
if (c.startsWith("#")) return c.slice(1).toUpperCase();
|
|
279
|
+
// rgba()/rgb() → strip; pptxgenjs only accepts hex.
|
|
280
|
+
const rgb = c.match(/^rgba?\(([^)]+)\)$/i);
|
|
281
|
+
if (rgb) {
|
|
282
|
+
const parts = rgb[1].split(",").map((p) => parseInt(p.trim(), 10));
|
|
283
|
+
if (parts.length >= 3) {
|
|
284
|
+
return parts
|
|
285
|
+
.slice(0, 3)
|
|
286
|
+
.map((n) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0"))
|
|
287
|
+
.join("")
|
|
288
|
+
.toUpperCase();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return c.toUpperCase();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isDataUrl(src: string): boolean {
|
|
295
|
+
return /^data:image\//i.test(src);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function clamp01(n: number): number {
|
|
299
|
+
return Math.max(0, Math.min(1, n));
|
|
300
|
+
}
|