@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.
@@ -19,6 +19,9 @@ const NS_REL =
19
19
  const JAPANESE_FONT_FACE = "Yu Gothic";
20
20
  const HUNDREDTH_POINTS_PER_PIXEL = 75;
21
21
  const SLIDE_LAYOUT_ID_BASE = 2147500000;
22
+ const DRAWINGML_ANGLE_UNITS_PER_DEGREE = 60000;
23
+ const DRAWINGML_HALF_TURN = 180 * DRAWINGML_ANGLE_UNITS_PER_DEGREE;
24
+ const DRAWINGML_FULL_TURN = 360 * DRAWINGML_ANGLE_UNITS_PER_DEGREE;
22
25
 
23
26
  const REL = {
24
27
  officeDocument: `${NS_R}/officeDocument`,
@@ -111,6 +114,33 @@ function optionalUnitInterval(value, path, fallback = 1) {
111
114
  return number;
112
115
  }
113
116
 
117
+ function optionalOwnUnitInterval(value, key, path, fallback = 1) {
118
+ if (!Object.hasOwn(value, key)) {
119
+ if (key in value) fail(`${path}.${key} must be an own property`);
120
+ return fallback;
121
+ }
122
+ return optionalUnitInterval(value[key], `${path}.${key}`, fallback);
123
+ }
124
+
125
+ function optionalOwnRotationUnits(value, path) {
126
+ if (!Object.hasOwn(value, "rotation")) {
127
+ if ("rotation" in value) fail(`${path}.rotation must be an own property`);
128
+ return 0;
129
+ }
130
+ const rotation = finiteNumber(value.rotation, `${path}.rotation`);
131
+ let units = Math.round((((rotation % 360) + 360) % 360) * DRAWINGML_ANGLE_UNITS_PER_DEGREE);
132
+ if (units >= DRAWINGML_HALF_TURN) units -= DRAWINGML_FULL_TURN;
133
+ return units === 0 ? 0 : units;
134
+ }
135
+
136
+ function paintOpacities(element, path) {
137
+ return {
138
+ opacity: optionalOwnUnitInterval(element, "opacity", path),
139
+ fillOpacity: optionalOwnUnitInterval(element, "fillOpacity", path),
140
+ strokeOpacity: optionalOwnUnitInterval(element, "strokeOpacity", path),
141
+ };
142
+ }
143
+
114
144
  function boundsOf(value, path) {
115
145
  if (!value || typeof value !== "object" || Array.isArray(value)) {
116
146
  fail(`${path} must be an object`);
@@ -127,8 +157,9 @@ function emu(value) {
127
157
  return Math.round(value * PPTX_DIMENSIONS.emusPerPx);
128
158
  }
129
159
 
130
- function xfrmXml(bounds, tag = "a:xfrm") {
131
- return `<${tag}><a:off x="${emu(bounds.x)}" y="${emu(bounds.y)}"/><a:ext cx="${emu(bounds.width)}" cy="${emu(bounds.height)}"/></${tag}>`;
160
+ function xfrmXml(bounds, tag = "a:xfrm", rotationUnits = 0) {
161
+ const rotation = rotationUnits ? ` rot="${rotationUnits}"` : "";
162
+ return `<${tag}${rotation}><a:off x="${emu(bounds.x)}" y="${emu(bounds.y)}"/><a:ext cx="${emu(bounds.width)}" cy="${emu(bounds.height)}"/></${tag}>`;
132
163
  }
133
164
 
134
165
  function parseChannel(value, path) {
@@ -210,16 +241,25 @@ function colorXml(value, path, opacity = 1) {
210
241
  return color ? `<a:solidFill>${color}</a:solidFill>` : "<a:noFill/>";
211
242
  }
212
243
 
213
- function lineXml(element, path) {
244
+ function lineCapXml(value, path) {
245
+ if (value === undefined) return "";
246
+ const caps = { butt: "flat", round: "rnd", square: "sq" };
247
+ if (typeof value !== "string" || !Object.hasOwn(caps, value)) fail(`${path} is not a supported line cap`);
248
+ return ` cap="${caps[value]}"`;
249
+ }
250
+
251
+ function lineXml(element, path, opacities = paintOpacities(element, path)) {
214
252
  const width = element.strokeWidth === undefined
215
253
  ? 1
216
254
  : positiveNumber(element.strokeWidth, `${path}.strokeWidth`);
217
255
  const color = colorOf(element.stroke, `${path}.stroke`);
218
- if (!color) return `<a:ln w="${emu(width)}"><a:noFill/></a:ln>`;
219
- const opacity = optionalUnitInterval(element.opacity, `${path}.opacity`);
220
- const alpha = Math.round(color.alpha * opacity * 100000);
256
+ const cap = lineCapXml(element.lineCap, `${path}.lineCap`);
257
+ if (!color) return `<a:ln w="${emu(width)}"${cap}><a:noFill/></a:ln>`;
258
+ const alpha = Math.round(
259
+ color.alpha * opacities.opacity * opacities.strokeOpacity * 100000,
260
+ );
221
261
  const dash = dashXml(element.dash, `${path}.dash`);
222
- return `<a:ln w="${emu(width)}"><a:solidFill><a:srgbClr val="${color.hex}">${
262
+ return `<a:ln w="${emu(width)}"${cap}><a:solidFill><a:srgbClr val="${color.hex}">${
223
263
  alpha < 100000 ? `<a:alpha val="${alpha}"/>` : ""
224
264
  }</a:srgbClr></a:solidFill>${dash}</a:ln>`;
225
265
  }
@@ -468,9 +508,9 @@ function runXml(run, path, relationships) {
468
508
  .filter(Boolean)
469
509
  .join(" ");
470
510
  let properties = colorXml(
471
- run.color ?? "#000000",
511
+ run.color === undefined ? "#000000" : run.color,
472
512
  `${path}.color`,
473
- optionalUnitInterval(run.opacity, `${path}.opacity`),
513
+ optionalOwnUnitInterval(run, "opacity", path),
474
514
  );
475
515
  if (run.fontFace !== undefined) {
476
516
  if (typeof run.fontFace !== "string" || !run.fontFace) {
@@ -548,12 +588,13 @@ function textBodyXml(
548
588
  return `<${tag}>${textBodyPropertiesXml(bodyOptions, bodyPath)}<a:lstStyle/>${paragraphs}</${tag}>`;
549
589
  }
550
590
 
551
- function shapeBase(id, name, bounds, properties, text = "") {
552
- return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${xmlEscape(name)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>${xfrmXml(bounds)}${properties}</p:spPr>${text}</p:sp>`;
591
+ function shapeBase(id, name, bounds, properties, text = "", rotationUnits = 0) {
592
+ return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="${xmlEscape(name)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>${xfrmXml(bounds, "a:xfrm", rotationUnits)}${properties}</p:spPr>${text}</p:sp>`;
553
593
  }
554
594
 
555
595
  function textShapeXml(element, path, id, relationships) {
556
596
  const bounds = boundsOf(element, path);
597
+ const rotationUnits = optionalOwnRotationUnits(element, path);
557
598
  const text = { paragraphs: element.paragraphs };
558
599
  const paragraphs = Array.isArray(text.paragraphs) ? text.paragraphs : [];
559
600
  const bulletInsetPx = Math.max(
@@ -584,6 +625,7 @@ function textShapeXml(element, path, id, relationships) {
584
625
  path,
585
626
  bulletInsetPx,
586
627
  ),
628
+ rotationUnits,
587
629
  );
588
630
  }
589
631
 
@@ -604,7 +646,8 @@ function shapeTextOf(element, path) {
604
646
 
605
647
  function nativeShapeXml(element, path, id, relationships) {
606
648
  const bounds = boundsOf(element, path);
607
- const preset = {
649
+ const rotationUnits = optionalOwnRotationUnits(element, path);
650
+ const presets = {
608
651
  rect: "rect",
609
652
  roundedRect: "roundRect",
610
653
  ellipse: "ellipse",
@@ -612,18 +655,53 @@ function nativeShapeXml(element, path, id, relationships) {
612
655
  triangle: "triangle",
613
656
  hexagon: "hexagon",
614
657
  parallelogram: "parallelogram",
615
- }[element.shape];
616
- if (!preset) {
658
+ };
659
+ const customGeometries = {
660
+ quarterHeightHexagon: '<a:custGeom><a:avLst/><a:gdLst><a:gd name="dx" fmla="*/ h 1 4"/><a:gd name="rx" fmla="+- w 0 dx"/><a:gd name="cy" fmla="*/ h 1 2"/></a:gdLst><a:ahLst/><a:cxnLst/><a:rect l="l" t="t" r="r" b="b"/><a:pathLst><a:path><a:moveTo><a:pt x="dx" y="h"/></a:moveTo><a:lnTo><a:pt x="rx" y="h"/></a:lnTo><a:lnTo><a:pt x="w" y="cy"/></a:lnTo><a:lnTo><a:pt x="rx" y="0"/></a:lnTo><a:lnTo><a:pt x="dx" y="0"/></a:lnTo><a:lnTo><a:pt x="0" y="cy"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>',
661
+ sequenceTab: '<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="l" t="t" r="r" b="b"/><a:pathLst><a:path w="50000" h="20000"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="50000" y="0"/></a:lnTo><a:lnTo><a:pt x="50000" y="13000"/></a:lnTo><a:lnTo><a:pt x="41600" y="20000"/></a:lnTo><a:lnTo><a:pt x="0" y="20000"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>',
662
+ reverseParallelogram: '<a:custGeom><a:avLst/><a:gdLst><a:gd name="dx" fmla="*/ h 1 2"/><a:gd name="rx" fmla="+- w 0 dx"/></a:gdLst><a:ahLst/><a:cxnLst/><a:rect l="l" t="t" r="r" b="b"/><a:pathLst><a:path><a:moveTo><a:pt x="dx" y="h"/></a:moveTo><a:lnTo><a:pt x="w" y="h"/></a:lnTo><a:lnTo><a:pt x="rx" y="0"/></a:lnTo><a:lnTo><a:pt x="0" y="0"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>',
663
+ trapezoid: '<a:custGeom><a:avLst/><a:gdLst><a:gd name="dx" fmla="*/ h 1 2"/><a:gd name="rx" fmla="+- w 0 dx"/></a:gdLst><a:ahLst/><a:cxnLst/><a:rect l="l" t="t" r="r" b="b"/><a:pathLst><a:path><a:moveTo><a:pt x="0" y="h"/></a:moveTo><a:lnTo><a:pt x="w" y="h"/></a:lnTo><a:lnTo><a:pt x="rx" y="0"/></a:lnTo><a:lnTo><a:pt x="dx" y="0"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>',
664
+ invertedTrapezoid: '<a:custGeom><a:avLst/><a:gdLst><a:gd name="dx" fmla="*/ h 1 2"/><a:gd name="rx" fmla="+- w 0 dx"/></a:gdLst><a:ahLst/><a:cxnLst/><a:rect l="l" t="t" r="r" b="b"/><a:pathLst><a:path><a:moveTo><a:pt x="dx" y="h"/></a:moveTo><a:lnTo><a:pt x="rx" y="h"/></a:lnTo><a:lnTo><a:pt x="w" y="0"/></a:lnTo><a:lnTo><a:pt x="0" y="0"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>',
665
+ };
666
+ const adjustedGeometries = {
667
+ stadium: '<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val 50000"/></a:avLst></a:prstGeom>',
668
+ };
669
+ if (element.shape === "topRoundedRect") {
670
+ const radius = Math.min(nonNegativeNumber(element.cornerRadius === undefined ? 5 : element.cornerRadius, `${path}.cornerRadius`),
671
+ bounds.width / 2, bounds.height / 2);
672
+ const r = Math.round(radius * 9525);
673
+ // This bounded preset retains Mermaid's two quadratic top corners and square bottom.
674
+ customGeometries.topRoundedRect = `<a:custGeom><a:avLst/><a:gdLst><a:gd name="r" fmla="val ${r}"/><a:gd name="rx" fmla="+- w 0 r"/></a:gdLst><a:ahLst/><a:cxnLst/><a:rect l="l" t="t" r="r" b="b"/><a:pathLst><a:path><a:moveTo><a:pt x="0" y="h"/></a:moveTo><a:lnTo><a:pt x="0" y="r"/></a:lnTo><a:quadBezTo><a:pt x="0" y="0"/><a:pt x="r" y="0"/></a:quadBezTo><a:lnTo><a:pt x="rx" y="0"/></a:lnTo><a:quadBezTo><a:pt x="w" y="0"/><a:pt x="w" y="r"/></a:quadBezTo><a:lnTo><a:pt x="w" y="h"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>`;
675
+ }
676
+ if (element.shape === "roundedRect" && element.cornerRadius !== undefined) {
677
+ const radius = nonNegativeNumber(element.cornerRadius, `${path}.cornerRadius`);
678
+ const adjustment = Math.round(Math.min(50000,
679
+ radius / Math.min(bounds.width, bounds.height) * 100000));
680
+ adjustedGeometries.roundedRect =
681
+ `<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val ${adjustment}"/></a:avLst></a:prstGeom>`;
682
+ }
683
+ const shape = Object.hasOwn(element, "shape") ? element.shape : undefined;
684
+ const preset = typeof shape === "string" && Object.hasOwn(presets, shape) ? presets[shape] : "";
685
+ const customGeometry = typeof shape === "string" && Object.hasOwn(customGeometries, shape)
686
+ ? customGeometries[shape]
687
+ : "";
688
+ const adjustedGeometry = typeof shape === "string" && Object.hasOwn(adjustedGeometries, shape)
689
+ ? adjustedGeometries[shape]
690
+ : "";
691
+ const geometry = adjustedGeometry || customGeometry || (preset
692
+ ? `<a:prstGeom prst="${preset}"><a:avLst/></a:prstGeom>`
693
+ : "");
694
+ if (!geometry) {
617
695
  fail(
618
- `${path}.shape must be rect, roundedRect, ellipse, diamond, triangle, hexagon, or parallelogram`,
696
+ `${path}.shape must be rect, roundedRect, topRoundedRect, stadium, ellipse, diamond, triangle, hexagon, quarterHeightHexagon, parallelogram, reverseParallelogram, trapezoid, invertedTrapezoid, or sequenceTab`,
619
697
  );
620
698
  }
621
- const opacity = optionalUnitInterval(element.opacity, `${path}.opacity`);
622
- const properties = `${xfrmXml(bounds)}<a:prstGeom prst="${preset}"><a:avLst/></a:prstGeom>${colorXml(
699
+ const opacities = paintOpacities(element, path);
700
+ const properties = `${xfrmXml(bounds, "a:xfrm", rotationUnits)}${geometry}${colorXml(
623
701
  element.fill,
624
702
  `${path}.fill`,
625
- opacity,
626
- )}${lineXml(element, path)}`;
703
+ opacities.opacity * opacities.fillOpacity,
704
+ )}${lineXml(element, path, opacities)}`;
627
705
  const shapeText = shapeTextOf(element, path);
628
706
  if (!shapeText) textBodyPropertiesXml(element, path);
629
707
  const text = shapeText
@@ -741,9 +819,12 @@ function connectorXml(element, path, nextId, relationships) {
741
819
  : positiveNumber(element.strokeWidth, `${path}.strokeWidth`);
742
820
  const color = colorOf(element.stroke ?? "#000000", `${path}.stroke`);
743
821
  if (!color) fail(`${path}.stroke cannot be null`);
744
- const opacity = optionalUnitInterval(element.opacity, `${path}.opacity`);
745
- const alpha = Math.round(color.alpha * opacity * 100000);
822
+ const opacities = paintOpacities(element, path);
823
+ const alpha = Math.round(
824
+ color.alpha * opacities.opacity * opacities.strokeOpacity * 100000,
825
+ );
746
826
  const dash = dashXml(element.dash, `${path}.dash`);
827
+ const cap = lineCapXml(element.lineCap, `${path}.lineCap`);
747
828
  const shapes = [];
748
829
  for (let index = 0; index < points.length - 1; index += 1) {
749
830
  const start = points[index];
@@ -762,7 +843,7 @@ function connectorXml(element, path, nextId, relationships) {
762
843
  ? arrowXml(element.arrowEnd, `${path}.arrowEnd`)
763
844
  : "";
764
845
  shapes.push(
765
- `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="Connector ${id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm${flipH}${flipV}><a:off x="${emu(x)}" y="${emu(y)}"/><a:ext cx="${emu(Math.abs(end.x - start.x))}" cy="${emu(Math.abs(end.y - start.y))}"/></a:xfrm><a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:ln w="${emu(width)}"><a:solidFill><a:srgbClr val="${color.hex}">${
846
+ `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="Connector ${id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm${flipH}${flipV}><a:off x="${emu(x)}" y="${emu(y)}"/><a:ext cx="${emu(Math.abs(end.x - start.x))}" cy="${emu(Math.abs(end.y - start.y))}"/></a:xfrm><a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:ln w="${emu(width)}"${cap}><a:solidFill><a:srgbClr val="${color.hex}">${
766
847
  alpha < 100000 ? `<a:alpha val="${alpha}"/>` : ""
767
848
  }</a:srgbClr></a:solidFill>${dash}${head}${tail}</a:ln></p:spPr></p:sp>`,
768
849
  );
@@ -18,6 +18,7 @@ import { dirname, join, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { randomBytes } from "node:crypto";
20
20
  import { resolveAssetFile } from "../scripts/asset-paths.mjs";
21
+ import { loadSlideBackgrounds, resolveSlideBackgroundFile } from "./slide-backgrounds.mjs";
21
22
  import { importedArchitectureBlockIndex } from "../scripts/markdown-blocks.mjs";
22
23
  import { isMarkdownPath, listMarkdownFiles } from "../scripts/markdown-files.mjs";
23
24
  import { createMarkdownWatcher } from "../scripts/markdown-watcher.mjs";
@@ -26,6 +27,7 @@ import { saveArchitectureSource } from "./architecture-source.mjs";
26
27
  import { exportPdf, exportPptx } from "./output.mjs";
27
28
  import {
28
29
  isPathInside,
30
+ outputPathForSource,
29
31
  pdfNameForSource,
30
32
  pptxNameForSource,
31
33
  } from "./output-paths.mjs";
@@ -401,6 +403,7 @@ export async function startPresentationServer(
401
403
  themeLocked: job.themeLocked,
402
404
  customThemeCss: job.customThemeCss,
403
405
  customThemeMeta: job.customThemeMeta,
406
+ mermaidImageFallback: job.mermaidImageFallback === true,
404
407
  });
405
408
  return;
406
409
  }
@@ -674,6 +677,11 @@ export async function startPresentationServer(
674
677
  body: { ok: false, error: "block_not_found" },
675
678
  };
676
679
  }
680
+ try {
681
+ await loadSlideBackgrounds(session.workspaceRoot, session.sourceName, session.slides);
682
+ } catch (error) {
683
+ return { status: 400, body: { ok: false, error: error.code, message: error.message } };
684
+ }
677
685
  const result = await saveArchitectureSource({
678
686
  workspaceRoot: session.workspaceRoot,
679
687
  sourcePath: session.sourceName,
@@ -852,16 +860,34 @@ export async function startPresentationServer(
852
860
  return;
853
861
  }
854
862
  const pptx = route === "/export-pptx";
863
+ let body = {};
864
+ if (pptx) {
865
+ try {
866
+ body = await readJsonBody(req);
867
+ if (!body || typeof body !== "object" || Array.isArray(body) ||
868
+ (body.mermaidImageFallback !== undefined && typeof body.mermaidImageFallback !== "boolean")) {
869
+ throw new Error("invalid_export_options");
870
+ }
871
+ } catch (error) {
872
+ json(res, error?.message === "payload_too_large" ? 413 : 400, {
873
+ ok: false,
874
+ error: error?.message || "bad_request",
875
+ });
876
+ return;
877
+ }
878
+ }
855
879
  try {
856
880
  const result = pptx
857
881
  ? await exportPptxImpl(
858
882
  session,
859
- pptxNameForSource(session.sourceName),
883
+ outputPathForSource(session.sourceName, pptxNameForSource(session.sourceName)),
860
884
  session.theme,
885
+ undefined,
886
+ { mermaidImageFallback: body.mermaidImageFallback === true },
861
887
  )
862
888
  : await exportPdfImpl(
863
889
  session,
864
- pdfNameForSource(session.sourceName),
890
+ outputPathForSource(session.sourceName, pdfNameForSource(session.sourceName)),
865
891
  session.theme,
866
892
  );
867
893
  json(res, 200, result);
@@ -958,6 +984,22 @@ export async function startPresentationServer(
958
984
  return;
959
985
  }
960
986
 
987
+ if (route.startsWith("/background-assets/")) {
988
+ try {
989
+ const file = await resolveSlideBackgroundFile(
990
+ session.workspaceRoot,
991
+ session.sourceName,
992
+ `/assets/${route.slice("/background-assets/".length)}`,
993
+ );
994
+ await sendFile(res, file, { cache: false });
995
+ } catch (error) {
996
+ res.statusCode = error?.code === "slide_background_too_large" ? 413
997
+ : error?.code === "invalid_slide_background" ? 403 : 404;
998
+ res.end(error.message);
999
+ }
1000
+ return;
1001
+ }
1002
+
961
1003
  if (route.startsWith("/assets/")) {
962
1004
  try {
963
1005
  const abs = await resolveAssetFile(
@@ -0,0 +1,44 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { parseSlideBackground } from "../renderer/slide-background.mjs";
3
+ import { parseFrontMatter, THEME_ASSET_MAX_BYTES } from "../renderer/theme.mjs";
4
+ import { resolveAssetFile } from "../scripts/asset-paths.mjs";
5
+ import { MarkdStageError } from "./errors.mjs";
6
+
7
+ export async function resolveSlideBackgroundFile(workspaceRoot, sourceName, value) {
8
+ let canonical;
9
+ try {
10
+ canonical = parseSlideBackground(value);
11
+ } catch (error) {
12
+ throw new MarkdStageError("invalid_slide_background", error.message);
13
+ }
14
+ if (!canonical) return null;
15
+ let file;
16
+ try {
17
+ file = await resolveAssetFile(workspaceRoot, sourceName, canonical.slice("/assets/".length));
18
+ } catch (error) {
19
+ throw new MarkdStageError("invalid_slide_background", `Invalid background-image ${canonical}: ${error.message}`);
20
+ }
21
+ if (!file) {
22
+ throw new MarkdStageError("slide_background_not_found", `Background image was not found: ${canonical}`);
23
+ }
24
+ const info = await stat(file);
25
+ if (!info.isFile()) {
26
+ throw new MarkdStageError("slide_background_not_found", `Background image is not a file: ${canonical}`);
27
+ }
28
+ if (info.size > THEME_ASSET_MAX_BYTES) {
29
+ throw new MarkdStageError("slide_background_too_large", `Background image must be 2 MiB or smaller: ${canonical}`);
30
+ }
31
+ return file;
32
+ }
33
+
34
+ export async function loadSlideBackgrounds(workspaceRoot, sourceName, slides) {
35
+ for (let index = 0; index < slides.length; index += 1) {
36
+ const meta = parseFrontMatter(slides[index]);
37
+ if (!Object.hasOwn(meta, "background-image")) continue;
38
+ try {
39
+ await resolveSlideBackgroundFile(workspaceRoot, sourceName, meta["background-image"]);
40
+ } catch (error) {
41
+ throw new MarkdStageError(error.code || "invalid_slide_background", `Slide ${index + 1}: ${error.message}`);
42
+ }
43
+ }
44
+ }
@@ -12,6 +12,22 @@
12
12
  "version": {
13
13
  "const": 1
14
14
  },
15
+ "background": {
16
+ "description": "Common decorative background for default and center layouts only.",
17
+ "$ref": "#/$defs/decorativeImage"
18
+ },
19
+ "layouts": {
20
+ "type": "object",
21
+ "properties": {
22
+ "default": {
23
+ "$ref": "#/$defs/layoutBackground"
24
+ },
25
+ "center": {
26
+ "$ref": "#/$defs/layoutBackground"
27
+ }
28
+ },
29
+ "additionalProperties": false
30
+ },
15
31
  "cover": {
16
32
  "type": "object",
17
33
  "properties": {
@@ -39,6 +55,15 @@
39
55
  },
40
56
  "additionalProperties": false,
41
57
  "$defs": {
58
+ "layoutBackground": {
59
+ "type": "object",
60
+ "properties": {
61
+ "background": {
62
+ "$ref": "#/$defs/decorativeImage"
63
+ }
64
+ },
65
+ "additionalProperties": false
66
+ },
42
67
  "assetPath": {
43
68
  "type": "string",
44
69
  "maxLength": 200,
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/runceel/markdstage/schema/theme-v1.json",
4
4
  "title": "MarkdStage custom theme v1",
5
- "description": "Machine-readable catalog for CSS custom properties supported by the MarkdStage canvas custom theme. Optional cover/backcover assets live in a sibling theme.json manifest.",
5
+ "description": "Machine-readable catalog for CSS custom properties supported by the MarkdStage canvas custom theme. Optional common and default/center background images and cover/backcover assets live in a sibling theme.json manifest; background-image front matter overrides images per slide without CSS url().",
6
6
  "type": "object",
7
7
  "required": [
8
8
  "version",
@@ -16,7 +16,7 @@
16
16
  "type": "object",
17
17
  "description": "CSS custom property declarations. The runtime accepts any name matching the pattern.",
18
18
  "properties": {
19
- "--bg": { "type": "string", "description": "Standard slide background" },
19
+ "--bg": { "type": "string", "description": "Standard slide background beneath optional decorative images" },
20
20
  "--fg": { "type": "string", "description": "Headings and primary text" },
21
21
  "--muted": { "type": "string", "description": "Secondary text" },
22
22
  "--body": { "type": "string", "description": "Body text" },
@@ -80,5 +80,5 @@
80
80
  "additionalProperties": false,
81
81
  "x-theme-file-format": "CSS custom property declarations, optionally wrapped in one :root block.",
82
82
  "x-value-syntax": "Any non-empty CSS value except selectors, @import, url(), javascript:, expression(), and style tags.",
83
- "x-theme-metadata": "When present, theme.json beside the CSS file must conform to theme-metadata-v1.schema.json."
83
+ "x-theme-metadata": "When present, theme.json beside the CSS file must conform to theme-metadata-v1.schema.json. Decorative { image, alt? } entries in background and layouts.default.background/layouts.center.background use theme-local assets/ paths. Layout images override the common background for default/center only; title retains cover.background. Per-slide background-image overrides every layout and theme. Images are centered cover over existing colors; missing settings, not invalid images, trigger fallback."
84
84
  }
package/src/cli.mjs CHANGED
@@ -64,7 +64,10 @@ const COMMAND_OPTIONS = {
64
64
  validate: {},
65
65
  inspect: { slide: { type: "string" }, all: { type: "boolean" }, "fail-on-issues": { type: "boolean" } },
66
66
  capture: { pages: { type: "string" }, output: { type: "string" } },
67
- export: { output: { type: "string" } },
67
+ export: {
68
+ output: { type: "string" },
69
+ "mermaid-image-fallback": { type: "boolean" },
70
+ },
68
71
  guide: {},
69
72
  skill: {
70
73
  target: { type: "string" },
@@ -156,11 +159,14 @@ function usage(command) {
156
159
  "Without --pages only the slides reported as clipped are captured.",
157
160
  ],
158
161
  export: [
159
- "Usage: markdstage export <file.md> [--output slides.pdf|slides.pptx]",
162
+ "Usage: markdstage export <file.md> [options]",
160
163
  "",
161
164
  "Produces the same 16:9 PDF or hybrid editable PowerPoint as the MarkdStage canvas.",
162
165
  "PowerPoint output includes speaker-note Markdown as readable plain text notes.",
163
166
  "The output extension selects the format; omitting --output keeps PDF as the default.",
167
+ " --output <path> Write PDF or PowerPoint to this path.",
168
+ " --mermaid-image-fallback Render each Mermaid diagram as one image in PowerPoint.",
169
+ " Applies only with an explicit .pptx output.",
164
170
  ],
165
171
  guide: [
166
172
  "Usage: markdstage guide [topic] [--json]",
@@ -421,6 +427,7 @@ export async function run(argv, io = {}) {
421
427
  const report = await exportCommand({
422
428
  ...deckOptions(file, values),
423
429
  output: values.output,
430
+ mermaidImageFallback: values["mermaid-image-fallback"],
424
431
  });
425
432
  if (values.json) json(report);
426
433
  else out(formatExportReport(report));
@@ -22,6 +22,8 @@ export async function exportCommand(
22
22
  session,
23
23
  options.output || pptxNameForSource(session.sourceName),
24
24
  options.theme,
25
+ undefined,
26
+ { mermaidImageFallback: options.mermaidImageFallback === true },
25
27
  );
26
28
  }
27
29
  return exporters.pdf(session, requested, options.theme);