@markdstage/markdstage 3.4.0 → 3.8.1
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/README.md +2 -1
- package/package.json +1 -1
- package/shared/README.md +40 -4
- package/shared/architecture-editor/editor.css +9 -5
- package/shared/architecture-editor/editor.js +440 -75
- package/shared/architecture-editor/index.html +2 -2
- package/shared/docs/custom-theme-authoring.md +61 -4
- package/shared/markdown-deck.mjs +9 -5
- package/shared/renderer/architecture-document.mjs +169 -10
- package/shared/renderer/index.html +26 -3
- package/shared/renderer/mermaid-scene.mjs +6725 -197
- package/shared/renderer/renderer.js +421 -102
- package/shared/renderer/scene-graph.mjs +83 -13
- package/shared/renderer/scene-pptx.mjs +154 -1
- package/shared/renderer/scene-svg.mjs +227 -11
- package/shared/renderer/slide-background.mjs +22 -0
- package/shared/renderer/slide-viewport.mjs +48 -0
- package/shared/renderer/slides.css +41 -10
- package/shared/renderer/theme.mjs +328 -12
- package/shared/runtime/browser.mjs +75 -5
- package/shared/runtime/deck-session.mjs +12 -17
- package/shared/runtime/output-paths.mjs +7 -0
- package/shared/runtime/output.mjs +4 -2
- package/shared/runtime/pptx-package.mjs +103 -22
- package/shared/runtime/presentation-server.mjs +44 -2
- package/shared/runtime/slide-backgrounds.mjs +44 -0
- package/shared/schema/theme-metadata-v1.schema.json +25 -0
- package/shared/schema/theme-v1.json +3 -3
- package/src/cli.mjs +9 -2
- package/src/commands/export.mjs +2 -0
|
@@ -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
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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:
|
|
139
|
+
primaryBorderColor: muted,
|
|
127
140
|
secondaryColor: accentStrong,
|
|
128
141
|
secondaryTextColor: background,
|
|
129
|
-
secondaryBorderColor:
|
|
142
|
+
secondaryBorderColor: accent,
|
|
130
143
|
tertiaryColor: muted,
|
|
131
144
|
tertiaryTextColor: background,
|
|
132
|
-
tertiaryBorderColor:
|
|
145
|
+
tertiaryBorderColor: muted,
|
|
133
146
|
lineColor: accent,
|
|
134
147
|
textColor: foreground,
|
|
135
148
|
mainBkg: surface,
|
|
136
|
-
nodeBorder:
|
|
149
|
+
nodeBorder: muted,
|
|
137
150
|
clusterBkg: accentSoft,
|
|
138
|
-
clusterBorder:
|
|
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(
|
|
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
|
|
233
|
-
|
|
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
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
159
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|