quicklook-pptx-renderer 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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +266 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +175 -0
  5. package/dist/diff/compare.d.ts +17 -0
  6. package/dist/diff/compare.js +71 -0
  7. package/dist/index.d.ts +29 -0
  8. package/dist/index.js +72 -0
  9. package/dist/lint.d.ts +27 -0
  10. package/dist/lint.js +328 -0
  11. package/dist/mapper/bleed-map.d.ts +6 -0
  12. package/dist/mapper/bleed-map.js +1 -0
  13. package/dist/mapper/constants.d.ts +2 -0
  14. package/dist/mapper/constants.js +4 -0
  15. package/dist/mapper/drawable-mapper.d.ts +16 -0
  16. package/dist/mapper/drawable-mapper.js +1464 -0
  17. package/dist/mapper/html-generator.d.ts +13 -0
  18. package/dist/mapper/html-generator.js +539 -0
  19. package/dist/mapper/image-mapper.d.ts +14 -0
  20. package/dist/mapper/image-mapper.js +70 -0
  21. package/dist/mapper/nano-malloc.d.ts +130 -0
  22. package/dist/mapper/nano-malloc.js +197 -0
  23. package/dist/mapper/ql-bleed.d.ts +35 -0
  24. package/dist/mapper/ql-bleed.js +254 -0
  25. package/dist/mapper/shape-mapper.d.ts +41 -0
  26. package/dist/mapper/shape-mapper.js +2384 -0
  27. package/dist/mapper/slide-mapper.d.ts +4 -0
  28. package/dist/mapper/slide-mapper.js +112 -0
  29. package/dist/mapper/style-builder.d.ts +12 -0
  30. package/dist/mapper/style-builder.js +30 -0
  31. package/dist/mapper/text-mapper.d.ts +14 -0
  32. package/dist/mapper/text-mapper.js +302 -0
  33. package/dist/model/enums.d.ts +25 -0
  34. package/dist/model/enums.js +2 -0
  35. package/dist/model/types.d.ts +482 -0
  36. package/dist/model/types.js +7 -0
  37. package/dist/package/content-types.d.ts +1 -0
  38. package/dist/package/content-types.js +4 -0
  39. package/dist/package/package.d.ts +10 -0
  40. package/dist/package/package.js +52 -0
  41. package/dist/package/relationships.d.ts +6 -0
  42. package/dist/package/relationships.js +25 -0
  43. package/dist/package/zip.d.ts +6 -0
  44. package/dist/package/zip.js +17 -0
  45. package/dist/reader/color.d.ts +3 -0
  46. package/dist/reader/color.js +79 -0
  47. package/dist/reader/drawing.d.ts +17 -0
  48. package/dist/reader/drawing.js +403 -0
  49. package/dist/reader/effects.d.ts +2 -0
  50. package/dist/reader/effects.js +83 -0
  51. package/dist/reader/fill.d.ts +2 -0
  52. package/dist/reader/fill.js +94 -0
  53. package/dist/reader/presentation.d.ts +5 -0
  54. package/dist/reader/presentation.js +127 -0
  55. package/dist/reader/slide-layout.d.ts +2 -0
  56. package/dist/reader/slide-layout.js +28 -0
  57. package/dist/reader/slide-master.d.ts +4 -0
  58. package/dist/reader/slide-master.js +49 -0
  59. package/dist/reader/slide.d.ts +2 -0
  60. package/dist/reader/slide.js +26 -0
  61. package/dist/reader/text-list-style.d.ts +2 -0
  62. package/dist/reader/text-list-style.js +9 -0
  63. package/dist/reader/text.d.ts +5 -0
  64. package/dist/reader/text.js +295 -0
  65. package/dist/reader/theme.d.ts +2 -0
  66. package/dist/reader/theme.js +109 -0
  67. package/dist/reader/transform.d.ts +2 -0
  68. package/dist/reader/transform.js +21 -0
  69. package/dist/render/image-renderer.d.ts +3 -0
  70. package/dist/render/image-renderer.js +33 -0
  71. package/dist/render/renderer.d.ts +9 -0
  72. package/dist/render/renderer.js +178 -0
  73. package/dist/render/shape-renderer.d.ts +3 -0
  74. package/dist/render/shape-renderer.js +175 -0
  75. package/dist/render/text-renderer.d.ts +3 -0
  76. package/dist/render/text-renderer.js +152 -0
  77. package/dist/resolve/color-resolver.d.ts +18 -0
  78. package/dist/resolve/color-resolver.js +321 -0
  79. package/dist/resolve/font-map.d.ts +2 -0
  80. package/dist/resolve/font-map.js +66 -0
  81. package/dist/resolve/inheritance.d.ts +5 -0
  82. package/dist/resolve/inheritance.js +106 -0
  83. package/package.json +74 -0
