pptxtojson-pro 0.1.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/.babelrc.cjs +15 -0
- package/.eslintignore +3 -0
- package/.eslintrc.cjs +78 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/favicon.ico +0 -0
- package/index.html +541 -0
- package/package.json +56 -0
- package/rollup.config.js +42 -0
- package/scripts/extract-pptx-structure.js +115 -0
- package/scripts/transvert.js +34 -0
- package/src/adapter/toPptxtojson.ts +46 -0
- package/src/adapter/types.ts +330 -0
- package/src/export/serializePresentation.ts +200 -0
- package/src/index.ts +27 -0
- package/src/model/Layout.ts +218 -0
- package/src/model/Master.ts +114 -0
- package/src/model/Presentation.ts +502 -0
- package/src/model/Slide.ts +386 -0
- package/src/model/Theme.ts +95 -0
- package/src/model/nodes/BaseNode.ts +169 -0
- package/src/model/nodes/ChartNode.ts +55 -0
- package/src/model/nodes/GroupNode.ts +62 -0
- package/src/model/nodes/PicNode.ts +102 -0
- package/src/model/nodes/ShapeNode.ts +289 -0
- package/src/model/nodes/TableNode.ts +135 -0
- package/src/parser/RelParser.ts +81 -0
- package/src/parser/XmlParser.ts +101 -0
- package/src/parser/ZipParser.ts +277 -0
- package/src/parser/units.ts +59 -0
- package/src/serializer/RenderContext.ts +79 -0
- package/src/serializer/StyleResolver.ts +821 -0
- package/src/serializer/backgroundSerializer.ts +143 -0
- package/src/serializer/borderMapper.ts +93 -0
- package/src/serializer/chartSerializer.ts +97 -0
- package/src/serializer/fillMapper.ts +224 -0
- package/src/serializer/groupSerializer.ts +94 -0
- package/src/serializer/imageSerializer.ts +330 -0
- package/src/serializer/index.ts +27 -0
- package/src/serializer/shapeSerializer.ts +694 -0
- package/src/serializer/slideSerializer.ts +250 -0
- package/src/serializer/tableSerializer.ts +66 -0
- package/src/serializer/textSerializer.md +70 -0
- package/src/serializer/textSerializer.ts +1019 -0
- package/src/shapes/customGeometry.ts +178 -0
- package/src/shapes/presets.ts +6587 -0
- package/src/shapes/shapeArc.ts +44 -0
- package/src/types/vendor-shims.d.ts +20 -0
- package/src/utils/color.ts +488 -0
- package/src/utils/emfParser.ts +298 -0
- package/src/utils/media.ts +73 -0
- package/src/utils/mediaWebConvert.ts +100 -0
- package/src/utils/rgbaToPng.ts +33 -0
- package/src/utils/urlSafety.ts +17 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image serializer — converts PicNodeData into positioned HTML image/video/audio elements.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PicNodeData } from '../model/nodes/PicNode';
|
|
6
|
+
import type { RenderContext } from './RenderContext';
|
|
7
|
+
import { SafeXmlNode } from '../parser/XmlParser';
|
|
8
|
+
import { encodeMediaForWebDisplay } from '../utils/mediaWebConvert';
|
|
9
|
+
import { lineStyleToBorder } from './borderMapper';
|
|
10
|
+
import type { Image, Video, Audio } from '../adapter/types';
|
|
11
|
+
import { getOrCreateBlobUrl, resolveMediaPath } from '../utils/media';
|
|
12
|
+
|
|
13
|
+
const PX_TO_PT = 0.75;
|
|
14
|
+
|
|
15
|
+
function pxToPt(px: number): number {
|
|
16
|
+
return px * PX_TO_PT;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if a file extension is an unsupported legacy format (WMF only now; EMF is handled).
|
|
21
|
+
*/
|
|
22
|
+
function isUnsupportedFormat(path: string): boolean {
|
|
23
|
+
const ext = path.split('.').pop()?.toLowerCase() || '';
|
|
24
|
+
return ext === 'wmf';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if a file path is an EMF image.
|
|
29
|
+
*/
|
|
30
|
+
function isEmfFormat(path: string): boolean {
|
|
31
|
+
const ext = path.split('.').pop()?.toLowerCase() || '';
|
|
32
|
+
return ext === 'emf';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** OOXML fixed-point scale (100000 = 100%). */
|
|
36
|
+
const OOXML_100K = 100000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build `filters` for PPTist: `sharpen`, `colorTemperature`, `saturation`, `brightness`, `contrast`.
|
|
40
|
+
*
|
|
41
|
+
* - **ISO / DrawingML**: `<a:lum bright contrast>` on `<a:blip>`.
|
|
42
|
+
* - **Office 2010+** (same as legacy `src1/fill.js` `getPicFilters`): `a:extLst` → `ext` →
|
|
43
|
+
* `a14:imgProps` / `a14:imgLayer` / `a14:imgEffect` → `a14:saturation`, `a14:brightnessContrast`,
|
|
44
|
+
* `a14:sharpenSoften`, `a14:colorTemperature`.
|
|
45
|
+
*
|
|
46
|
+
* Extension effects are applied after `lum` and may override brightness/contrast when both exist.
|
|
47
|
+
*/
|
|
48
|
+
function buildImageFilters(node: PicNodeData): Image['filters'] | undefined {
|
|
49
|
+
const blipFill = node.source.child('blipFill');
|
|
50
|
+
if (!blipFill.exists()) return undefined;
|
|
51
|
+
const blip = blipFill.child('blip');
|
|
52
|
+
if (!blip.exists()) return undefined;
|
|
53
|
+
|
|
54
|
+
const out: NonNullable<Image['filters']> = {};
|
|
55
|
+
|
|
56
|
+
applyLumToFilters(blip, out);
|
|
57
|
+
applyExtLstImageEffectsToFilters(blip, out);
|
|
58
|
+
|
|
59
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `<a:lum>` — brightness / contrast (values typically −100000…100000, scale 100000 = 100%). */
|
|
63
|
+
function applyLumToFilters(blip: SafeXmlNode, out: NonNullable<Image['filters']>): void {
|
|
64
|
+
const lum = blip.child('lum');
|
|
65
|
+
if (!lum.exists()) return;
|
|
66
|
+
const bright = lum.numAttr('bright');
|
|
67
|
+
const contrast = lum.numAttr('contrast');
|
|
68
|
+
if (bright !== undefined && bright !== 0) {
|
|
69
|
+
out.brightness = bright / OOXML_100K;
|
|
70
|
+
}
|
|
71
|
+
if (contrast !== undefined && contrast !== 0) {
|
|
72
|
+
out.contrast = contrast / OOXML_100K;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `a:extLst` / `a14:img*` image adjustments (namespace-agnostic `localName` from DOM).
|
|
78
|
+
*/
|
|
79
|
+
function applyExtLstImageEffectsToFilters(blip: SafeXmlNode, out: NonNullable<Image['filters']>): void {
|
|
80
|
+
const extLst = blip.child('extLst');
|
|
81
|
+
if (!extLst.exists()) return;
|
|
82
|
+
|
|
83
|
+
for (const ext of extLst.children()) {
|
|
84
|
+
if (ext.localName !== 'ext') continue;
|
|
85
|
+
const imgProps = ext.child('imgProps');
|
|
86
|
+
if (!imgProps.exists()) continue;
|
|
87
|
+
const imgLayer = imgProps.child('imgLayer');
|
|
88
|
+
if (!imgLayer.exists()) continue;
|
|
89
|
+
|
|
90
|
+
for (const imgEffect of imgLayer.children()) {
|
|
91
|
+
if (imgEffect.localName !== 'imgEffect') continue;
|
|
92
|
+
for (const el of imgEffect.allChildren()) {
|
|
93
|
+
switch (el.localName) {
|
|
94
|
+
case 'saturation': {
|
|
95
|
+
const sat = el.numAttr('sat');
|
|
96
|
+
if (sat !== undefined) {
|
|
97
|
+
out.saturation = sat / OOXML_100K;
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case 'brightnessContrast': {
|
|
102
|
+
const bright = el.numAttr('bright');
|
|
103
|
+
const contrast = el.numAttr('contrast');
|
|
104
|
+
if (bright !== undefined && bright !== 0) {
|
|
105
|
+
out.brightness = bright / OOXML_100K;
|
|
106
|
+
}
|
|
107
|
+
if (contrast !== undefined && contrast !== 0) {
|
|
108
|
+
out.contrast = contrast / OOXML_100K;
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case 'sharpenSoften': {
|
|
113
|
+
const amount = el.numAttr('amount');
|
|
114
|
+
if (amount !== undefined && amount !== 0) {
|
|
115
|
+
// Positive = sharpen, negative = soften (PPTist only has `sharpen`; use signed value).
|
|
116
|
+
out.sharpen = amount / OOXML_100K;
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'colorTemperature': {
|
|
121
|
+
const ct = el.numAttr('colorTemp');
|
|
122
|
+
if (ct !== undefined) {
|
|
123
|
+
out.colorTemperature = ct;
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
default:
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve a media URL from a relationship ID.
|
|
137
|
+
*/
|
|
138
|
+
function resolveMediaUrl(rId: string | undefined, ctx: RenderContext): string | undefined {
|
|
139
|
+
if (!rId) return undefined;
|
|
140
|
+
|
|
141
|
+
const rel = ctx.slide.rels.get(rId);
|
|
142
|
+
if (!rel) return undefined;
|
|
143
|
+
|
|
144
|
+
// Check if target is an external URL
|
|
145
|
+
if (rel.target.startsWith('http://') || rel.target.startsWith('https://')) {
|
|
146
|
+
return rel.target;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Resolve from embedded media
|
|
150
|
+
const mediaPath = resolveMediaPath(rel.target);
|
|
151
|
+
const data = ctx.presentation.media.get(mediaPath);
|
|
152
|
+
if (!data) return undefined;
|
|
153
|
+
|
|
154
|
+
return getOrCreateBlobUrl(mediaPath, data, ctx.mediaUrlCache);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render a video element.
|
|
159
|
+
*/
|
|
160
|
+
function renderVideo(
|
|
161
|
+
node: PicNodeData,
|
|
162
|
+
ctx: RenderContext,
|
|
163
|
+
order: number,
|
|
164
|
+
box: { left: number; top: number; width: number; height: number },
|
|
165
|
+
): Video {
|
|
166
|
+
// Try to get video URL from mediaRId
|
|
167
|
+
const videoUrl = resolveMediaUrl(node.mediaRId, ctx);
|
|
168
|
+
|
|
169
|
+
// Also try to show poster image from blipEmbed
|
|
170
|
+
let posterUrl: string | undefined;
|
|
171
|
+
if (node.blipEmbed) {
|
|
172
|
+
const rel = ctx.slide.rels.get(node.blipEmbed);
|
|
173
|
+
if (rel) {
|
|
174
|
+
const mediaPath = resolveMediaPath(rel.target);
|
|
175
|
+
const data = ctx.presentation.media.get(mediaPath);
|
|
176
|
+
if (data && !isUnsupportedFormat(mediaPath)) {
|
|
177
|
+
posterUrl = getOrCreateBlobUrl(mediaPath, data, ctx.mediaUrlCache);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const blob = videoUrl || undefined;
|
|
183
|
+
const src = posterUrl ?? videoUrl ?? undefined;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
type: 'video',
|
|
187
|
+
...box,
|
|
188
|
+
blob,
|
|
189
|
+
src,
|
|
190
|
+
order,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Render an audio element.
|
|
196
|
+
*/
|
|
197
|
+
function renderAudio(
|
|
198
|
+
node: PicNodeData,
|
|
199
|
+
ctx: RenderContext,
|
|
200
|
+
order: number,
|
|
201
|
+
box: { left: number; top: number; width: number; height: number },
|
|
202
|
+
): Audio {
|
|
203
|
+
const audioUrl = resolveMediaUrl(node.mediaRId, ctx);
|
|
204
|
+
const blob = audioUrl || '';
|
|
205
|
+
// TODO: optional cover image from blipEmbed
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
type: 'audio',
|
|
209
|
+
...box,
|
|
210
|
+
blob,
|
|
211
|
+
order,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Render an image element.
|
|
217
|
+
*/
|
|
218
|
+
function renderImage(
|
|
219
|
+
node: PicNodeData,
|
|
220
|
+
ctx: RenderContext,
|
|
221
|
+
order: number,
|
|
222
|
+
box: { left: number; top: number; width: number; height: number },
|
|
223
|
+
): Image {
|
|
224
|
+
const embedId = node.blipEmbed;
|
|
225
|
+
let src = '';
|
|
226
|
+
|
|
227
|
+
if (!embedId) {
|
|
228
|
+
return buildImage(node, ctx, order, box, src, undefined);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const rel = ctx.slide.rels.get(embedId);
|
|
232
|
+
if (!rel) {
|
|
233
|
+
return buildImage(node, ctx, order, box, src, undefined);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const mediaPath = resolveMediaPath(rel.target);
|
|
237
|
+
|
|
238
|
+
const data = ctx.presentation.media.get(mediaPath);
|
|
239
|
+
if (!data) {
|
|
240
|
+
return buildImage(node, ctx, order, box, src, undefined);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
244
|
+
|
|
245
|
+
src = encodeMediaForWebDisplay(mediaPath, bytes);
|
|
246
|
+
return buildImage(node, ctx, order, box, src, buildImageFilters(node));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildImage(
|
|
250
|
+
node: PicNodeData,
|
|
251
|
+
ctx: RenderContext,
|
|
252
|
+
order: number,
|
|
253
|
+
box: { left: number; top: number; width: number; height: number },
|
|
254
|
+
src: string,
|
|
255
|
+
filters: Image['filters'] | undefined,
|
|
256
|
+
): Image {
|
|
257
|
+
const spPr = node.source.child('spPr');
|
|
258
|
+
const ln = spPr.exists() ? spPr.child('ln') : node.source.child('__none__');
|
|
259
|
+
const borderResult = ln.exists()
|
|
260
|
+
? lineStyleToBorder(ln, ctx)
|
|
261
|
+
: {
|
|
262
|
+
border: { borderColor: '#000000', borderWidth: 0, borderType: 'solid' as const },
|
|
263
|
+
borderStrokeDasharray: '',
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let rect: Image['rect'] | undefined;
|
|
267
|
+
if (
|
|
268
|
+
node.crop &&
|
|
269
|
+
(node.crop.top !== 0 || node.crop.bottom !== 0 || node.crop.left !== 0 || node.crop.right !== 0)
|
|
270
|
+
) {
|
|
271
|
+
// OOXML srcRect
|
|
272
|
+
rect = {
|
|
273
|
+
t: node.crop.top,
|
|
274
|
+
b: node.crop.bottom,
|
|
275
|
+
l: node.crop.left,
|
|
276
|
+
r: node.crop.right,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
type: 'image',
|
|
282
|
+
...box,
|
|
283
|
+
src,
|
|
284
|
+
rotate: node.rotation,
|
|
285
|
+
isFlipH: node.flipH,
|
|
286
|
+
isFlipV: node.flipV,
|
|
287
|
+
order,
|
|
288
|
+
rect,
|
|
289
|
+
geom: 'rect',
|
|
290
|
+
borderColor: borderResult.border.borderColor,
|
|
291
|
+
borderWidth: borderResult.border.borderWidth,
|
|
292
|
+
borderType: borderResult.border.borderType,
|
|
293
|
+
borderStrokeDasharray: borderResult.borderStrokeDasharray || '',
|
|
294
|
+
...(filters && Object.keys(filters).length > 0 ? { filters } : {}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Serialize picture node to Image, Video, or Audio element.
|
|
300
|
+
*
|
|
301
|
+
* Handles:
|
|
302
|
+
* - Standard images (png, jpg, gif, svg, bmp)
|
|
303
|
+
* - Unsupported formats (wmf) with placeholder
|
|
304
|
+
* - Video elements with controls
|
|
305
|
+
* - Audio elements with controls
|
|
306
|
+
* - Crop via `rect` (fractions)
|
|
307
|
+
* - Rotation and flip on Image
|
|
308
|
+
*/
|
|
309
|
+
export function pictureToElement(
|
|
310
|
+
node: PicNodeData,
|
|
311
|
+
ctx: RenderContext,
|
|
312
|
+
order: number,
|
|
313
|
+
): Image | Video | Audio {
|
|
314
|
+
const box = {
|
|
315
|
+
left: pxToPt(node.position.x),
|
|
316
|
+
top: pxToPt(node.position.y),
|
|
317
|
+
width: pxToPt(node.size.w),
|
|
318
|
+
height: pxToPt(node.size.h),
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
if (node.isVideo) {
|
|
322
|
+
return renderVideo(node, ctx, order, box);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (node.isAudio) {
|
|
326
|
+
return renderAudio(node, ctx, order, box);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return renderImage(node, ctx, order, box);
|
|
330
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serializer layer: converts PresentationData (and render context) into
|
|
3
|
+
* pptxtojson/PPTist JSON using the same resolution flow as the reference renderer.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { createRenderContext, type RenderContext } from './RenderContext';
|
|
7
|
+
export { renderTextBody, type RenderTextBodyOptions } from './textSerializer';
|
|
8
|
+
export {
|
|
9
|
+
spPrToFill,
|
|
10
|
+
resolveShapeFill,
|
|
11
|
+
spPrHasExplicitFill,
|
|
12
|
+
gradientFillDataToValue,
|
|
13
|
+
type SpPrToFillOptions,
|
|
14
|
+
} from './fillMapper';
|
|
15
|
+
export { lineStyleToBorder, dashArrayForKind, type BorderResult } from './borderMapper';
|
|
16
|
+
export { slideToSlide, nodeToElement } from './slideSerializer';
|
|
17
|
+
export {
|
|
18
|
+
resolveSlideFill,
|
|
19
|
+
renderBgPr as bgPrToFill,
|
|
20
|
+
renderBgRef as bgRefToFill,
|
|
21
|
+
} from './backgroundSerializer';
|
|
22
|
+
export { renderShape, shapeToElement } from './shapeSerializer';
|
|
23
|
+
export { pictureToElement } from './imageSerializer';
|
|
24
|
+
export { tableToElement } from './tableSerializer';
|
|
25
|
+
export { chartToElement } from './chartSerializer';
|
|
26
|
+
export { groupToElement, type NodeToElement } from './groupSerializer';
|
|
27
|
+
export type { Slide as OutputSlide } from '../adapter/types';
|