@markdstage/markdstage 2.6.0 → 3.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.
@@ -9,7 +9,7 @@
9
9
  import { mkdtemp, rename, rm, writeFile } from "node:fs/promises";
10
10
  import { basename, extname, join } from "node:path";
11
11
  import { tmpdir } from "node:os";
12
- import { randomUUID } from "node:crypto";
12
+ import { createHash, randomUUID } from "node:crypto";
13
13
  import { getOutputSnapshotSlides } from "../deck-state.mjs";
14
14
  import { normalizeTheme } from "../renderer/theme.mjs";
15
15
  import { MarkdStageError } from "./errors.mjs";
@@ -81,13 +81,21 @@ export function createOutputJob(snapshot, kind) {
81
81
  }
82
82
 
83
83
  function decodeDataImage(source) {
84
- const match = /^data:(image\/(?:png|jpeg|gif))(;base64)?,([\s\S]*)$/i.exec(source);
84
+ const match = /^data:(image\/(?:png|jpeg|gif|svg\+xml))((?:;[^,]*)*),([\s\S]*)$/i.exec(source);
85
85
  if (!match) {
86
- throw new Error("Only PNG, JPEG, or GIF data URLs can be embedded in PowerPoint.");
86
+ throw new Error("Only PNG, JPEG, GIF, or SVG data URLs can be embedded in PowerPoint.");
87
87
  }
88
- const data = match[2]
88
+ const parameters = match[2]
89
+ .split(";")
90
+ .map((parameter) => parameter.trim().toLowerCase())
91
+ .filter(Boolean);
92
+ const base64 = parameters.includes("base64");
93
+ const data = base64
89
94
  ? Buffer.from(match[3], "base64")
90
- : Buffer.from(decodeURIComponent(match[3]), "binary");
95
+ : Buffer.from(
96
+ decodeURIComponent(match[3]),
97
+ match[1].toLowerCase() === "image/svg+xml" ? "utf8" : "binary",
98
+ );
91
99
  return { data, contentType: match[1].toLowerCase() };
92
100
  }
93
101
 
