@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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
const alpha = Math.round(
|
|
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
|
|
511
|
+
run.color === undefined ? "#000000" : run.color,
|
|
472
512
|
`${path}.color`,
|
|
473
|
-
|
|
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
|
|
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
|
-
}
|
|
616
|
-
|
|
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
|
|
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
|
|
622
|
-
const properties = `${xfrmXml(bounds
|
|
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
|
|
745
|
-
const alpha = Math.round(
|
|
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: {
|
|
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> [
|
|
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));
|
package/src/commands/export.mjs
CHANGED
|
@@ -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);
|