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.
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +175 -0
- package/dist/diff/compare.d.ts +17 -0
- package/dist/diff/compare.js +71 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +72 -0
- package/dist/lint.d.ts +27 -0
- package/dist/lint.js +328 -0
- package/dist/mapper/bleed-map.d.ts +6 -0
- package/dist/mapper/bleed-map.js +1 -0
- package/dist/mapper/constants.d.ts +2 -0
- package/dist/mapper/constants.js +4 -0
- package/dist/mapper/drawable-mapper.d.ts +16 -0
- package/dist/mapper/drawable-mapper.js +1464 -0
- package/dist/mapper/html-generator.d.ts +13 -0
- package/dist/mapper/html-generator.js +539 -0
- package/dist/mapper/image-mapper.d.ts +14 -0
- package/dist/mapper/image-mapper.js +70 -0
- package/dist/mapper/nano-malloc.d.ts +130 -0
- package/dist/mapper/nano-malloc.js +197 -0
- package/dist/mapper/ql-bleed.d.ts +35 -0
- package/dist/mapper/ql-bleed.js +254 -0
- package/dist/mapper/shape-mapper.d.ts +41 -0
- package/dist/mapper/shape-mapper.js +2384 -0
- package/dist/mapper/slide-mapper.d.ts +4 -0
- package/dist/mapper/slide-mapper.js +112 -0
- package/dist/mapper/style-builder.d.ts +12 -0
- package/dist/mapper/style-builder.js +30 -0
- package/dist/mapper/text-mapper.d.ts +14 -0
- package/dist/mapper/text-mapper.js +302 -0
- package/dist/model/enums.d.ts +25 -0
- package/dist/model/enums.js +2 -0
- package/dist/model/types.d.ts +482 -0
- package/dist/model/types.js +7 -0
- package/dist/package/content-types.d.ts +1 -0
- package/dist/package/content-types.js +4 -0
- package/dist/package/package.d.ts +10 -0
- package/dist/package/package.js +52 -0
- package/dist/package/relationships.d.ts +6 -0
- package/dist/package/relationships.js +25 -0
- package/dist/package/zip.d.ts +6 -0
- package/dist/package/zip.js +17 -0
- package/dist/reader/color.d.ts +3 -0
- package/dist/reader/color.js +79 -0
- package/dist/reader/drawing.d.ts +17 -0
- package/dist/reader/drawing.js +403 -0
- package/dist/reader/effects.d.ts +2 -0
- package/dist/reader/effects.js +83 -0
- package/dist/reader/fill.d.ts +2 -0
- package/dist/reader/fill.js +94 -0
- package/dist/reader/presentation.d.ts +5 -0
- package/dist/reader/presentation.js +127 -0
- package/dist/reader/slide-layout.d.ts +2 -0
- package/dist/reader/slide-layout.js +28 -0
- package/dist/reader/slide-master.d.ts +4 -0
- package/dist/reader/slide-master.js +49 -0
- package/dist/reader/slide.d.ts +2 -0
- package/dist/reader/slide.js +26 -0
- package/dist/reader/text-list-style.d.ts +2 -0
- package/dist/reader/text-list-style.js +9 -0
- package/dist/reader/text.d.ts +5 -0
- package/dist/reader/text.js +295 -0
- package/dist/reader/theme.d.ts +2 -0
- package/dist/reader/theme.js +109 -0
- package/dist/reader/transform.d.ts +2 -0
- package/dist/reader/transform.js +21 -0
- package/dist/render/image-renderer.d.ts +3 -0
- package/dist/render/image-renderer.js +33 -0
- package/dist/render/renderer.d.ts +9 -0
- package/dist/render/renderer.js +178 -0
- package/dist/render/shape-renderer.d.ts +3 -0
- package/dist/render/shape-renderer.js +175 -0
- package/dist/render/text-renderer.d.ts +3 -0
- package/dist/render/text-renderer.js +152 -0
- package/dist/resolve/color-resolver.d.ts +18 -0
- package/dist/resolve/color-resolver.js +321 -0
- package/dist/resolve/font-map.d.ts +2 -0
- package/dist/resolve/font-map.js +66 -0
- package/dist/resolve/inheritance.d.ts +5 -0
- package/dist/resolve/inheritance.js +106 -0
- 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
|
+
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
|
+
}
|