@@ -0,0 +1,13 @@
1
+ import type { Presentation, QLBleedData } from "../model/types.js";
2
+ import type { PptxPackage } from "../package/package.js";
3
+ export interface MapperOptions {
4
+ pkg?: PptxPackage;
5
+ /** QL-extracted bleed data. When provided, injects bleed PDF images at the
6
+ * correct z-order positions per slide. Extracted from qlmanage via extractQLBleed(). */
7
+ qlBleed?: QLBleedData;
8
+ }
9
+ export interface HtmlOutput {
10
+ html: string;
11
+ attachments: Map<string, Buffer>;
12
+ }
13
+ export declare function generateHtml(presentation: Presentation, options?: MapperOptions): Promise<HtmlOutput>;
@@ -0,0 +1,539 @@
1
+ import { StyleBuilder } from "./style-builder.js";
2
+ import { mapSlide } from "./slide-mapper.js";
3
+ import { clearDrawableCache } from "./drawable-mapper.js";
4
+ import { resetAttachmentCounter, nextAttachmentIndex } from "./image-mapper.js";
5
+ import { emuToPx } from "./constants.js";
6
+ // Global CSS matching OfficeImport's output exactly
7
+ const GLOBAL_CSS = `div
8
+ {
9
+ margin-top: 0;
10
+ margin-bottom: 0;
11
+ font-family:Arial, sans-serif;
12
+ }
13
+ p
14
+ {
15
+ margin-top: 0;
16
+ margin-bottom: 0;
17
+ word-wrap:break-word;
18
+ }
19
+ table
20
+ {
21
+ border-collapse: collapse;
22
+ border-color: black;
23
+ border-style: solid;
24
+ border-width: thin;
25
+ }
26
+ td
27
+ {
28
+ word-wrap:break-word;
29
+ font-family:Arial;
30
+ vertical-align:top;
31
+ border-style: solid;
32
+ border-width: thin;
33
+ }
34
+ div.slide
35
+ {
36
+ position:relative;
37
+ }div.slide, div.loading-slide
38
+ {
39
+ overflow:hidden;
40
+ page-break-inside: avoid;
41
+ margin-top: 5px;
42
+ }
43
+ div.slide:first-of-type {
44
+ &nbsp;margin-top: 0;
45
+ }
46
+ div.loading-slide
47
+ {
48
+ position: absolute;
49
+ background:#BBBBBB;
50
+ }
51
+ @media screen {
52
+ body {
53
+ background: #ACB2BB;
54
+ }
55
+ div.slide
56
+ {
57
+ -webkit-box-shadow: 5px 5px 5px rgba(0, 0, 0, 0.5)
58
+ }
59
+ div.slide, div.loading-slide
60
+ {
61
+ margin-left: 8px;
62
+ margin-right: 8px;
63
+ }}`;
64
+ export async function generateHtml(presentation, options) {
65
+ const attachments = new Map();
66
+ resetAttachmentCounter();
67
+ clearDrawableCache();
68
+ // Derive slide pixel dimensions from the presentation's slide size (EMU → px)
69
+ const slideWidthPx = emuToPx(presentation.slideSize.cx);
70
+ const slideHeightPx = emuToPx(presentation.slideSize.cy);
71
+ const qlBleed = options?.qlBleed;
72
+ const styles = new StyleBuilder();
73
+ const slideParts = [];
74
+ let styleIndex = 0;
75
+ for (let i = 0; i < presentation.slides.length; i++) {
76
+ const slide = presentation.slides[i];
77
+ const master = slide.slideLayout.slideMaster;
78
+ const colorMap = slide.colorMapOverride
79
+ ?? slide.slideLayout.colorMapOverride
80
+ ?? master.colorMap;
81
+ const colorScheme = master.theme.colorScheme;
82
+ const fontScheme = master.theme.fontScheme;
83
+ const imageRels = new Map();
84
+ const slidePath = `ppt/slides/slide${i + 1}.xml`;
85
+ if (options?.pkg) {
86
+ try {
87
+ const rels = await options.pkg.getRelationships(slidePath);
88
+ for (const [rId, rel] of rels) {
89
+ if (rel.type === "image") {
90
+ const imgPath = options.pkg.resolveRelTarget(slidePath, rel.target);
91
+ const buf = await options.pkg.getPartBuffer(imgPath);
92
+ if (buf)
93
+ imageRels.set(rId, buf);
94
+ }
95
+ }
96
+ // Resolve chart/SmartArt/OLE fallback images for GraphicFrames
97
+ await resolveGraphicFrameFallbacks(slide, rels, slidePath, options.pkg);
98
+ // Resolve background image fill
99
+ await resolveBackgroundImageFill(slide, rels, slidePath, options.pkg);
100
+ // Resolve image fill blip data for shapes with picture fills
101
+ await resolveImageFills(slide.drawables, rels, slidePath, options.pkg);
102
+ // Also resolve image fills for layout and master drawables
103
+ // Follow slide → layout → master relationship chain
104
+ const layoutRel = [...rels.values()].find(r => r.type === "slideLayout");
105
+ if (layoutRel) {
106
+ const layoutPath = options.pkg.resolveRelTarget(slidePath, layoutRel.target);
107
+ try {
108
+ const layoutRels = await options.pkg.getRelationships(layoutPath);
109
+ await resolveImageFills(slide.slideLayout.drawables, layoutRels, layoutPath, options.pkg);
110
+ const masterRel = [...layoutRels.values()].find(r => r.type === "slideMaster");
111
+ if (masterRel) {
112
+ const masterPath = options.pkg.resolveRelTarget(layoutPath, masterRel.target);
113
+ try {
114
+ const masterRels = await options.pkg.getRelationships(masterPath);
115
+ await resolveImageFills(slide.slideLayout.slideMaster.drawables, masterRels, masterPath, options.pkg);
116
+ }
117
+ catch { /* master rels might not exist */ }
118
+ }
119
+ }
120
+ catch { /* layout rels might not exist */ }
121
+ }
122
+ }
123
+ catch { /* slide rels might not exist */ }
124
+ }
125
+ styles.reset(styleIndex);
126
+ const ctx = {
127
+ colorMap, colorScheme, fontScheme,
128
+ styleMatrix: master.theme.styleMatrix,
129
+ slide, pkg: options?.pkg,
130
+ slideIndex: i, slidePath, imageRels,
131
+ };
132
+ let slideContent = mapSlide(slide, presentation, styles, attachments, ctx, slideWidthPx, slideHeightPx);
133
+ // ── QL bleed injection ──
134
+ // 1. Remove own elements that QL replaced with bleed
135
+ // 2. Insert bleed PDF <img> tags at the correct z-order positions
136
+ const slideRemovals = qlBleed?.removals.get(i);
137
+ if (slideRemovals && slideRemovals.length > 0) {
138
+ slideContent = removeReplacedElements(slideContent, slideRemovals, attachments);
139
+ }
140
+ const slideBleed = qlBleed?.entries.get(i);
141
+ if (slideBleed && slideBleed.length > 0) {
142
+ slideContent = injectSlideBleed(slideContent, slideBleed, qlBleed, attachments);
143
+ }
144
+ const slideStyleSheet = styles.getStyleSheet();
145
+ styleIndex = styles.nextIndex;
146
+ slideParts.push(`<style type="text/css">\n${slideStyleSheet}\n</style>` +
147
+ `<div class="slide" style="top:0; left:0;">${slideContent}</div>`);
148
+ }
149
+ const viewportWidth = slideWidthPx + 16; // slide + 8px margin each side
150
+ const sizeCss = `div.slide, div.loading-slide { width: ${slideWidthPx}; height: ${slideHeightPx};}`;
151
+ const html = [
152
+ `<html><head><meta charset="utf-8">`,
153
+ `<meta name="viewport" content="width=${viewportWidth}, maximum-scale=4.0">`,
154
+ `<style type="text/css">@media print {\ndiv.loading-slide {\ndisplay: none;\n}\n}\n${GLOBAL_CSS}</style>`,
155
+ `<style type="text/css">${sizeCss}</style>`,
156
+ `</head><body>`,
157
+ ...slideParts,
158
+ `</body></html>`,
159
+ ].join("");
160
+ return { html, attachments };
161
+ }
162
+ /**
163
+ * Resolve fallback images for chart/SmartArt/OLE GraphicFrames.
164
+ * QuickLook (OfficeImport) renders these using pre-generated fallback images stored in the PPTX,
165
+ * NOT by parsing chart XML or diagram definitions.
166
+ *
167
+ * Chart: slide rels → chart part path → chart rels → image
168
+ * SmartArt: slide rels → drawing part path → drawing rels → image
169
+ * OLE: slide rels → oleObject → embedded image (typically via mc:AlternateContent)
170
+ */
171
+ async function resolveGraphicFrameFallbacks(slide, slideRels, slidePath, pkg) {
172
+ for (const d of allDrawables(slide.drawables)) {
173
+ if (d.drawableType !== "graphicFrame")
174
+ continue;
175
+ const gf = d;
176
+ if (gf.tableData || gf.fallbackImageData)
177
+ continue;
178
+ // Try chart fallback image, then parse chart XML
179
+ if (gf.chartRId) {
180
+ const chartRel = slideRels.get(gf.chartRId);
181
+ if (chartRel) {
182
+ const chartPath = pkg.resolveRelTarget(slidePath, chartRel.target);
183
+ const img = await findFallbackImage(chartPath, pkg);
184
+ if (img) {
185
+ gf.fallbackImageData = img;
186
+ continue;
187
+ }
188
+ // No fallback — parse chart XML for direct rendering
189
+ const chartXml = await pkg.getPartXml(chartPath);
190
+ if (chartXml) {
191
+ const cd = parseChartData(chartXml);
192
+ if (cd) {
193
+ gf.chartData = cd;
194
+ continue;
195
+ }
196
+ }
197
+ }
198
+ }
199
+ // Try SmartArt fallback — look for the drawing (dm) relationship, then its image
200
+ if (gf.smartArtRId) {
201
+ const dmRel = slideRels.get(gf.smartArtRId);
202
+ if (dmRel) {
203
+ const dmPath = pkg.resolveRelTarget(slidePath, dmRel.target);
204
+ const img = await findFallbackImage(dmPath, pkg);
205
+ if (img) {
206
+ gf.fallbackImageData = img;
207
+ continue;
208
+ }
209
+ }
210
+ }
211
+ // OLE objects: look for image relationship directly in slide rels
212
+ if (gf.oleRId) {
213
+ const oleRel = slideRels.get(gf.oleRId);
214
+ if (oleRel) {
215
+ // OLE objects often have a sibling image rel
216
+ for (const [, rel] of slideRels) {
217
+ if (rel.type === "image") {
218
+ const imgPath = pkg.resolveRelTarget(slidePath, rel.target);
219
+ const buf = await pkg.getPartBuffer(imgPath);
220
+ if (buf) {
221
+ gf.fallbackImageData = buf;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+ }
229
+ }
230
+ /** Find a fallback image by following the relationship chain of a part (chart/diagram). */
231
+ async function findFallbackImage(partPath, pkg) {
232
+ try {
233
+ const rels = await pkg.getRelationships(partPath);
234
+ // Look for image relationship type
235
+ for (const [, rel] of rels) {
236
+ if (rel.type === "image") {
237
+ const imgPath = pkg.resolveRelTarget(partPath, rel.target);
238
+ const buf = await pkg.getPartBuffer(imgPath);
239
+ if (buf)
240
+ return buf;
241
+ }
242
+ }
243
+ // Some charts store the fallback as "oleObject" or "userShapes" with an image
244
+ // Also check for a preview image in the chart directory
245
+ for (const [, rel] of rels) {
246
+ if ((rel.target.includes("media/") || rel.target.endsWith(".png") || rel.target.endsWith(".emf")) && !rel.target.endsWith(".xlsx")) {
247
+ const imgPath = pkg.resolveRelTarget(partPath, rel.target);
248
+ const buf = await pkg.getPartBuffer(imgPath);
249
+ if (buf)
250
+ return buf;
251
+ }
252
+ }
253
+ }
254
+ catch { /* part rels don't exist */ }
255
+ return null;
256
+ }
257
+ /** Parse chart XML into ChartData structure. */
258
+ function parseChartData(xml) {
259
+ const chart = xml.chartSpace?.chart ?? xml.chart;
260
+ if (!chart)
261
+ return null;
262
+ const plotArea = chart.plotArea;
263
+ if (!plotArea)
264
+ return null;
265
+ // Detect chart type
266
+ const toArray = (v) => v == null ? [] : Array.isArray(v) ? v : [v];
267
+ let type = "bar";
268
+ let direction = "col";
269
+ let grouping = "clustered";
270
+ let chartNode = null;
271
+ if (plotArea.barChart) {
272
+ chartNode = plotArea.barChart;
273
+ type = "bar";
274
+ direction = chartNode["barDir"]?.["@_val"] ?? chartNode["@_barDir"] ?? "col";
275
+ }
276
+ else if (plotArea.bar3DChart) {
277
+ chartNode = plotArea.bar3DChart;
278
+ type = "bar";
279
+ direction = chartNode["barDir"]?.["@_val"] ?? "col";
280
+ }
281
+ else if (plotArea.lineChart) {
282
+ chartNode = plotArea.lineChart;
283
+ type = "line";
284
+ }
285
+ else if (plotArea.pieChart) {
286
+ chartNode = plotArea.pieChart;
287
+ type = "pie";
288
+ }
289
+ else if (plotArea.areaChart) {
290
+ chartNode = plotArea.areaChart;
291
+ type = "area";
292
+ }
293
+ else if (plotArea.scatterChart) {
294
+ chartNode = plotArea.scatterChart;
295
+ type = "scatter";
296
+ }
297
+ else
298
+ return null;
299
+ if (chartNode.grouping)
300
+ grouping = chartNode.grouping["@_val"] ?? chartNode.grouping ?? "clustered";
301
+ const serNodes = toArray(chartNode.ser);
302
+ if (serNodes.length === 0)
303
+ return null;
304
+ // Parse categories from first series
305
+ let categories = [];
306
+ const catRef = serNodes[0].cat;
307
+ if (catRef?.strRef?.strCache) {
308
+ categories = toArray(catRef.strRef.strCache.pt).map((pt) => String(pt.v ?? ""));
309
+ }
310
+ else if (catRef?.numRef?.numCache) {
311
+ categories = toArray(catRef.numRef.numCache.pt).map((pt) => String(pt.v ?? ""));
312
+ }
313
+ // Parse series
314
+ const series = serNodes.map((ser) => {
315
+ let name = "";
316
+ if (ser.tx?.strRef?.strCache?.pt) {
317
+ const pts = toArray(ser.tx.strRef.strCache.pt);
318
+ name = String(pts[0]?.v ?? "");
319
+ }
320
+ else if (ser.tx?.v) {
321
+ name = String(ser.tx.v);
322
+ }
323
+ let values = [];
324
+ if (ser.val?.numRef?.numCache) {
325
+ values = toArray(ser.val.numRef.numCache.pt).map((pt) => Number(pt.v ?? 0));
326
+ }
327
+ return { name, values };
328
+ });
329
+ // Parse legend position
330
+ const legend = chart.legend;
331
+ const legendPos = legend?.legendPos?.["@_val"];
332
+ return { type, direction, grouping, categories, series, legendPos };
333
+ }
334
+ /** Flatten all drawables including nested group children. */
335
+ function allDrawables(drawables) {
336
+ const result = [];
337
+ for (const d of drawables) {
338
+ result.push(d);
339
+ if (d.drawableType === "grpSp") {
340
+ result.push(...allDrawables(d.children));
341
+ }
342
+ }
343
+ return result;
344
+ }
345
+ /** Resolve background image fill for a slide. */
346
+ async function resolveBackgroundImageFill(slide, slideRels, slidePath, pkg) {
347
+ const bg = slide.background
348
+ ?? slide.slideLayout.background
349
+ ?? slide.slideLayout.slideMaster.background;
350
+ if (bg?.fill?.type === "image" && bg.fill.blipRId && !bg.fill.blipData) {
351
+ // Try slide rels first, then layout/master rels
352
+ const rId = bg.fill.blipRId;
353
+ const paths = await resolveLayoutMasterPaths(slideRels, slidePath, pkg);
354
+ // Try each level: slide → layout → master
355
+ for (const { path, rels: relsPromise } of [
356
+ { path: slidePath, rels: Promise.resolve(slideRels) },
357
+ ...(paths.layoutPath ? [{ path: paths.layoutPath, rels: pkg.getRelationships(paths.layoutPath).catch(() => new Map()) }] : []),
358
+ ...(paths.masterPath ? [{ path: paths.masterPath, rels: pkg.getRelationships(paths.masterPath).catch(() => new Map()) }] : []),
359
+ ]) {
360
+ const rels = await relsPromise;
361
+ const rel = rels.get(rId);
362
+ if (rel) {
363
+ const imgPath = pkg.resolveRelTarget(path, rel.target);
364
+ const buf = await pkg.getPartBuffer(imgPath);
365
+ if (buf) {
366
+ bg.fill.blipData = buf;
367
+ return;
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
373
+ /** Resolve image fill blipRId → blipData for shapes with picture fills. */
374
+ async function resolveImageFills(drawables, rels, partPath, pkg) {
375
+ for (const d of allDrawables(drawables)) {
376
+ const fill = d.fill;
377
+ if (fill?.type === "image" && fill.blipRId && !fill.blipData) {
378
+ const rel = rels.get(fill.blipRId);
379
+ if (rel) {
380
+ const imgPath = pkg.resolveRelTarget(partPath, rel.target);
381
+ const buf = await pkg.getPartBuffer(imgPath);
382
+ if (buf)
383
+ fill.blipData = buf;
384
+ }
385
+ }
386
+ }
387
+ }
388
+ /** Resolve layout and master paths from a slide's relationships. */
389
+ async function resolveLayoutMasterPaths(slideRels, slidePath, pkg) {
390
+ const layoutRel = [...slideRels.values()].find(r => r.type === "slideLayout");
391
+ if (!layoutRel)
392
+ return {};
393
+ const layoutPath = pkg.resolveRelTarget(slidePath, layoutRel.target);
394
+ try {
395
+ const layoutRels = await pkg.getRelationships(layoutPath);
396
+ const masterRel = [...layoutRels.values()].find(r => r.type === "slideMaster");
397
+ if (masterRel) {
398
+ const masterPath = pkg.resolveRelTarget(layoutPath, masterRel.target);
399
+ return { layoutPath, masterPath };
400
+ }
401
+ return { layoutPath };
402
+ }
403
+ catch {
404
+ return { layoutPath };
405
+ }
406
+ }
407
+ /**
408
+ * Remove own elements that were replaced by bleed in QL output.
409
+ * These are elements in our HTML that QL doesn't have (cache hit replaced them).
410
+ */
411
+ function removeReplacedElements(slideHtml, removals, attachments) {
412
+ const elements = parseTopLevelElementStrings(slideHtml);
413
+ const kept = [];
414
+ for (const el of elements) {
415
+ // Check if this element should be removed
416
+ const styleMatch = el.match(/style="([^"]+)"/);
417
+ if (styleMatch) {
418
+ const pos = parseBleedPos(styleMatch[1]);
419
+ if (pos) {
420
+ const shouldRemove = removals.some(r => Math.abs(r.pos.top - pos.top) <= 2 &&
421
+ Math.abs(r.pos.left - pos.left) <= 2 &&
422
+ Math.abs(r.pos.width - pos.width) <= 2 &&
423
+ Math.abs(r.pos.height - pos.height) <= 2);
424
+ if (shouldRemove) {
425
+ // Also remove the attachment file to keep numbering clean
426
+ const srcMatch = el.match(/src="([^"]+)"/);
427
+ if (srcMatch)
428
+ attachments.delete(srcMatch[1]);
429
+ continue; // skip this element
430
+ }
431
+ }
432
+ }
433
+ kept.push(el);
434
+ }
435
+ return kept.join("");
436
+ }
437
+ /** Parse position from style string (used by removeReplacedElements). */
438
+ function parseBleedPos(style) {
439
+ const t = style.match(/top:\s*(-?\d+)/);
440
+ const l = style.match(/left:\s*(-?\d+)/);
441
+ const w = style.match(/width:\s*(-?\d+)/);
442
+ const h = style.match(/height:\s*(-?\d+)/);
443
+ if (!t || !l || !w || !h)
444
+ return null;
445
+ return { top: +t[1], left: +l[1], width: +w[1], height: +h[1] };
446
+ }
447
+ /**
448
+ * Inject bleed PDF <img> tags into a slide's rendered HTML.
449
+ *
450
+ * Bleed entries specify afterOwnElement: the number of own (non-bleed)
451
+ * elements after which to insert each bleed <img>.
452
+ *
453
+ * Works by parsing the slide HTML into top-level elements, inserting
454
+ * bleed entries at the correct positions, and reassembling.
455
+ */
456
+ function injectSlideBleed(slideHtml, bleedEntries, bleedData, attachments) {
457
+ // Parse slide HTML into top-level element strings
458
+ const elements = parseTopLevelElementStrings(slideHtml);
459
+ // Group bleed entries by afterOwnElement position
460
+ const bleedByPos = new Map();
461
+ for (const entry of bleedEntries) {
462
+ const list = bleedByPos.get(entry.afterOwnElement) ?? [];
463
+ list.push(entry);
464
+ bleedByPos.set(entry.afterOwnElement, list);
465
+ }
466
+ // Rebuild with bleed injected
467
+ const result = [];
468
+ // Inject any bleeds before all own elements (afterOwnElement = 0)
469
+ const before = bleedByPos.get(0);
470
+ if (before) {
471
+ for (const b of before)
472
+ result.push(makeBleedImg(b, bleedData, attachments));
473
+ }
474
+ for (let i = 0; i < elements.length; i++) {
475
+ result.push(elements[i]);
476
+ const after = bleedByPos.get(i + 1);
477
+ if (after) {
478
+ for (const b of after)
479
+ result.push(makeBleedImg(b, bleedData, attachments));
480
+ }
481
+ }
482
+ return result.join("");
483
+ }
484
+ /** Create an <img> tag for a bleed entry, adding the PDF to attachments. */
485
+ function makeBleedImg(entry, bleedData, attachments) {
486
+ const pdfBuf = bleedData.pdfs.get(entry.style);
487
+ if (!pdfBuf)
488
+ return "";
489
+ const idx = nextAttachmentIndex();
490
+ const name = `Attachment${idx}.pdf`;
491
+ attachments.set(name, pdfBuf);
492
+ return `<img src="${name}" style="${entry.style}">`;
493
+ }
494
+ /**
495
+ * Parse slide HTML into an array of top-level element strings.
496
+ * Tracks div nesting depth to find element boundaries.
497
+ */
498
+ function parseTopLevelElementStrings(html) {
499
+ const elements = [];
500
+ let depth = 0;
501
+ let i = 0;
502
+ let elemStart = -1;
503
+ while (i < html.length) {
504
+ if (html[i] !== '<') {
505
+ i++;
506
+ continue;
507
+ }
508
+ if (html[i + 1] === '/') {
509
+ const end = html.indexOf('>', i);
510
+ if (end === -1)
511
+ break;
512
+ depth--;
513
+ if (depth === 0 && elemStart >= 0) {
514
+ elements.push(html.substring(elemStart, end + 1));
515
+ elemStart = -1;
516
+ }
517
+ i = end + 1;
518
+ continue;
519
+ }
520
+ const tagEnd = html.indexOf('>', i);
521
+ if (tagEnd === -1)
522
+ break;
523
+ const tag = html.substring(i, tagEnd + 1);
524
+ const selfClose = tag.startsWith('<img ') || tag.startsWith('<col ') ||
525
+ tag.startsWith('<br') || tag.endsWith('/>');
526
+ if (depth === 0) {
527
+ if (selfClose) {
528
+ elements.push(tag);
529
+ i = tagEnd + 1;
530
+ continue;
531
+ }
532
+ elemStart = i;
533
+ }
534
+ if (!selfClose)
535
+ depth++;
536
+ i = tagEnd + 1;
537
+ }
538
+ return elements;
539
+ }
@@ -0,0 +1,14 @@
1
+ import type { Picture, ColorMap, ColorScheme, FontScheme } from "../model/types.js";
2
+ import type { PptxPackage } from "../package/package.js";
3
+ import type { StyleBuilder } from "./style-builder.js";
4
+ export interface ImageMapperContext {
5
+ colorMap: ColorMap;
6
+ colorScheme: ColorScheme;
7
+ fontScheme: FontScheme;
8
+ pkg?: PptxPackage;
9
+ slidePath: string;
10
+ imageRels: Map<string, Buffer>;
11
+ }
12
+ export declare function resetAttachmentCounter(): void;
13
+ export declare function nextAttachmentIndex(): number;
14
+ export declare function mapPicture(pic: Picture, _styles: StyleBuilder, attachments: Map<string, Buffer>, ctx: ImageMapperContext): string;
@@ -0,0 +1,70 @@
1
+ import { emuToPx } from "./constants.js";
2
+ let attachmentCounter = 1; // OfficeImport starts at Attachment1 (confirmed by probes)
3
+ export function resetAttachmentCounter() {
4
+ attachmentCounter = 1;
5
+ }
6
+ export function nextAttachmentIndex() {
7
+ return attachmentCounter++;
8
+ }
9
+ export function mapPicture(pic, _styles, attachments, ctx) {
10
+ const b = pic.bounds;
11
+ if (!b)
12
+ return "";
13
+ // Resolve image data: pre-resolved buffer or from relationships
14
+ const data = pic.blipData ?? (pic.blipRId ? ctx.imageRels.get(pic.blipRId) : undefined);
15
+ if (!data)
16
+ return "";
17
+ // Detect format from magic bytes
18
+ const ext = detectImageExt(data);
19
+ const name = `Attachment${nextAttachmentIndex()}.${ext}`;
20
+ attachments.set(name, data);
21
+ // Handle rotation — OfficeImport computes AABB for rotated pictures (same as shapes)
22
+ const rot = b.rot;
23
+ if (rot && rot !== 0) {
24
+ const rad = (rot / 60000) * (Math.PI / 180);
25
+ const cosA = Math.abs(Math.cos(rad));
26
+ const sinA = Math.abs(Math.sin(rad));
27
+ const aabbW_emu = b.cx * cosA + b.cy * sinA;
28
+ const aabbH_emu = b.cx * sinA + b.cy * cosA;
29
+ const cx_emu = b.x + b.cx / 2;
30
+ const cy_emu = b.y + b.cy / 2;
31
+ const imgX = emuToPx(cx_emu - aabbW_emu / 2);
32
+ const imgY = emuToPx(cy_emu - aabbH_emu / 2);
33
+ const imgW = emuToPx(aabbW_emu);
34
+ const imgH = emuToPx(aabbH_emu);
35
+ return `<img src="${name}" style="position:absolute; top:${imgY}; left:${imgX}; width:${imgW}; height:${imgH};">`;
36
+ }
37
+ const x = emuToPx(b.x);
38
+ const y = emuToPx(b.y);
39
+ const w = emuToPx(b.cx);
40
+ const h = emuToPx(b.cy);
41
+ // Cropping: blipFill stretch fillRect defines crop percentages (0-100000)
42
+ const fill = pic.fill;
43
+ if (fill?.type === "image" && fill.stretch?.fillRect) {
44
+ const cr = fill.stretch.fillRect;
45
+ const hasClip = (cr.l ?? 0) > 0 || (cr.t ?? 0) > 0 || (cr.r ?? 0) > 0 || (cr.b ?? 0) > 0;
46
+ if (hasClip) {
47
+ // OfficeImport applies cropping by adjusting the image bounds and using CSS clip
48
+ const clipL = (cr.l ?? 0) / 100000;
49
+ const clipT = (cr.t ?? 0) / 100000;
50
+ const clipR = (cr.r ?? 0) / 100000;
51
+ const clipB = (cr.b ?? 0) / 100000;
52
+ // CSS clip-path with inset
53
+ const clipCss = `clip-path:inset(${(clipT * 100).toFixed(1)}% ${(clipR * 100).toFixed(1)}% ${(clipB * 100).toFixed(1)}% ${(clipL * 100).toFixed(1)}%)`;
54
+ return `<img src="${name}" style="position:absolute; top:${y}; left:${x}; width:${w}; height:${h}; ${clipCss};">`;
55
+ }
56
+ }
57
+ return `<img src="${name}" style="position:absolute; top:${y}; left:${x}; width:${w}; height:${h};">`;
58
+ }
59
+ function detectImageExt(buf) {
60
+ if (buf[0] === 0x89 && buf[1] === 0x50)
61
+ return "png";
62
+ if (buf[0] === 0xFF && buf[1] === 0xD8)
63
+ return "jpeg";
64
+ if (buf[0] === 0x47 && buf[1] === 0x49)
65
+ return "gif";
66
+ if (buf.length > 4 && buf.slice(0, 4).toString("ascii") === "RIFF")
67
+ return "webp";
68
+ // SVG/EMF/WMF — treat as png for attachment purposes
69
+ return "png";
70
+ }