@@ -126,7 +134,7 @@ async function loadPptxImage(inst, source, fetchImpl, currentTotal) {
126
134
  const data = Buffer.from(await response.arrayBuffer());
127
135
  ensurePptxAssetSize(data, source, currentTotal);
128
136
  const responseType = response.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
129
- const contentType = ["image/png", "image/jpeg", "image/gif"].includes(responseType)
137
+ const contentType = ["image/png", "image/jpeg", "image/gif", "image/svg+xml"].includes(responseType)
130
138
  ? responseType
131
139
  : undefined;
132
140
  return { data, contentType };
@@ -135,7 +143,8 @@ async function loadPptxImage(inst, source, fetchImpl, currentTotal) {
135
143
  export async function preparePptxPackageModel(
136
144
  inst,
137
145
  model,
138
- backgrounds,
146
+ layoutArtworks,
147
+ slideFallbackImages,
139
148
  fetchImpl = fetch,
140
149
  ) {
141
150
  if (
@@ -143,29 +152,148 @@ export async function preparePptxPackageModel(
143
152
  model.version !== 1 ||
144
153
  model.width !== PPTX_DIMENSIONS.widthPx ||
145
154
  model.height !== PPTX_DIMENSIONS.heightPx ||
155
+ !Array.isArray(model.masters) ||
156
+ model.masters.length === 0 ||
157
+ !Array.isArray(model.layouts) ||
158
+ model.layouts.length === 0 ||
146
159
  !Array.isArray(model.slides) ||
147
160
  model.slides.length === 0
148
161
  ) {
149
162
  throw new Error("The renderer returned an unsupported PowerPoint export model.");
150
163
  }
151
- if (!Array.isArray(backgrounds) || backgrounds.length !== model.slides.length) {
152
- throw new Error("PowerPoint fallback artwork does not match the slide count.");
164
+ if (!Array.isArray(layoutArtworks) || layoutArtworks.length !== model.layouts.length) {
165
+ throw new Error("PowerPoint layout artwork does not match the layout count.");
166
+ }
167
+ if (
168
+ !Array.isArray(slideFallbackImages) ||
169
+ slideFallbackImages.length !== model.slides.length ||
170
+ slideFallbackImages.some((images) => !Array.isArray(images))
171
+ ) {
172
+ throw new Error("PowerPoint fallback images do not match the slide count.");
153
173
  }
154
174
 
155
175
  const assets = [];
156
176
  const sourceAssets = new Map();
177
+ const pngAssets = new Map();
157
178
  let totalAssetBytes = 0;
158
- for (const [index, background] of backgrounds.entries()) {
159
- if (!Buffer.isBuffer(background)) {
160
- throw new Error(`PowerPoint fallback artwork for slide ${index + 1} is invalid.`);
179
+ const addPngAsset = (id, data, label) => {
180
+ if (!Buffer.isBuffer(data)) {
181
+ throw new Error(`${label} is invalid.`);
161
182
  }
162
- ensurePptxAssetSize(background, `slide ${index + 1} fallback artwork`, totalAssetBytes);
163
- totalAssetBytes += background.length;
183
+ const key = createHash("sha256").update(data).digest("hex");
184
+ const existing = pngAssets.get(key);
185
+ if (existing) return existing;
186
+ ensurePptxAssetSize(data, label, totalAssetBytes);
187
+ totalAssetBytes += data.length;
164
188
  assets.push({
165
- id: `markdstage-background-${index + 1}`,
189
+ id,
166
190
  contentType: "image/png",
167
- data: background,
191
+ data,
168
192
  });
193
+ pngAssets.set(key, id);
194
+ return id;
195
+ };
196
+ const prepareImage = async (sourceElement) => {
197
+ const source = sourceElement.src;
198
+ let assetId = sourceAssets.get(source);
199
+ if (!assetId) {
200
+ const loaded = await loadPptxImage(inst, source, fetchImpl, totalAssetBytes);
201
+ totalAssetBytes += loaded.data.length;
202
+ assetId = `markdstage-image-${sourceAssets.size + 1}`;
203
+ sourceAssets.set(source, assetId);
204
+ assets.push({ id: assetId, ...loaded });
205
+ }
206
+ const { src: _src, source: _source, ...image } = sourceElement;
207
+ if (
208
+ (image.fit === "contain" || image.fit === "scale-down") &&
209
+ image.naturalWidth > 0 &&
210
+ image.naturalHeight > 0
211
+ ) {
212
+ const scale = Math.min(
213
+ image.width / image.naturalWidth,
214
+ image.height / image.naturalHeight,
215
+ image.fit === "scale-down" ? 1 : Number.POSITIVE_INFINITY,
216
+ );
217
+ const width = image.naturalWidth * scale;
218
+ const height = image.naturalHeight * scale;
219
+ image.x += (image.width - width) / 2;
220
+ image.y += (image.height - height) / 2;
221
+ image.width = width;
222
+ image.height = height;
223
+ }
224
+ return { ...image, assetId };
225
+ };
226
+
227
+ const layouts = [];
228
+ const layoutById = new Map();
229
+ for (const [index, sourceLayout] of model.layouts.entries()) {
230
+ if (
231
+ !sourceLayout ||
232
+ typeof sourceLayout.id !== "string" ||
233
+ !sourceLayout.id ||
234
+ typeof sourceLayout.name !== "string" ||
235
+ !sourceLayout.name ||
236
+ typeof sourceLayout.theme !== "string" ||
237
+ !sourceLayout.theme
238
+ ) {
239
+ throw new Error(`PowerPoint layout ${index + 1} is invalid.`);
240
+ }
241
+ if (layoutById.has(sourceLayout.id)) {
242
+ throw new Error(`PowerPoint layout id is duplicated: ${sourceLayout.id}`);
243
+ }
244
+ const artworkAssetId = addPngAsset(
245
+ `markdstage-layout-${index + 1}`,
246
+ layoutArtworks[index],
247
+ `PowerPoint artwork for layout ${sourceLayout.id}`,
248
+ );
249
+ const sourceElements = sourceLayout.elements ?? [];
250
+ if (!Array.isArray(sourceElements) || sourceElements.some((element) => element?.type !== "image")) {
251
+ throw new Error(`PowerPoint layout ${sourceLayout.id} has an invalid element list.`);
252
+ }
253
+ const elements = [];
254
+ for (const sourceElement of sourceElements) {
255
+ elements.push(await prepareImage(sourceElement));
256
+ }
257
+ const layout = {
258
+ id: sourceLayout.id,
259
+ name: sourceLayout.name,
260
+ theme: sourceLayout.theme,
261
+ artworkAssetId,
262
+ elements,
263
+ };
264
+ layoutById.set(layout.id, layout);
265
+ layouts.push(layout);
266
+ }
267
+
268
+ const masters = model.masters.map((sourceMaster, index) => {
269
+ if (
270
+ !sourceMaster ||
271
+ typeof sourceMaster.id !== "string" ||
272
+ !sourceMaster.id ||
273
+ typeof sourceMaster.theme !== "string" ||
274
+ !sourceMaster.theme ||
275
+ !Array.isArray(sourceMaster.layoutIds) ||
276
+ sourceMaster.layoutIds.length === 0
277
+ ) {
278
+ throw new Error(`PowerPoint master ${index + 1} is invalid.`);
279
+ }
280
+ const layoutIds = sourceMaster.layoutIds.map((layoutId) => {
281
+ const layout = layoutById.get(layoutId);
282
+ if (!layout || layout.theme !== sourceMaster.theme) {
283
+ throw new Error(
284
+ `PowerPoint master ${sourceMaster.id} references invalid layout ${layoutId}.`,
285
+ );
286
+ }
287
+ return layoutId;
288
+ });
289
+ return {
290
+ id: sourceMaster.id,
291
+ theme: sourceMaster.theme,
292
+ layoutIds,
293
+ };
294
+ });
295
+ if (new Set(masters.map((master) => master.id)).size !== masters.length) {
296
+ throw new Error("PowerPoint master ids must be unique.");
169
297
  }
170
298
 
171
299
  const slides = [];
@@ -173,6 +301,12 @@ export async function preparePptxPackageModel(
173
301
  if (!sourceSlide || !Array.isArray(sourceSlide.elements)) {
174
302
  throw new Error(`PowerPoint slide ${slideIndex + 1} has an invalid element list.`);
175
303
  }
304
+ if (
305
+ typeof sourceSlide.layoutId !== "string" ||
306
+ !layoutById.has(sourceSlide.layoutId)
307
+ ) {
308
+ throw new Error(`PowerPoint slide ${slideIndex + 1} references an invalid layout.`);
309
+ }
176
310
  if (sourceSlide.notes !== undefined && typeof sourceSlide.notes !== "string") {
177
311
  throw new Error(`PowerPoint slide ${slideIndex + 1} has invalid speaker notes.`);
178
312
  }
@@ -182,42 +316,75 @@ export async function preparePptxPackageModel(
182
316
  elements.push(sourceElement);
183
317
  continue;
184
318
  }
185
- const source = sourceElement.src;
186
- let assetId = sourceAssets.get(source);
187
- if (!assetId) {
188
- const loaded = await loadPptxImage(inst, source, fetchImpl, totalAssetBytes);
189
- totalAssetBytes += loaded.data.length;
190
- assetId = `markdstage-image-${sourceAssets.size + 1}`;
191
- sourceAssets.set(source, assetId);
192
- assets.push({ id: assetId, ...loaded });
193
- }
194
- const { src: _src, source: _source, ...image } = sourceElement;
319
+ elements.push(await prepareImage(sourceElement));
320
+ }
321
+ const fallbacks = Array.isArray(sourceSlide.fallbacks) ? sourceSlide.fallbacks : [];
322
+ const expectedFallbackIndexes = fallbacks
323
+ .map((fallback, fallbackIndex) => ({ fallback, fallbackIndex }))
324
+ .filter(({ fallback }) => fallback?.artwork !== false);
325
+ const captures = slideFallbackImages[slideIndex];
326
+ if (captures.length !== expectedFallbackIndexes.length) {
327
+ throw new Error(
328
+ `PowerPoint fallback images do not match slide ${slideIndex + 1}.`,
329
+ );
330
+ }
331
+ const captureByFallback = new Map();
332
+ for (const capture of captures) {
195
333
  if (
196
- (image.fit === "contain" || image.fit === "scale-down") &&
197
- image.naturalWidth > 0 &&
198
- image.naturalHeight > 0
334
+ !capture ||
335
+ !Number.isInteger(capture.fallbackIndex) ||
336
+ captureByFallback.has(capture.fallbackIndex)
199
337
  ) {
200
- const scale = Math.min(
201
- image.width / image.naturalWidth,
202
- image.height / image.naturalHeight,
203
- image.fit === "scale-down" ? 1 : Number.POSITIVE_INFINITY,
338
+ throw new Error(`PowerPoint fallback image for slide ${slideIndex + 1} is invalid.`);
339
+ }
340
+ captureByFallback.set(capture.fallbackIndex, capture);
341
+ }
342
+ const fallbackElements = [];
343
+ for (const { fallback, fallbackIndex } of expectedFallbackIndexes) {
344
+ const capture = captureByFallback.get(fallbackIndex);
345
+ if (!capture) {
346
+ throw new Error(
347
+ `PowerPoint fallback image ${fallbackIndex + 1} for slide ${slideIndex + 1} is missing.`,
204
348
  );
205
- const width = image.naturalWidth * scale;
206
- const height = image.naturalHeight * scale;
207
- image.x += (image.width - width) / 2;
208
- image.y += (image.height - height) / 2;
209
- image.width = width;
210
- image.height = height;
211
349
  }
212
- elements.push({ ...image, assetId });
350
+ const assetId = addPngAsset(
351
+ `markdstage-slide-${slideIndex + 1}-fallback-${fallbackIndex + 1}`,
352
+ capture.data,
353
+ `PowerPoint fallback image ${fallbackIndex + 1} for slide ${slideIndex + 1}`,
354
+ );
355
+ fallbackElements.push({
356
+ type: "image",
357
+ path: fallback.path,
358
+ name: `${fallback.type || "Fallback"} artwork`,
359
+ x: capture.x,
360
+ y: capture.y,
361
+ width: capture.width,
362
+ height: capture.height,
363
+ fit: "fill",
364
+ opacity: 1,
365
+ ...(Number.isFinite(fallback.zOrder) ? { zOrder: fallback.zOrder } : {}),
366
+ assetId,
367
+ });
213
368
  }
369
+ const orderedElements = [...fallbackElements, ...elements]
370
+ .map((element, elementIndex) => ({ element, elementIndex }))
371
+ .sort((left, right) => {
372
+ const leftOrder = Number.isFinite(left.element.zOrder)
373
+ ? left.element.zOrder
374
+ : Number.POSITIVE_INFINITY;
375
+ const rightOrder = Number.isFinite(right.element.zOrder)
376
+ ? right.element.zOrder
377
+ : Number.POSITIVE_INFINITY;
378
+ return leftOrder - rightOrder || left.elementIndex - right.elementIndex;
379
+ })
380
+ .map(({ element }) => element);
214
381
  slides.push({
215
- backgroundAssetId: `markdstage-background-${slideIndex + 1}`,
382
+ layoutId: sourceSlide.layoutId,
216
383
  ...(sourceSlide.notes ? { notes: sourceSlide.notes } : {}),
217
- elements,
384
+ elements: orderedElements,
218
385
  });
219
386
  }
220
- return { slides, assets };
387
+ return { masters, layouts, slides, assets };
221
388
  }
222
389
 
223
390
  async function runLayoutInspectionJob(inst, snapshot, browser) {
@@ -497,14 +664,14 @@ export async function exportPdf(inst, requestedPath, requestedTheme) {
497
664
  profileDir = await mkdtemp(join(tmpdir(), "markdstage-pdf-"));
498
665
  const outputBase = basename(outputPath, extname(outputPath)) || "markdstage";
499
666
  temporaryOutputPath = join(outputParent, `.${outputBase}.${token}.tmp.pdf`);
500
- inst.exportJobs.set(token, createOutputJob(snapshot, "pdf"));
667
+ const exportJob = createOutputJob(snapshot, "pdf");
668
+ inst.exportJobs.set(token, exportJob);
501
669
 
502
670
  const pageUrl = pageUrlFor(inst, { print: 1, token });
503
- await runPdfBrowser(browser, pageUrl, temporaryOutputPath, profileDir);
504
- const exportJob = inst.exportJobs.get(token);
505
- if (exportJob?.status !== "ready") {
671
+ await runPdfBrowser(browser, pageUrl, temporaryOutputPath, profileDir, exportJob);
672
+ if (exportJob.status !== "ready") {
506
673
  throw new Error(
507
- exportJob?.error || "The print renderer did not finish before the browser exited.",
674
+ exportJob.error || "The print renderer did not finish before PDF generation.",
508
675
  );
509
676
  }
510
677
  const bytes = await verifyPdf(temporaryOutputPath);
@@ -585,14 +752,19 @@ export async function exportPptx(
585
752
  inst.exportJobs.set(token, job);
586
753
 
587
754
  const pageUrl = pageUrlFor(inst, { pptx: 1, token });
588
- const { model, backgrounds } = await runBrowser(
755
+ const { model, layoutArtworks, slideFallbackImages } = await runBrowser(
589
756
  browser,
590
757
  pageUrl,
591
758
  profileDir,
592
759
  job,
593
760
  snapshot.slides.length,
594
761
  );
595
- const packageModel = await prepareModel(inst, model, backgrounds);
762
+ const packageModel = await prepareModel(
763
+ inst,
764
+ model,
765
+ layoutArtworks,
766
+ slideFallbackImages,
767
+ );
596
768
  const buffer = buildPackage({
597
769
  title: model.slides[0]?.title || outputBase,
598
770
  ...packageModel,
@@ -605,6 +777,8 @@ export async function exportPptx(
605
777
  !packageSummary.valid ||
606
778
  packageSummary.slideCount !== snapshot.slides.length ||
607
779
  packageSummary.notesCount !== expectedNotes ||
780
+ packageSummary.masterCount !== model.masters.length ||
781
+ packageSummary.layoutCount !== model.layouts.length ||
608
782
  packageSummary.dimensions.widthEmu !== PPTX_DIMENSIONS.widthEmu ||
609
783
  packageSummary.dimensions.heightEmu !== PPTX_DIMENSIONS.heightEmu
610
784
  ) {
@@ -615,11 +789,19 @@ export async function exportPptx(
615
789
  await rename(temporaryOutputPath, outputPath);
616
790
  temporaryOutputPath = "";
617
791
  const fallbacks = model.slides.flatMap((slide, slideIndex) =>
618
- (Array.isArray(slide.fallbacks) ? slide.fallbacks : []).map((fallback) => ({
619
- slideIndex,
620
- page: slideIndex + 1,
621
- ...fallback,
622
- })),
792
+ (Array.isArray(slide.fallbacks) ? slide.fallbacks : []).map((fallback) => {
793
+ const {
794
+ artwork: _artwork,
795
+ captureId: _captureId,
796
+ zOrder: _zOrder,
797
+ ...reportedFallback
798
+ } = fallback;
799
+ return {
800
+ slideIndex,
801
+ page: slideIndex + 1,
802
+ ...reportedFallback,
803
+ };
804
+ }),
623
805
  );
624
806
  logFor(
625
807
  inst,