@markdstage/markdstage 3.4.0 → 3.8.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.
@@ -95,9 +95,9 @@ export function serializeThemeVariables(variables) {
95
95
  // its built-in named themes (dark/default/neutral/forest). Deriving that
96
96
  // palette from the rendered deck's custom properties makes Mermaid diagrams
97
97
  // share the slide's background, border, and text colors instead of only
98
- // approximating the deck theme, and it reuses the same primary/secondary
99
- // roles as the Architecture DSL (nodes: surface+border+fg, groups:
100
- // accent-soft+accent-line+accent-strong) so both diagram types match.
98
+ // approximating the deck theme. Thin diagram outlines use the muted text
99
+ // role instead of the decorative slide border so compartments, shapes, and
100
+ // sequence lifelines remain distinguishable on either background.
101
101
  //
102
102
  // `secondaryColor`/`tertiaryColor` also back many categorical fills across
103
103
  // Mermaid's diagram types (pie slices, git graph nodes, venn/quadrant charts,
@@ -107,44 +107,304 @@ export function serializeThemeVariables(variables) {
107
107
  // `noteBorderColor`/`noteTextColor` are hardcoded by Mermaid's base theme
108
108
  // (always a pale yellow) unless set explicitly, so sequence-diagram notes
109
109
  // need their own override to follow the deck theme too.
110
- export function mermaidThemeVariables(style) {
110
+ export function mermaidThemeVariables(style, resolveColor = (value) => value) {
111
111
  const read = (name) => style.getPropertyValue(name).trim();
112
112
  const background = read("--bg");
113
113
  const surface = read("--surface");
114
+ const code = read("--code");
114
115
  const border = read("--border");
115
116
  const foreground = read("--fg");
116
117
  const muted = read("--muted");
118
+ const body = read("--body");
117
119
  const accent = read("--accent");
118
120
  const accentStrong = read("--accent-strong");
119
121
  const accentSoft = read("--accent-soft");
120
122
  const accentLine = read("--accent-line");
123
+ // C4 hardcodes white entity text. Evaluate each fill, not just the slide
124
+ // background: a readable custom theme may still have a bright accent or
125
+ // surface. Select existing palette values without inventing brand colors.
126
+ const c4Fill = (...colors) =>
127
+ colors.find((color) => supportsWhiteText(resolveColor(color))) ?? colors[0];
128
+ const c4Person = c4Fill(surface, foreground, body);
129
+ const c4System = c4Fill(code, accentStrong, body, foreground);
130
+ const c4Container = c4Fill(border, accent, body, foreground);
131
+ const c4Component = c4Fill(background, body, foreground);
132
+ const c4External = c4Fill(code, foreground, body);
133
+ const c4ExternalAlt = c4Fill(background, body, foreground);
121
134
 
122
135
  return {
123
136
  background,
124
137
  primaryColor: surface,
125
138
  primaryTextColor: foreground,
126
- primaryBorderColor: border,
139
+ primaryBorderColor: muted,
127
140
  secondaryColor: accentStrong,
128
141
  secondaryTextColor: background,
129
- secondaryBorderColor: accentLine,
142
+ secondaryBorderColor: accent,
130
143
  tertiaryColor: muted,
131
144
  tertiaryTextColor: background,
132
- tertiaryBorderColor: border,
145
+ tertiaryBorderColor: muted,
133
146
  lineColor: accent,
134
147
  textColor: foreground,
135
148
  mainBkg: surface,
136
- nodeBorder: border,
149
+ nodeBorder: muted,
137
150
  clusterBkg: accentSoft,
138
- clusterBorder: accentLine,
151
+ clusterBorder: accent,
139
152
  titleColor: foreground,
140
153
  edgeLabelBackground: background,
141
154
  noteBkgColor: accentSoft,
142
155
  noteBorderColor: accentLine,
143
156
  noteTextColor: accentStrong,
144
157
  pie1: accent,
158
+ ...mermaidCategoricalTheme({ background, foreground, accent, surface, code, muted }, resolveColor),
159
+
160
+ // The unified ER renderer reads rowOdd/rowEven, not the legacy
161
+ // attributeBackgroundColor roles. Its base theme otherwise lightens
162
+ // alternate rows even when the slide has light text on a dark surface.
163
+ rowOdd: surface,
164
+ rowEven: code,
165
+ packet: {
166
+ startByteColor: foreground,
167
+ endByteColor: foreground,
168
+ labelColor: foreground,
169
+ titleColor: foreground,
170
+ blockStrokeColor: muted,
171
+ blockFillColor: surface,
172
+ },
173
+ treeView: {
174
+ labelColor: foreground,
175
+ lineColor: muted,
176
+ },
177
+
178
+ // C4's documented shape variables are read by its renderer, unlike the
179
+ // generic primary/secondary roles above. The database and queue variants
180
+ // intentionally follow their containing shape's semantic color.
181
+ person_bg_color: c4Person,
182
+ person_border_color: foreground,
183
+ external_person_bg_color: c4External,
184
+ external_person_border_color: foreground,
185
+ system_bg_color: c4System,
186
+ system_border_color: foreground,
187
+ system_db_bg_color: c4System,
188
+ system_db_border_color: foreground,
189
+ system_queue_bg_color: c4System,
190
+ system_queue_border_color: foreground,
191
+ external_system_bg_color: c4External,
192
+ external_system_border_color: foreground,
193
+ external_system_db_bg_color: c4External,
194
+ external_system_db_border_color: foreground,
195
+ external_system_queue_bg_color: c4External,
196
+ external_system_queue_border_color: foreground,
197
+ container_bg_color: c4Container,
198
+ container_border_color: foreground,
199
+ container_db_bg_color: c4Container,
200
+ container_db_border_color: foreground,
201
+ container_queue_bg_color: c4Container,
202
+ container_queue_border_color: foreground,
203
+ external_container_bg_color: c4ExternalAlt,
204
+ external_container_border_color: foreground,
205
+ external_container_db_bg_color: c4ExternalAlt,
206
+ external_container_db_border_color: foreground,
207
+ external_container_queue_bg_color: c4ExternalAlt,
208
+ external_container_queue_border_color: foreground,
209
+ component_bg_color: c4Component,
210
+ component_border_color: foreground,
211
+ component_db_bg_color: c4Component,
212
+ component_db_border_color: foreground,
213
+ component_queue_bg_color: c4Component,
214
+ component_queue_border_color: foreground,
215
+ external_component_bg_color: c4ExternalAlt,
216
+ external_component_border_color: foreground,
217
+ external_component_db_bg_color: c4ExternalAlt,
218
+ external_component_db_border_color: foreground,
219
+ external_component_queue_bg_color: c4ExternalAlt,
220
+ external_component_queue_border_color: foreground,
221
+
222
+ // Architecture-beta exposes these roles directly in its SVG CSS.
223
+ archEdgeColor: accent,
224
+ archEdgeArrowColor: accent,
225
+ archGroupBorderColor: accent,
226
+ archGroupBorderWidth: "1",
227
+
228
+ // Event Modeling exposes a fill/stroke pair for each entity kind and
229
+ // separate roles for lanes and relations.
230
+ emUiFill: surface,
231
+ emUiStroke: accent,
232
+ emProcessorFill: accentSoft,
233
+ emProcessorStroke: accent,
234
+ emReadModelFill: code,
235
+ emReadModelStroke: accentStrong,
236
+ emCommandFill: background,
237
+ emCommandStroke: accent,
238
+ emEventFill: accentLine,
239
+ emEventStroke: accentStrong,
240
+ emSwimlaneBackgroundOdd: accentSoft,
241
+ emSwimlaneBackgroundStroke: accent,
242
+ emArrowhead: accent,
243
+ emRelationStroke: accent,
244
+ attributeBackgroundColorOdd: surface,
245
+ attributeBackgroundColorEven: code,
145
246
  };
146
247
  }
147
248
 
249
+ const C4_THEME_VARIABLE = /^(?:external_)?(?:person|system|container|component)(?:_(?:db|queue))?_(?:bg|border)_color$/;
250
+
251
+ export function mermaidC4ThemeVariables(themeVariables) {
252
+ return Object.fromEntries(
253
+ Object.entries(themeVariables).filter(([name]) => C4_THEME_VARIABLE.test(name)),
254
+ );
255
+ }
256
+
257
+ function supportsWhiteText(value) {
258
+ const channels = opaqueColorChannels(value);
259
+ return channels !== null && 1.05 / (colorLuminance(channels) + 0.05) >= 4.5;
260
+ }
261
+
262
+ function opaqueColorChannels(value) {
263
+ const match = String(value || "").match(
264
+ /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\(\s*([0-9.]+%?)[,\s]+([0-9.]+%?)[,\s]+([0-9.]+%?)(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i,
265
+ );
266
+ if (!match) return null;
267
+ const alpha = match[1]?.length === 4
268
+ ? Number.parseInt(match[1][3].repeat(2), 16) / 255
269
+ : match[1]?.length === 8
270
+ ? Number.parseInt(match[1].slice(6), 16) / 255
271
+ : match[5]
272
+ ? Number.parseFloat(match[5]) / (match[5].endsWith("%") ? 100 : 1)
273
+ : 1;
274
+ // Translucent fills depend on their backdrop; prefer a solid palette role.
275
+ if (alpha < 1) return null;
276
+ return match[1]
277
+ ? (match[1].length <= 4
278
+ ? [...match[1].slice(0, 3)].map((channel) => Number.parseInt(channel + channel, 16))
279
+ : [0, 2, 4].map((index) => Number.parseInt(match[1].slice(index, index + 2), 16)))
280
+ : match.slice(2, 5).map((channel) => {
281
+ const numeric = Number.parseFloat(channel);
282
+ return channel.endsWith("%") ? numeric * 2.55 : numeric;
283
+ });
284
+ }
285
+
286
+ function colorLuminance(channels) {
287
+ return channels.reduce(
288
+ (sum, channel, index) => sum + [0.2126, 0.7152, 0.0722][index] * relativeLuminance(channel),
289
+ 0,
290
+ );
291
+ }
292
+
293
+ function mermaidCategoricalTheme(palette, resolveColor) {
294
+ const colors = Object.fromEntries(
295
+ ["background", "foreground", "accent", "surface"].map((key) => [
296
+ key, opaqueColorChannels(resolveColor(palette[key])),
297
+ ]),
298
+ );
299
+ // An incomplete/translucent custom palette has no reliable contrast
300
+ // backdrop. Keep its existing Mermaid derivation instead of guessing one.
301
+ if (Object.values(colors).some((color) => color === null)) return {};
302
+ const background = colorLuminance(colors.background);
303
+ const foreground = colorLuminance(colors.foreground);
304
+ const darkMode = background < foreground;
305
+ const lower = darkMode
306
+ ? (background + 0.05) * 3 - 0.05
307
+ : (foreground + 0.05) * 4.5 - 0.05;
308
+ const upper = darkMode
309
+ ? (foreground + 0.05) / 4.5 - 0.05
310
+ : (background + 0.05) / 3.2 - 0.05;
311
+ // Prefer legible labels when a custom palette cannot satisfy both text and
312
+ // shape contrast. Do not alter the author's foreground/background values.
313
+ const target = Math.max(0, Math.min(1,
314
+ darkMode ? (lower <= upper ? (lower + upper) / 2 : upper) : Math.max(lower, upper),
315
+ ));
316
+ const hue = colorHue(colors.accent);
317
+ const categories = Array.from({ length: 12 }, (_, index) =>
318
+ colorAtLuminance((hue + index * 137.5) % 360, 0.48, target),
319
+ );
320
+ const critical = colorAtLuminance(0, 0.7, target);
321
+ const theme = {
322
+ darkMode,
323
+ git0: categories[0],
324
+ gitBranchLabel0: palette.foreground,
325
+ // Mermaid restores this nested object as a whole after deriving colors;
326
+ // setting only plotColorPalette loses the derived dark background/axes.
327
+ xyChart: {
328
+ backgroundColor: palette.background,
329
+ titleColor: palette.foreground,
330
+ xAxisTitleColor: palette.foreground,
331
+ xAxisLabelColor: palette.foreground,
332
+ xAxisTickColor: palette.muted,
333
+ xAxisLineColor: palette.muted,
334
+ yAxisTitleColor: palette.foreground,
335
+ yAxisLabelColor: palette.foreground,
336
+ yAxisTickColor: palette.muted,
337
+ yAxisLineColor: palette.muted,
338
+ plotColorPalette: [categories[0], palette.foreground, ...categories.slice(1)].join(","),
339
+ },
340
+ taskBkgColor: palette.surface,
341
+ taskBorderColor: palette.muted,
342
+ activeTaskBkgColor: mixColors(colors.surface, colors.accent, 0.18),
343
+ activeTaskBorderColor: palette.accent,
344
+ doneTaskBkgColor: palette.code,
345
+ doneTaskBorderColor: palette.muted,
346
+ critBkgColor: mixColors(colors.surface, opaqueColorChannels(critical), 0.18),
347
+ critBorderColor: critical,
348
+ taskTextColor: palette.foreground,
349
+ taskTextDarkColor: palette.foreground,
350
+ taskTextOutsideColor: palette.foreground,
351
+ };
352
+ for (const [index, color] of categories.entries()) {
353
+ theme[`cScale${index}`] = color;
354
+ // Treemap leaf-label indexes differ from their parent-fill indexes, and
355
+ // Journey/Kanban HTML labels use textColor. All fills need the same text.
356
+ theme[`cScaleLabel${index}`] = palette.foreground;
357
+ theme[`cScalePeer${index}`] = color;
358
+ theme[`cScaleInv${index}`] = palette.muted;
359
+ if (index < 8) theme[`fillType${index}`] = color;
360
+ }
361
+ return theme;
362
+ }
363
+
364
+ function colorHue(channels) {
365
+ const [red, green, blue] = channels.map((channel) => channel / 255);
366
+ const max = Math.max(red, green, blue);
367
+ const delta = max - Math.min(red, green, blue);
368
+ if (delta === 0) return 0;
369
+ const hue = max === red ? (green - blue) / delta
370
+ : max === green ? (blue - red) / delta + 2 : (red - green) / delta + 4;
371
+ return (hue * 60 + 360) % 360;
372
+ }
373
+
374
+ function colorAtLuminance(hue, saturation, target) {
375
+ const channelsAt = (lightness) => {
376
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
377
+ const x = chroma * (1 - Math.abs((hue / 60) % 2 - 1));
378
+ const sector = Math.floor(hue / 60);
379
+ const rgb = [[chroma, x, 0], [x, chroma, 0], [0, chroma, x],
380
+ [0, x, chroma], [x, 0, chroma], [chroma, 0, x]][sector];
381
+ return rgb.map((channel) => (channel + lightness - chroma / 2) * 255);
382
+ };
383
+ let low = 0;
384
+ let high = 1;
385
+ for (let iteration = 0; iteration < 20; iteration++) {
386
+ const mid = (low + high) / 2;
387
+ if (colorLuminance(channelsAt(mid)) < target) low = mid;
388
+ else high = mid;
389
+ }
390
+ return colorHex(channelsAt((low + high) / 2));
391
+ }
392
+
393
+ function mixColors(left, right, amount) {
394
+ return colorHex(left.map((channel, index) => channel * (1 - amount) + right[index] * amount));
395
+ }
396
+
397
+ function colorHex(channels) {
398
+ return `#${channels.map((channel) => Math.round(Math.max(0, Math.min(255, channel))).toString(16).padStart(2, "0")).join("")}`;
399
+ }
400
+
401
+ function relativeLuminance(channel) {
402
+ const normalized = Math.max(0, Math.min(255, channel)) / 255;
403
+ return normalized <= 0.04045
404
+ ? normalized / 12.92
405
+ : ((normalized + 0.055) / 1.055) ** 2.4;
406
+ }
407
+
148
408
  function assertPlainObject(value, path) {
149
409
  if (!value || typeof value !== "object" || Array.isArray(value)) {
150
410
  throw new Error(`${path} must be an object`);
@@ -170,6 +430,9 @@ function parseImage(value, path, { altRequired = false } = {}) {
170
430
  `${path}.image must be a safe path under the theme assets/ folder using svg, png, webp, jpg, or jpeg`,
171
431
  );
172
432
  }
433
+ if (image.alt !== undefined && typeof image.alt !== "string") {
434
+ throw new Error(`${path}.alt must be a string`);
435
+ }
173
436
  const alt = typeof image.alt === "string" ? image.alt.trim() : "";
174
437
  if (altRequired && !alt) throw new Error(`${path}.alt must be a non-empty string`);
175
438
  return { image: image.image, ...(alt ? { alt } : {}) };
@@ -178,12 +441,35 @@ function parseImage(value, path, { altRequired = false } = {}) {
178
441
  export function parseThemeMetadata(value) {
179
442
  const metadata = typeof value === "string" ? JSON.parse(value) : value;
180
443
  const root = assertPlainObject(metadata, "theme metadata");
181
- assertOnlyKeys(root, new Set(["$schema", "version", "cover", "backcover"]), "theme metadata");
444
+ assertOnlyKeys(
445
+ root,
446
+ new Set(["$schema", "version", "background", "layouts", "cover", "backcover"]),
447
+ "theme metadata",
448
+ );
182
449
  if (root.version !== THEME_METADATA_VERSION) {
183
450
  throw new Error(`theme metadata version must be ${THEME_METADATA_VERSION}`);
184
451
  }
185
452
 
186
453
  const result = { version: THEME_METADATA_VERSION };
454
+ if (root.background !== undefined) {
455
+ result.background = parseImage(root.background, "background");
456
+ }
457
+ if (root.layouts !== undefined) {
458
+ const layouts = assertPlainObject(root.layouts, "layouts");
459
+ assertOnlyKeys(layouts, new Set(["default", "center"]), "layouts");
460
+ result.layouts = {};
461
+ for (const layout of ["default", "center"]) {
462
+ if (layouts[layout] === undefined) continue;
463
+ const entry = assertPlainObject(layouts[layout], `layouts.${layout}`);
464
+ assertOnlyKeys(entry, new Set(["background"]), `layouts.${layout}`);
465
+ if (entry.background !== undefined) {
466
+ result.layouts[layout] = {
467
+ background: parseImage(entry.background, `layouts.${layout}.background`),
468
+ };
469
+ }
470
+ }
471
+ if (Object.keys(result.layouts).length === 0) delete result.layouts;
472
+ }
187
473
  if (root.cover !== undefined) {
188
474
  const cover = assertPlainObject(root.cover, "cover");
189
475
  assertOnlyKeys(cover, new Set(["background", "logo"]), "cover");
@@ -217,6 +503,14 @@ export function parseThemeMetadata(value) {
217
503
  return result;
218
504
  }
219
505
 
506
+ export function resolveThemeBackground(metadata, layout) {
507
+ const normalized = typeof layout === "string" ? layout.trim().toLowerCase() : "";
508
+ if (normalized === "title") return metadata?.cover?.background;
509
+ if (normalized === "section" || normalized === "backcover") return undefined;
510
+ const key = normalized === "center" ? "center" : "default";
511
+ return metadata?.layouts?.[key]?.background ?? metadata?.background;
512
+ }
513
+
220
514
  export function themeMetadataAssetPaths(metadata) {
221
515
  const paths = [];
222
516
  const add = (entry) => {
@@ -225,14 +519,36 @@ export function themeMetadataAssetPaths(metadata) {
225
519
  add(metadata?.cover?.background);
226
520
  add(metadata?.cover?.logo);
227
521
  add(metadata?.backcover?.logo);
522
+ add(metadata?.background);
523
+ add(metadata?.layouts?.default?.background);
524
+ add(metadata?.layouts?.center?.background);
228
525
  return paths;
229
526
  }
230
527
 
231
528
  export function mapThemeMetadataAssets(metadata, mapAsset) {
232
- const mapImage = (entry) =>
233
- entry ? { ...entry, image: mapAsset(entry.image) } : undefined;
529
+ const mapped = new Map();
530
+ const mapImage = (entry) => {
531
+ if (!entry) return undefined;
532
+ if (!mapped.has(entry.image)) mapped.set(entry.image, mapAsset(entry.image));
533
+ return { ...entry, image: mapped.get(entry.image) };
534
+ };
234
535
  return {
235
536
  version: metadata.version,
537
+ ...(metadata.background ? { background: mapImage(metadata.background) } : {}),
538
+ ...(metadata.layouts
539
+ ? {
540
+ layouts: Object.fromEntries(
541
+ ["default", "center"]
542
+ .filter((layout) => metadata.layouts[layout])
543
+ .map((layout) => [
544
+ layout,
545
+ metadata.layouts[layout].background
546
+ ? { background: mapImage(metadata.layouts[layout].background) }
547
+ : {},
548
+ ]),
549
+ ),
550
+ }
551
+ : {}),
236
552
  ...(metadata.cover
237
553
  ? {
238
554
  cover: {
@@ -440,6 +440,48 @@ export async function runCdpOutputBrowser(browser, pageUrl, profileDir, job, cap
440
440
  }
441
441
  }
442
442
 
443
+ // Runs in Chromium so PNG cropping needs no separate image codec dependency.
444
+ async function trimTransparentArtwork(data) {
445
+ const image = new Image();
446
+ image.src = `data:image/png;base64,${data}`;
447
+ await image.decode();
448
+ const canvas = document.createElement("canvas");
449
+ canvas.width = image.naturalWidth;
450
+ canvas.height = image.naturalHeight;
451
+ const context = canvas.getContext("2d", { willReadFrequently: true });
452
+ context.drawImage(image, 0, 0);
453
+ const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
454
+ let left = canvas.width;
455
+ let top = canvas.height;
456
+ let right = 0;
457
+ let bottom = 0;
458
+ for (let y = 0; y < canvas.height; y++) {
459
+ for (let x = 0; x < canvas.width; x++) {
460
+ if (pixels[(y * canvas.width + x) * 4 + 3] === 0) continue;
461
+ left = Math.min(left, x);
462
+ top = Math.min(top, y);
463
+ right = Math.max(right, x + 1);
464
+ bottom = Math.max(bottom, y + 1);
465
+ }
466
+ }
467
+ // An intentionally invisible diagram has no meaningful painted bounds to trim.
468
+ if (right <= left || bottom <= top) {
469
+ return { x: 0, y: 0, width: canvas.width, height: canvas.height, data };
470
+ }
471
+ const padding = 2;
472
+ left = Math.max(0, left - padding);
473
+ top = Math.max(0, top - padding);
474
+ right = Math.min(canvas.width, right + padding);
475
+ bottom = Math.min(canvas.height, bottom + padding);
476
+ const width = right - left;
477
+ const height = bottom - top;
478
+ const cropped = context.getImageData(left, top, width, height);
479
+ canvas.width = width;
480
+ canvas.height = height;
481
+ context.putImageData(cropped, 0, 0);
482
+ return { x: left, y: top, width, height, data: canvas.toDataURL("image/png").split(",")[1] };
483
+ }
484
+
443
485
  export async function runPptxOutputBrowser(browser, pageUrl, profileDir, job, total) {
444
486
  const { cdp, child } = await openCdpOutputPage(browser, pageUrl, profileDir, job);
445
487
  try {
@@ -527,11 +569,17 @@ export async function runPptxOutputBrowser(browser, pageUrl, profileDir, job, to
527
569
  `PowerPoint fallback ${fallbackIndex + 1} on slide ${slideIndex + 1} has invalid bounds.`,
528
570
  );
529
571
  }
530
- const bounds = {
531
- x: left,
532
- y: top,
533
- width: right - left,
534
- height: bottom - top,
572
+ const trimMermaid = fallback.type === "mermaid" &&
573
+ fallback.reason === "mermaid-rendered-as-artwork";
574
+ // Align local Mermaid artwork too: fractional PNG placement resamples
575
+ // translucent strokes and changes their composited theme colors.
576
+ const bounds = fallback.type === "mermaid" ? {
577
+ x: Math.floor(left),
578
+ y: Math.floor(top),
579
+ width: Math.ceil(right) - Math.floor(left),
580
+ height: Math.ceil(bottom) - Math.floor(top),
581
+ } : {
582
+ x: left, y: top, width: right - left, height: bottom - top,
535
583
  };
536
584
  await cdp.send("Runtime.evaluate", {
537
585
  expression: `(() => {
@@ -561,6 +609,28 @@ export async function runPptxOutputBrowser(browser, pageUrl, profileDir, job, to
561
609
  `Chromium did not return fallback artwork ${fallbackIndex + 1} for slide ${slideIndex + 1}.`,
562
610
  );
563
611
  }
612
+ if (trimMermaid) {
613
+ const cropped = await cdp.send("Runtime.evaluate", {
614
+ expression: `(${trimTransparentArtwork.toString()})(${JSON.stringify(screenshot.data)})`,
615
+ awaitPromise: true,
616
+ returnByValue: true,
617
+ });
618
+ const result = cropped.result?.value;
619
+ if (cropped.exceptionDetails || !result ||
620
+ ![result.x, result.y, result.width, result.height].every(Number.isInteger) ||
621
+ result.x < 0 || result.y < 0 || result.width <= 0 || result.height <= 0 ||
622
+ result.x + result.width > bounds.width || result.y + result.height > bounds.height ||
623
+ typeof result.data !== "string" || !result.data) {
624
+ throw new Error(
625
+ `Chromium could not trim Mermaid artwork ${fallbackIndex + 1} on slide ${slideIndex + 1}.`,
626
+ );
627
+ }
628
+ bounds.x += result.x;
629
+ bounds.y += result.y;
630
+ bounds.width = result.width;
631
+ bounds.height = result.height;
632
+ screenshot.data = result.data;
633
+ }
564
634
  images.push({
565
635
  fallbackIndex,
566
636
  ...bounds,
@@ -14,6 +14,7 @@ import { resolveWorkspaceRoot } from "../scripts/workspace-root.mjs";
14
14
  import { DEFAULT_THEME, normalizeTheme, resolveFrontMatterTheme } from "../renderer/theme.mjs";
15
15
  import { MarkdStageError } from "./errors.mjs";
16
16
  import { loadCustomTheme } from "./custom-theme.mjs";
17
+ import { loadSlideBackgrounds } from "./slide-backgrounds.mjs";
17
18
  import { isPathInside } from "./output-paths.mjs";
18
19
 
19
20
  export function clampIndex(value, total) {
@@ -155,11 +156,12 @@ export async function createDeckSession({
155
156
  log,
156
157
  };
157
158
 
158
- session.load = async ({ preserveIndex = false } = {}) => {
159
- if (!session.file) {
159
+ const loadFile = async (file, sourceName, preserveIndex) => {
160
+ if (!file) {
160
161
  throw new MarkdStageError("no_deck", "Open a Markdown file first.");
161
162
  }
162
- const { markdown, slides } = await readDeckSlides(session.file);
163
+ const { markdown, slides } = await readDeckSlides(file);
164
+ await loadSlideBackgrounds(session.workspaceRoot, sourceName, slides);
163
165
  const selection = resolveDeckTheme({
164
166
  slides,
165
167
  explicitTheme: session.requestedTheme,
@@ -169,11 +171,13 @@ export async function createDeckSession({
169
171
  selection.theme === "custom"
170
172
  ? await loadCustomTheme(
171
173
  session.workspaceRoot,
172
- session.sourceName,
174
+ sourceName,
173
175
  selection.themeFile,
174
176
  { assetUrlPrefix: session.assetUrlPrefix },
175
177
  )
176
178
  : { file: "", css: "", dir: "", metadata: null, assets: [] };
179
+ session.file = file;
180
+ session.sourceName = sourceName;
177
181
  session.theme = selection.theme;
178
182
  session.themeLocked = selection.themeLocked;
179
183
  session.customThemeFile = custom.file;
@@ -190,21 +194,12 @@ export async function createDeckSession({
190
194
  return session.slides.length;
191
195
  };
192
196
 
197
+ session.load = async ({ preserveIndex = false } = {}) =>
198
+ loadFile(session.file, session.sourceName, preserveIndex);
199
+
193
200
  session.openFile = async (nextFile, { preserveIndex = false } = {}) => {
194
201
  const next = await resolveDeckFile(nextFile, session.workspaceRoot);
195
- const previous = {
196
- file: session.file,
197
- sourceName: session.sourceName,
198
- };
199
- session.file = next.path;
200
- session.sourceName = workspaceRelative(next.workspaceRoot, next.path);
201
- try {
202
- return await session.load({ preserveIndex });
203
- } catch (error) {
204
- session.file = previous.file;
205
- session.sourceName = previous.sourceName;
206
- throw error;
207
- }
202
+ return loadFile(next.path, workspaceRelative(next.workspaceRoot, next.path), preserveIndex);
208
203
  };
209
204
 
210
205
  session.navigate = (target) => {
@@ -38,6 +38,13 @@ export function pptxNameForSource(sourceName) {
38
38
  return `${safeBaseName(sourceName) || basename(DEFAULT_PPTX_NAME, ".pptx")}.pptx`;
39
39
  }
40
40
 
41
+ export function outputPathForSource(sourceName, outputName) {
42
+ const sourceDirectory = typeof sourceName === "string" && sourceName.trim()
43
+ ? dirname(sourceName.trim())
44
+ : "";
45
+ return join(sourceDirectory, outputName);
46
+ }
47
+
41
48
  export function captureDirectoryName(sourceName) {
42
49
  const safeBase = safeBaseName(sourceName);
43
50
  return safeBase ? `${safeBase}-previews` : DEFAULT_CAPTURE_DIR;
@@ -66,7 +66,7 @@ export function createOutputSnapshot(inst, requestedTheme) {
66
66
  };
67
67
  }
68
68
 
69
- export function createOutputJob(snapshot, kind) {
69
+ export function createOutputJob(snapshot, kind, options = {}) {
70
70
  return {
71
71
  slides: snapshot.slides,
72
72
  theme: snapshot.theme,
@@ -74,6 +74,7 @@ export function createOutputJob(snapshot, kind) {
74
74
  customThemeCss: snapshot.customThemeCss,
75
75
  customThemeMeta: snapshot.customThemeMeta,
76
76
  kind,
77
+ mermaidImageFallback: options.mermaidImageFallback === true,
77
78
  status: "pending",
78
79
  error: "",
79
80
  layout: null,
@@ -708,6 +709,7 @@ export async function exportPptx(
708
709
  requestedPath,
709
710
  requestedTheme,
710
711
  dependencies = {},
712
+ options = {},
711
713
  ) {
712
714
  const findBrowser = dependencies.findChromiumBrowser ?? findChromiumBrowser;
713
715
  const runBrowser = dependencies.runPptxOutputBrowser ?? runPptxOutputBrowser;
@@ -748,7 +750,7 @@ export async function exportPptx(
748
750
  profileDir = await mkdtemp(join(tmpdir(), "markdstage-pptx-"));
749
751
  const outputBase = basename(outputPath, extname(outputPath)) || "markdstage";
750
752
  temporaryOutputPath = join(outputParent, `.${outputBase}.${token}.tmp.pptx`);
751
- const job = createOutputJob(snapshot, "pptx");
753
+ const job = createOutputJob(snapshot, "pptx", options);
752
754
  inst.exportJobs.set(token, job);
753
755
 
754
756
  const pageUrl = pageUrlFor(inst, { pptx: 1, token });