@markdstage/markdstage 0.1.3 → 2.3.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.
@@ -90,7 +90,9 @@ let lastMermaidTheme = null;
90
90
  // Editing mode is available only in normal view, not presenter or print mode.
91
91
  // Print mode returns early in init, so presenterMode is the effective branch here.
92
92
  let architectureEditMode = false;
93
+ let architectureEditAvailable = false;
93
94
  let architectureDetailedEdit = false;
95
+ let architectureDetailedEditTarget = "";
94
96
  let presenterMode = false;
95
97
  let previewMode = false;
96
98
  let previewOffset = 0;
@@ -100,6 +102,9 @@ let fixedPreviewMode = false;
100
102
  let lastMarkdown = "";
101
103
  // Editing UI attached to the rendered slide; destroyed on every rerender.
102
104
  let architectureEditors = [];
105
+ // Serialize saves from every Architecture block so each request uses the deck
106
+ // version returned by the previous save.
107
+ let architectureSaveQueue = Promise.resolve();
103
108
  // `layoutTarget` is the slide currently on screen (cover and back cover
104
109
  // included); `autoSize` says whether it also takes part in the font auto-fit.
105
110
  let layoutTarget = null;
@@ -701,13 +706,15 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
701
706
  host.className = "architecture-edit-host";
702
707
  host.setAttribute("data-architecture-block", String(blockIndex));
703
708
  target.replaceWith(host);
709
+ const editorRenderToken = renderToken;
704
710
  const editor = attachArchitectureEditor(host, {
705
711
  source,
706
712
  documentRef: document,
707
713
  canOpenDetail: architectureDetailedEdit,
708
714
  onOpenDetail: () => openDetailedArchitectureEditor(slideIndex, blockIndex),
709
715
  // Return the save result to the editor; omitting it makes failures look successful.
710
- onCommit: (next) => saveArchitectureBlock(slideIndex, blockIndex, next),
716
+ onCommit: (next) =>
717
+ saveArchitectureBlock(slideIndex, blockIndex, next, editorRenderToken),
711
718
  });
712
719
  if (!editor) {
713
720
  // Do not edit invalid DSL; fall back to the standard error display.
@@ -759,6 +766,7 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
759
766
  sizeMode,
760
767
  titleSlide,
761
768
  sectionSlide,
769
+ centerSlide,
762
770
  backcoverSlide,
763
771
  title: meta.title || meta.deck || "Slide",
764
772
  };
@@ -771,11 +779,11 @@ function renderSlide(markdown) {
771
779
  architectureEditors.forEach((editor) => editor.destroy());
772
780
  architectureEditors = [];
773
781
  applyCustomThemeCss(customThemeCss);
782
+ const token = ++renderToken;
774
783
  const slide = createSlide(markdown, deckTheme);
775
784
  document.title = slide.title;
776
785
  document.documentElement.setAttribute("data-theme", slide.theme);
777
786
 
778
- const token = ++renderToken;
779
787
  document.body.classList.add("mermaid-loading");
780
788
  document.getElementById("stage").replaceChildren(slide.deck);
781
789
  if (layoutFrame) {
@@ -857,6 +865,990 @@ async function reportOutputStatus(token, status, error = "", layout = null) {
857
865
  if (!response.ok) throw new Error(`Could not report output status (${response.status}).`);
858
866
  }
859
867
 
868
+ const PPTX_RASTER_IMAGE = /\.(?:png|jpe?g|gif)(?:$|[?#])/i;
869
+
870
+ function normalizeCssColor(value) {
871
+ const text = String(value || "").trim();
872
+ if (!text || text === "none" || text === "transparent") return null;
873
+ const shortHex = text.match(/^#([\da-f])([\da-f])([\da-f])$/i);
874
+ if (shortHex) {
875
+ return `#${shortHex.slice(1).map((part) => part.repeat(2)).join("")}`.toUpperCase();
876
+ }
877
+ const hex = text.match(/^#([\da-f]{6})(?:[\da-f]{2})?$/i);
878
+ if (hex) return `#${text.slice(1).toUpperCase()}`;
879
+ const rgb = text.match(
880
+ /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i,
881
+ );
882
+ if (!rgb) return text;
883
+ if (rgb[4] === "0" || rgb[4] === "0%") return null;
884
+ const channels = rgb
885
+ .slice(1, 4)
886
+ .map((part) => Math.max(0, Math.min(255, Math.round(Number(part)))));
887
+ if (rgb[4] !== undefined) {
888
+ const alpha = rgb[4].endsWith("%") ? Number.parseFloat(rgb[4]) / 100 : Number(rgb[4]);
889
+ if (Number.isFinite(alpha) && alpha < 1) {
890
+ return `rgba(${channels.join(", ")}, ${Math.max(0, alpha)})`;
891
+ }
892
+ }
893
+ return `#${channels
894
+ .map((part) => part.toString(16).padStart(2, "0"))
895
+ .join("")
896
+ .toUpperCase()}`;
897
+ }
898
+
899
+ function resolveModelColor(value, context) {
900
+ if (!value || value === "none" || value === "transparent") return null;
901
+ const probe = document.createElement("span");
902
+ probe.style.color = String(value);
903
+ probe.style.position = "absolute";
904
+ probe.style.visibility = "hidden";
905
+ context.appendChild(probe);
906
+ const resolved = normalizeCssColor(getComputedStyle(probe).color);
907
+ probe.remove();
908
+ return resolved;
909
+ }
910
+
911
+ function relativeBounds(element, deck) {
912
+ const rect = element.getBoundingClientRect();
913
+ const slide = deck.getBoundingClientRect();
914
+ return {
915
+ x: roundedMetric(rect.left - slide.left),
916
+ y: roundedMetric(rect.top - slide.top),
917
+ width: roundedMetric(rect.width),
918
+ height: roundedMetric(rect.height),
919
+ };
920
+ }
921
+
922
+ function textContentBounds(element, deck) {
923
+ const range = document.createRange();
924
+ range.selectNodeContents(element);
925
+ const rect = range.getBoundingClientRect();
926
+ if (rect.width <= 0 || rect.height <= 0) return relativeBounds(element, deck);
927
+ const slide = deck.getBoundingClientRect();
928
+ return {
929
+ x: roundedMetric(rect.left - slide.left),
930
+ y: roundedMetric(rect.top - slide.top),
931
+ width: roundedMetric(rect.width),
932
+ height: roundedMetric(rect.height),
933
+ };
934
+ }
935
+
936
+ function rasterImageSupported(image) {
937
+ const source = image.currentSrc || image.getAttribute("src") || "";
938
+ if (["cover", "none"].includes(getComputedStyle(image).objectFit)) return false;
939
+ if (/^data:image\/(?:png|jpeg|gif)[;,]/i.test(source)) return true;
940
+ try {
941
+ const url = new URL(source, window.location.href);
942
+ return url.origin === window.location.origin && PPTX_RASTER_IMAGE.test(url.href);
943
+ } catch (_) {
944
+ return false;
945
+ }
946
+ }
947
+
948
+ function runStyle(element) {
949
+ const style = getComputedStyle(element);
950
+ const numericWeight = Number.parseInt(style.fontWeight, 10);
951
+ return {
952
+ fontFace: style.fontFamily.split(",")[0].trim().replace(/^["']|["']$/g, ""),
953
+ fontSize: roundedMetric(parseFloat(style.fontSize)),
954
+ bold: Number.isFinite(numericWeight) ? numericWeight >= 600 : style.fontWeight === "bold",
955
+ italic: style.fontStyle !== "normal",
956
+ underline: style.textDecorationLine.includes("underline"),
957
+ color: normalizeCssColor(style.color),
958
+ };
959
+ }
960
+
961
+ function collectTextRuns(root, { omitNestedLists = false } = {}) {
962
+ const runs = [];
963
+ const append = (text, element) => {
964
+ if (!text) return;
965
+ const anchor = element.closest("a[href]");
966
+ const run = {
967
+ text,
968
+ ...runStyle(element),
969
+ ...(anchor ? { href: anchor.getAttribute("href") || anchor.href } : {}),
970
+ };
971
+ const previous = runs.at(-1);
972
+ const previousStyle = previous && { ...previous, text: undefined };
973
+ const nextStyle = { ...run, text: undefined };
974
+ if (previous && JSON.stringify(previousStyle) === JSON.stringify(nextStyle)) {
975
+ previous.text += text;
976
+ } else {
977
+ runs.push(run);
978
+ }
979
+ };
980
+ const visit = (node) => {
981
+ if (node.nodeType === Node.TEXT_NODE) {
982
+ append(node.nodeValue || "", node.parentElement || root);
983
+ return;
984
+ }
985
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
986
+ if (node.tagName === "BR") {
987
+ append("\n", node.parentElement || root);
988
+ return;
989
+ }
990
+ if (
991
+ node !== root &&
992
+ (node.matches("pre, table, .architecture-diagram, .architecture-error, .mermaid") ||
993
+ (omitNestedLists && node.matches("ul, ol")))
994
+ ) {
995
+ return;
996
+ }
997
+ node.childNodes.forEach(visit);
998
+ };
999
+ root.childNodes.forEach(visit);
1000
+ return runs;
1001
+ }
1002
+
1003
+ function pptxAlignment(value) {
1004
+ if (value === "center" || value === "right" || value === "justify") return value;
1005
+ if (value === "end") return "right";
1006
+ return "left";
1007
+ }
1008
+
1009
+ function paragraphFor(element, options = {}) {
1010
+ const style = getComputedStyle(element);
1011
+ const runs = collectTextRuns(element, options);
1012
+ return {
1013
+ alignment: pptxAlignment(style.textAlign),
1014
+ lineHeight: roundedMetric(parseFloat(style.lineHeight)),
1015
+ runs,
1016
+ ...(options.level !== undefined ? { level: options.level } : {}),
1017
+ ...(options.bullet ? { bullet: options.bullet } : {}),
1018
+ };
1019
+ }
1020
+
1021
+ function renderedTextLineCount(element) {
1022
+ const range = document.createRange();
1023
+ range.selectNodeContents(element);
1024
+ const tops = [];
1025
+ for (const rect of range.getClientRects()) {
1026
+ if (rect.width <= 0 || rect.height <= 0) continue;
1027
+ if (!tops.some((top) => Math.abs(top - rect.top) < 1)) tops.push(rect.top);
1028
+ }
1029
+ return Math.max(1, tops.length);
1030
+ }
1031
+
1032
+ function unsupportedEffects(element) {
1033
+ const style = getComputedStyle(element);
1034
+ const effects = [];
1035
+ if (style.textShadow && style.textShadow !== "none") effects.push("text-shadow");
1036
+ if (style.boxShadow && style.boxShadow !== "none") effects.push("box-shadow");
1037
+ if (style.filter && style.filter !== "none") effects.push("filter");
1038
+ if (style.backdropFilter && style.backdropFilter !== "none") effects.push("backdrop-filter");
1039
+ if (style.mixBlendMode && style.mixBlendMode !== "normal") effects.push("mix-blend-mode");
1040
+ if (style.transform && style.transform !== "none") effects.push("transform");
1041
+ if (Number(style.opacity) < 1) effects.push("opacity");
1042
+ return effects;
1043
+ }
1044
+
1045
+ function effectFallbackRoot(element) {
1046
+ return element.closest(
1047
+ "p, li, blockquote, table, img, h1, h2, h3, h4, h5, h6, .kicker, .slide-title, .theme-backcover-logo-text, .theme-backcover-copyright, .body, header, footer",
1048
+ );
1049
+ }
1050
+
1051
+ function pptxFallback(type, element, deck, reason) {
1052
+ return {
1053
+ type,
1054
+ path: elementPath(element, deck),
1055
+ reason,
1056
+ ...relativeBounds(element, deck),
1057
+ };
1058
+ }
1059
+
1060
+ function preserveBoxShadow(element, deck) {
1061
+ const style = getComputedStyle(element);
1062
+ if (!style.boxShadow || style.boxShadow === "none") return;
1063
+ const bounds = relativeBounds(element, deck);
1064
+ const decoration = document.createElement("div");
1065
+ decoration.className = "pptx-effect-fallback";
1066
+ Object.assign(decoration.style, {
1067
+ position: "absolute",
1068
+ left: `${bounds.x}px`,
1069
+ top: `${bounds.y}px`,
1070
+ width: `${bounds.width}px`,
1071
+ height: `${bounds.height}px`,
1072
+ borderRadius: style.borderRadius,
1073
+ boxShadow: style.boxShadow,
1074
+ pointerEvents: "none",
1075
+ });
1076
+ decoration.setAttribute("aria-hidden", "true");
1077
+ deck.appendChild(decoration);
1078
+ }
1079
+
1080
+ function blobDataUrl(blob) {
1081
+ return new Promise((resolve, reject) => {
1082
+ const reader = new FileReader();
1083
+ reader.addEventListener("load", () => resolve(String(reader.result || "")), {
1084
+ once: true,
1085
+ });
1086
+ reader.addEventListener(
1087
+ "error",
1088
+ () => reject(reader.error || new Error("Could not encode Architecture artwork.")),
1089
+ { once: true },
1090
+ );
1091
+ reader.readAsDataURL(blob);
1092
+ });
1093
+ }
1094
+
1095
+ function freezeSvgPaint(source, clone) {
1096
+ const sourceNodes = [source, ...source.querySelectorAll("*")];
1097
+ const cloneNodes = [clone, ...clone.querySelectorAll("*")];
1098
+ sourceNodes.forEach((node, index) => {
1099
+ const target = cloneNodes[index];
1100
+ if (!target) return;
1101
+ const style = getComputedStyle(node);
1102
+ for (const property of ["fill", "stroke", "color"]) {
1103
+ if (style[property]) target.setAttribute(property, style[property]);
1104
+ }
1105
+ });
1106
+ }
1107
+
1108
+ async function inlineSvgImageSources(root) {
1109
+ for (const image of root.querySelectorAll("image")) {
1110
+ const href = image.getAttribute("href") || image.getAttribute("xlink:href");
1111
+ if (!href || href.startsWith("data:")) continue;
1112
+ const response = await fetch(new URL(href, window.location.href), { cache: "no-store" });
1113
+ if (!response.ok) {
1114
+ throw new Error(`Could not load Architecture image artwork (${response.status}).`);
1115
+ }
1116
+ const dataUrl = await blobDataUrl(await response.blob());
1117
+ image.setAttribute("href", dataUrl);
1118
+ image.removeAttribute("xlink:href");
1119
+ }
1120
+ }
1121
+
1122
+ async function architectureForegroundPng(svg, sources, width, height, crop) {
1123
+ if (!sources.length) return "";
1124
+ const clone = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1125
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
1126
+ const viewBox = svg.viewBox?.baseVal;
1127
+ clone.setAttribute("viewBox", svg.getAttribute("viewBox") || "0 0 1 1");
1128
+ clone.setAttribute("width", String(viewBox?.width || 1));
1129
+ clone.setAttribute("height", String(viewBox?.height || 1));
1130
+ clone.setAttribute("preserveAspectRatio", "xMidYMid meet");
1131
+ const defs = svg.querySelector(":scope > defs");
1132
+ if (defs) clone.appendChild(defs.cloneNode(true));
1133
+ for (const source of sources) {
1134
+ const copy = source.cloneNode(true);
1135
+ freezeSvgPaint(source, copy);
1136
+ const ownOpacityValue = Number(getComputedStyle(source).opacity);
1137
+ const ownOpacity = Number.isFinite(ownOpacityValue) ? ownOpacityValue : 1;
1138
+ const node = source.closest('[data-architecture-type="node"]');
1139
+ const parentOpacityAttribute =
1140
+ node && node !== source ? node.getAttribute("opacity") : null;
1141
+ const parentOpacityValue =
1142
+ parentOpacityAttribute === null ? 1 : Number(parentOpacityAttribute);
1143
+ const parentOpacity = Number.isFinite(parentOpacityValue) ? parentOpacityValue : 1;
1144
+ copy.setAttribute("opacity", String(ownOpacity * parentOpacity));
1145
+ clone.appendChild(copy);
1146
+ }
1147
+ await inlineSvgImageSources(clone);
1148
+ const markup = new XMLSerializer().serializeToString(clone);
1149
+ const url = URL.createObjectURL(new Blob([markup], { type: "image/svg+xml" }));
1150
+ try {
1151
+ const image = new Image();
1152
+ image.src = url;
1153
+ await image.decode();
1154
+ const canvas = document.createElement("canvas");
1155
+ canvas.width = Math.max(1, Math.ceil(width));
1156
+ canvas.height = Math.max(1, Math.ceil(height));
1157
+ const context = canvas.getContext("2d");
1158
+ if (!context) throw new Error("Could not create Architecture artwork canvas.");
1159
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
1160
+ if (!crop) return canvas.toDataURL("image/png");
1161
+ const cropped = document.createElement("canvas");
1162
+ cropped.width = Math.max(1, Math.ceil(crop.width));
1163
+ cropped.height = Math.max(1, Math.ceil(crop.height));
1164
+ const croppedContext = cropped.getContext("2d");
1165
+ if (!croppedContext) throw new Error("Could not crop Architecture artwork.");
1166
+ croppedContext.drawImage(
1167
+ canvas,
1168
+ crop.x,
1169
+ crop.y,
1170
+ crop.width,
1171
+ crop.height,
1172
+ 0,
1173
+ 0,
1174
+ cropped.width,
1175
+ cropped.height,
1176
+ );
1177
+ return cropped.toDataURL("image/png");
1178
+ } finally {
1179
+ URL.revokeObjectURL(url);
1180
+ }
1181
+ }
1182
+
1183
+ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1184
+ const snapshot = wrapper.__presentationPptxSnapshot;
1185
+ const svg = wrapper.querySelector("svg.architecture-svg");
1186
+ if (!snapshot || !svg) {
1187
+ return {
1188
+ elements: [],
1189
+ fallbacks: [pptxFallback("architecture", wrapper, deck, "architecture-model-unavailable")],
1190
+ };
1191
+ }
1192
+ const svgRect = svg.getBoundingClientRect();
1193
+ const deckRect = deck.getBoundingClientRect();
1194
+ const scale = Math.min(
1195
+ svgRect.width / snapshot.canvas.width,
1196
+ svgRect.height / snapshot.canvas.height,
1197
+ );
1198
+ const usedWidth = snapshot.canvas.width * scale;
1199
+ const usedHeight = snapshot.canvas.height * scale;
1200
+ const originX = svgRect.left - deckRect.left + (svgRect.width - usedWidth) / 2;
1201
+ const originY = svgRect.top - deckRect.top + (svgRect.height - usedHeight) / 2;
1202
+ const mapBounds = (object) => ({
1203
+ x: roundedMetric(originX + object.x * scale),
1204
+ y: roundedMetric(originY + object.y * scale),
1205
+ width: roundedMetric(object.width * scale),
1206
+ height: roundedMetric(object.height * scale),
1207
+ });
1208
+ const mapColorFields = (object) => {
1209
+ const mapped = { ...object };
1210
+ const mapParagraphs = (paragraphs) =>
1211
+ paragraphs.map((paragraph) => ({
1212
+ ...paragraph,
1213
+ runs: paragraph.runs.map((run) => ({
1214
+ ...run,
1215
+ color: resolveModelColor(run.color, deck),
1216
+ fontFace: getComputedStyle(svg).fontFamily
1217
+ .split(",")[0]
1218
+ .trim()
1219
+ .replace(/^["']|["']$/g, ""),
1220
+ fontSize: roundedMetric(run.fontSize * scale),
1221
+ bold: Number(run.fontWeight) >= 600,
1222
+ })),
1223
+ }));
1224
+ if (mapped.dash !== undefined) mapped.dash = mapped.dash ? "dash" : "solid";
1225
+ for (const key of ["fill", "stroke", "color"]) {
1226
+ if (key in mapped) mapped[key] = resolveModelColor(mapped[key], deck);
1227
+ }
1228
+ if (Array.isArray(mapped.paragraphs)) {
1229
+ mapped.paragraphs = mapParagraphs(mapped.paragraphs);
1230
+ }
1231
+ if (mapped.text?.paragraphs) {
1232
+ mapped.text = {
1233
+ ...mapped.text,
1234
+ paragraphs: mapParagraphs(mapped.text.paragraphs),
1235
+ };
1236
+ }
1237
+ if (mapped.textInsets) {
1238
+ mapped.textInsets = Object.fromEntries(
1239
+ Object.entries(mapped.textInsets).map(([key, value]) => [
1240
+ key,
1241
+ roundedMetric(value * scale),
1242
+ ]),
1243
+ );
1244
+ }
1245
+ return mapped;
1246
+ };
1247
+ const fallbacks = snapshot.fallbacks.map((fallback) => ({
1248
+ ...fallback,
1249
+ path: `architecture[${blockIndex}].${fallback.path}`,
1250
+ ...mapBounds(fallback),
1251
+ }));
1252
+ const elements = [];
1253
+ const architectureGroups = [...wrapper.querySelectorAll("[data-architecture-type]")];
1254
+ const findById = (id) =>
1255
+ architectureGroups.find((element) => element.getAttribute("data-architecture-id") === id);
1256
+ const foregroundCandidates = [];
1257
+ for (const icon of snapshot.icons || []) {
1258
+ const group = findById(icon.id);
1259
+ const source = group?.querySelector("[data-architecture-icon]");
1260
+ if (source) {
1261
+ const rawBounds = mapBounds(icon);
1262
+ const padding = Math.max(
1263
+ 0,
1264
+ Math.min(2, rawBounds.x - originX, rawBounds.y - originY),
1265
+ );
1266
+ const bounds = {
1267
+ x: rawBounds.x - padding,
1268
+ y: rawBounds.y - padding,
1269
+ width: rawBounds.width + padding * 2,
1270
+ height: rawBounds.height + padding * 2,
1271
+ };
1272
+ foregroundCandidates.push({
1273
+ key: `icon:${icon.id}`,
1274
+ source,
1275
+ alt: `${icon.icon} icon`,
1276
+ bounds,
1277
+ crop: {
1278
+ x: bounds.x - originX,
1279
+ y: bounds.y - originY,
1280
+ width: bounds.width,
1281
+ height: bounds.height,
1282
+ },
1283
+ architecture: {
1284
+ kind: "icon-picture",
1285
+ id: icon.id,
1286
+ sourcePath: icon.sourcePath,
1287
+ order: icon.order,
1288
+ z: icon.z,
1289
+ },
1290
+ });
1291
+ }
1292
+ }
1293
+ for (const object of snapshot.objects.filter((entry) => entry.type === "image")) {
1294
+ const source = findById(object.architecture.id);
1295
+ if (source) {
1296
+ const rawBounds = mapBounds(object);
1297
+ const padding = Math.max(
1298
+ 0,
1299
+ Math.min(2, rawBounds.x - originX, rawBounds.y - originY),
1300
+ );
1301
+ const bounds = {
1302
+ x: rawBounds.x - padding,
1303
+ y: rawBounds.y - padding,
1304
+ width: rawBounds.width + padding * 2,
1305
+ height: rawBounds.height + padding * 2,
1306
+ };
1307
+ foregroundCandidates.push({
1308
+ key: `image:${object.architecture.id}`,
1309
+ source,
1310
+ alt: `${object.architecture.id} image`,
1311
+ bounds,
1312
+ crop: {
1313
+ x: bounds.x - originX,
1314
+ y: bounds.y - originY,
1315
+ width: bounds.width,
1316
+ height: bounds.height,
1317
+ },
1318
+ architecture: {
1319
+ ...object.architecture,
1320
+ kind: "image-picture",
1321
+ },
1322
+ });
1323
+ }
1324
+ }
1325
+ const foregroundLayers = new Map();
1326
+ const expectedForegrounds =
1327
+ (snapshot.icons?.length || 0) +
1328
+ snapshot.objects.filter((object) => object.type === "image").length;
1329
+ let foregroundReady = foregroundCandidates.length === expectedForegrounds;
1330
+ try {
1331
+ if (foregroundReady) {
1332
+ const generated = await Promise.all(
1333
+ foregroundCandidates.map(async (candidate) => ({
1334
+ ...candidate,
1335
+ src: await architectureForegroundPng(
1336
+ svg,
1337
+ [candidate.source],
1338
+ usedWidth,
1339
+ usedHeight,
1340
+ candidate.crop,
1341
+ ),
1342
+ })),
1343
+ );
1344
+ generated.forEach((layer) => foregroundLayers.set(layer.key, layer));
1345
+ } else {
1346
+ throw new Error("Architecture foreground source was not found.");
1347
+ }
1348
+ } catch (error) {
1349
+ foregroundReady = false;
1350
+ foregroundLayers.clear();
1351
+ fallbacks.push(
1352
+ pptxFallback(
1353
+ "architecture-foreground",
1354
+ wrapper,
1355
+ deck,
1356
+ `foreground-picture-failed: ${error?.message || "unknown error"}`,
1357
+ ),
1358
+ );
1359
+ }
1360
+ const foregroundElement = (layer) => ({
1361
+ type: "image",
1362
+ src: layer.src,
1363
+ alt: layer.alt,
1364
+ fit: "fill",
1365
+ opacity: 1,
1366
+ ...layer.bounds,
1367
+ architecture: layer.architecture,
1368
+ });
1369
+ if (!foregroundReady) {
1370
+ fallbacks.push(
1371
+ pptxFallback(
1372
+ "architecture",
1373
+ wrapper,
1374
+ deck,
1375
+ "architecture-rendered-as-artwork-after-foreground-failure",
1376
+ ),
1377
+ );
1378
+ return { elements: [], fallbacks };
1379
+ }
1380
+
1381
+ for (const sourceObject of snapshot.objects) {
1382
+ const object = mapColorFields({
1383
+ ...sourceObject,
1384
+ ...mapBounds(sourceObject),
1385
+ ...(sourceObject.points
1386
+ ? {
1387
+ points: sourceObject.points.map((point) => ({
1388
+ x: roundedMetric(originX + point.x * scale),
1389
+ y: roundedMetric(originY + point.y * scale),
1390
+ })),
1391
+ }
1392
+ : {}),
1393
+ ...(sourceObject.strokeWidth !== undefined
1394
+ ? { strokeWidth: roundedMetric(sourceObject.strokeWidth * scale) }
1395
+ : {}),
1396
+ ...(sourceObject.cornerRadius !== undefined
1397
+ ? { cornerRadius: roundedMetric(sourceObject.cornerRadius * scale) }
1398
+ : {}),
1399
+ });
1400
+ if (object.type === "shape") {
1401
+ const opacity = Number.isFinite(object.opacity) ? object.opacity : 1;
1402
+ if (object.text?.paragraphs) {
1403
+ object.text = {
1404
+ ...object.text,
1405
+ paragraphs: object.text.paragraphs.map((paragraph) => ({
1406
+ ...paragraph,
1407
+ runs: paragraph.runs.map((run) => ({
1408
+ ...run,
1409
+ opacity: (Number.isFinite(run.opacity) ? run.opacity : 1) * opacity,
1410
+ })),
1411
+ })),
1412
+ };
1413
+ }
1414
+ }
1415
+ if (object.type === "image") {
1416
+ const layer = foregroundLayers.get(`image:${object.architecture.id}`);
1417
+ fallbacks.push({
1418
+ type: "architecture-image",
1419
+ path: `architecture[${blockIndex}].${object.architecture.sourcePath}`,
1420
+ reason: foregroundReady && layer
1421
+ ? "architecture-image-rendered-as-foreground-picture"
1422
+ : "architecture-image-rendered-as-artwork",
1423
+ ...mapBounds(sourceObject),
1424
+ });
1425
+ if (foregroundReady && layer) elements.push(foregroundElement(layer));
1426
+ continue;
1427
+ }
1428
+ elements.push(object);
1429
+ if (object.type === "shape" && object.architecture?.kind === "node" && sourceObject.icon) {
1430
+ const layer = foregroundLayers.get(`icon:${object.architecture.id}`);
1431
+ if (foregroundReady && layer) elements.push(foregroundElement(layer));
1432
+ }
1433
+ }
1434
+ for (const icon of snapshot.icons || []) {
1435
+ const layer = foregroundLayers.get(`icon:${icon.id}`);
1436
+ fallbacks.push({
1437
+ type: "architecture-icon",
1438
+ path: `architecture[${blockIndex}].${icon.sourcePath}`,
1439
+ reason: foregroundReady && layer
1440
+ ? "icon-rendered-as-foreground-picture"
1441
+ : "icon-rendered-as-artwork",
1442
+ icon: icon.icon,
1443
+ ...mapBounds(icon),
1444
+ });
1445
+ }
1446
+ for (const sourceObject of snapshot.objects) {
1447
+ const architecture = sourceObject.architecture;
1448
+ if (!architecture) continue;
1449
+ if (architecture.kind === "group" || architecture.kind === "node") {
1450
+ const group = findById(architecture.id);
1451
+ if (!group) continue;
1452
+ [...group.children]
1453
+ .filter((child) =>
1454
+ foregroundReady
1455
+ ? child.matches("rect, ellipse, text")
1456
+ : child.matches("text"),
1457
+ )
1458
+ .forEach((child) => child.setAttribute("data-pptx-native", sourceObject.type));
1459
+ } else if (architecture.kind === "connector") {
1460
+ architectureGroups
1461
+ .filter(
1462
+ (element) =>
1463
+ element.getAttribute("data-architecture-type") === "connector" &&
1464
+ element.getAttribute("data-architecture-order") === String(architecture.order),
1465
+ )
1466
+ .forEach((group) =>
1467
+ [...group.children]
1468
+ .filter((child) => child.matches("path"))
1469
+ .forEach((child) => child.setAttribute("data-pptx-native", "connector")),
1470
+ );
1471
+ } else if (architecture.kind.startsWith("connector-label")) {
1472
+ wrapper
1473
+ .querySelectorAll(
1474
+ `[data-architecture-connector-label][data-architecture-label-layer]`,
1475
+ )
1476
+ .forEach((label) => label.setAttribute("data-pptx-native", sourceObject.type));
1477
+ }
1478
+ }
1479
+ if (foregroundReady) {
1480
+ foregroundCandidates.forEach((candidate) => {
1481
+ if (foregroundLayers.has(candidate.key)) {
1482
+ candidate.source.setAttribute("data-pptx-native", "image");
1483
+ }
1484
+ });
1485
+ }
1486
+ if (snapshot.routing.degraded) {
1487
+ fallbacks.push(
1488
+ pptxFallback(
1489
+ "architecture-routing",
1490
+ wrapper,
1491
+ deck,
1492
+ "routing-warning-rendered-as-artwork",
1493
+ ),
1494
+ );
1495
+ }
1496
+ return { elements, fallbacks };
1497
+ }
1498
+
1499
+ async function collectPptxSlide(slide, index) {
1500
+ const { deck } = slide;
1501
+ const elements = [];
1502
+ const fallbacks = [];
1503
+ const fallbackRoots = new Set();
1504
+ const addFallback = (type, element, reason) => {
1505
+ if (fallbackRoots.has(element)) return;
1506
+ fallbackRoots.add(element);
1507
+ fallbacks.push(pptxFallback(type, element, deck, reason));
1508
+ };
1509
+
1510
+ for (const element of deck.querySelectorAll("header *, .body, .body *, footer *")) {
1511
+ if (element.closest("pre, .architecture-diagram, .architecture-error")) continue;
1512
+ const effects = unsupportedEffects(element).filter((effect) => effect !== "box-shadow");
1513
+ if (!effects.length) continue;
1514
+ const root = effectFallbackRoot(element);
1515
+ if (root) {
1516
+ addFallback(
1517
+ "effect",
1518
+ root,
1519
+ `element-rendered-as-artwork: ${effects.join(", ")}`,
1520
+ );
1521
+ }
1522
+ }
1523
+
1524
+ deck.querySelectorAll("pre.mermaid").forEach((element) =>
1525
+ addFallback("mermaid", element, "mermaid-rendered-as-artwork"),
1526
+ );
1527
+ deck.querySelectorAll("pre:not(.mermaid)").forEach((element) =>
1528
+ addFallback("code", element, "code-block-rendered-as-artwork"),
1529
+ );
1530
+ deck.querySelectorAll(".architecture-error").forEach((element) =>
1531
+ addFallback("architecture", element, "architecture-error-rendered-as-artwork"),
1532
+ );
1533
+ deck
1534
+ .querySelectorAll(
1535
+ ".body div:not(.architecture-diagram):not(.architecture-error):not(.architecture-routing-warning), .body section, .body article, .body aside, .body details, .body video, .body audio, .body iframe, .body canvas, .body object, .body embed",
1536
+ )
1537
+ .forEach((element) => {
1538
+ if (!element.closest(".architecture-diagram")) {
1539
+ addFallback("html", element, "arbitrary-html-rendered-as-artwork");
1540
+ }
1541
+ });
1542
+
1543
+ const insideFallback = (element) =>
1544
+ [...fallbackRoots].some((root) => root === element || root.contains(element));
1545
+ const textCandidates = [
1546
+ ...deck.querySelectorAll(
1547
+ ".kicker, .slide-title, .body h1, .body h2, .body h3, .body h4, .body h5, .body h6, .body p, .body blockquote, .body li, footer > span, .theme-backcover-logo-text, .theme-backcover-copyright",
1548
+ ),
1549
+ ].filter(
1550
+ (element) =>
1551
+ !insideFallback(element) &&
1552
+ !element.closest(".architecture-diagram") &&
1553
+ !element.closest("table") &&
1554
+ !(element.matches("p") && element.closest("blockquote, li")),
1555
+ );
1556
+ for (const element of textCandidates) {
1557
+ const list = element.matches("li")
1558
+ ? [...element.querySelectorAll(":scope > ul, :scope > ol")]
1559
+ : [];
1560
+ const parentList = element.matches("li") ? element.parentElement : null;
1561
+ const actualLevel = element.matches("li")
1562
+ ? Math.max(0, [...element.closest(".body").querySelectorAll("ul, ol")].filter((candidate) =>
1563
+ candidate.contains(element),
1564
+ ).length - 1)
1565
+ : undefined;
1566
+ const paragraph = paragraphFor(element, {
1567
+ omitNestedLists: list.length > 0,
1568
+ level: actualLevel,
1569
+ bullet: parentList
1570
+ ? {
1571
+ type: parentList.tagName === "OL" ? "number" : "bullet",
1572
+ character:
1573
+ parentList.tagName === "OL"
1574
+ ? `${Number(parentList.getAttribute("start") || 1) + [...parentList.children].indexOf(element)}.`
1575
+ : "•",
1576
+ ...(parentList.tagName === "OL"
1577
+ ? { start: Number(parentList.getAttribute("start") || 1) + [...parentList.children].indexOf(element) }
1578
+ : {}),
1579
+ }
1580
+ : undefined,
1581
+ });
1582
+ if (!paragraph.runs.some((run) => run.text.trim())) continue;
1583
+ elements.push({
1584
+ type: "text",
1585
+ path: elementPath(element, deck),
1586
+ ...(element.classList.contains("kicker")
1587
+ ? textContentBounds(element, deck)
1588
+ : relativeBounds(element, deck)),
1589
+ paragraphs: [paragraph],
1590
+ opacity: Number(getComputedStyle(element).opacity) || 1,
1591
+ ...(element.matches("h1, h2, .slide-title") && renderedTextLineCount(element) === 1
1592
+ ? { textWrap: "none" }
1593
+ : {}),
1594
+ });
1595
+ element.setAttribute("data-pptx-native", "text");
1596
+ }
1597
+
1598
+ for (const table of deck.querySelectorAll(".body table")) {
1599
+ if (insideFallback(table)) continue;
1600
+ const descendantEffects = new Set();
1601
+ for (const descendant of table.querySelectorAll("*")) {
1602
+ for (const effect of unsupportedEffects(descendant)) descendantEffects.add(effect);
1603
+ }
1604
+ if (descendantEffects.size) {
1605
+ addFallback(
1606
+ "effect",
1607
+ table,
1608
+ `element-rendered-as-artwork: ${[...descendantEffects].join(", ")}`,
1609
+ );
1610
+ continue;
1611
+ }
1612
+ const effects = unsupportedEffects(table);
1613
+ if (parseFloat(getComputedStyle(table).borderRadius) > 0) effects.push("border-radius");
1614
+ const artworkEffects = effects.filter(
1615
+ (effect) => effect !== "box-shadow" && effect !== "border-radius",
1616
+ );
1617
+ if (artworkEffects.length) {
1618
+ addFallback(
1619
+ "effect",
1620
+ table,
1621
+ `element-rendered-as-artwork: ${effects.join(", ")}`,
1622
+ );
1623
+ continue;
1624
+ }
1625
+ const domRows = [...table.rows];
1626
+ const columnCount = domRows[0]?.cells.length || 0;
1627
+ if (
1628
+ !columnCount ||
1629
+ domRows.some(
1630
+ (row) =>
1631
+ row.cells.length !== columnCount ||
1632
+ [...row.cells].some((cell) => cell.colSpan !== 1 || cell.rowSpan !== 1),
1633
+ )
1634
+ ) {
1635
+ addFallback("table", table, "merged-table-rendered-as-artwork");
1636
+ continue;
1637
+ }
1638
+ const rows = domRows.map((row) => ({
1639
+ height: roundedMetric(row.getBoundingClientRect().height),
1640
+ cells: [...row.cells].map((cell) => {
1641
+ const style = getComputedStyle(cell);
1642
+ return {
1643
+ ...relativeBounds(cell, deck),
1644
+ header: cell.tagName === "TH",
1645
+ colspan: cell.colSpan,
1646
+ rowspan: cell.rowSpan,
1647
+ fill: normalizeCssColor(style.backgroundColor),
1648
+ color: normalizeCssColor(style.color),
1649
+ stroke: normalizeCssColor(style.borderColor),
1650
+ strokeWidth: roundedMetric(parseFloat(style.borderWidth)) || 1,
1651
+ alignment: pptxAlignment(style.textAlign),
1652
+ paragraphs: [paragraphFor(cell)],
1653
+ };
1654
+ }),
1655
+ }));
1656
+ elements.push({
1657
+ type: "table",
1658
+ path: elementPath(table, deck),
1659
+ ...relativeBounds(table, deck),
1660
+ rows,
1661
+ });
1662
+ if (effects.length) {
1663
+ fallbacks.push(
1664
+ pptxFallback(
1665
+ "effect",
1666
+ table,
1667
+ deck,
1668
+ `native-table-approximates: ${effects.join(", ")}`,
1669
+ ),
1670
+ );
1671
+ if (effects.includes("box-shadow")) preserveBoxShadow(table, deck);
1672
+ }
1673
+ table.setAttribute("data-pptx-native", "table");
1674
+ }
1675
+
1676
+ for (const image of deck.querySelectorAll("img")) {
1677
+ if (
1678
+ image.closest(".architecture-diagram") ||
1679
+ image.classList.contains("theme-cover-background") ||
1680
+ insideFallback(image)
1681
+ ) {
1682
+ continue;
1683
+ }
1684
+ if (!rasterImageSupported(image)) {
1685
+ const fit = getComputedStyle(image).objectFit;
1686
+ addFallback(
1687
+ "image",
1688
+ image,
1689
+ ["cover", "none"].includes(fit) ? "unsupported-image-fit" : "unsupported-image-format",
1690
+ );
1691
+ continue;
1692
+ }
1693
+ const style = getComputedStyle(image);
1694
+ const effects = unsupportedEffects(image);
1695
+ if (parseFloat(style.borderRadius) > 0) effects.push("border-radius");
1696
+ const artworkEffects = effects.filter(
1697
+ (effect) => effect !== "box-shadow" && effect !== "border-radius",
1698
+ );
1699
+ if (artworkEffects.length) {
1700
+ addFallback(
1701
+ "effect",
1702
+ image,
1703
+ `element-rendered-as-artwork: ${effects.join(", ")}`,
1704
+ );
1705
+ continue;
1706
+ }
1707
+ elements.push({
1708
+ type: "image",
1709
+ path: elementPath(image, deck),
1710
+ ...relativeBounds(image, deck),
1711
+ src: image.currentSrc || image.src,
1712
+ alt: image.alt || "",
1713
+ fit: style.objectFit || "contain",
1714
+ opacity: Number(style.opacity) || 1,
1715
+ naturalWidth: image.naturalWidth,
1716
+ naturalHeight: image.naturalHeight,
1717
+ });
1718
+ image.setAttribute("data-pptx-native", "image");
1719
+ if (effects.length) {
1720
+ fallbacks.push(
1721
+ pptxFallback(
1722
+ "effect",
1723
+ image,
1724
+ deck,
1725
+ `native-image-approximates: ${effects.join(", ")}`,
1726
+ ),
1727
+ );
1728
+ }
1729
+ }
1730
+
1731
+ const architectureWrappers = [...deck.querySelectorAll(".architecture-diagram")];
1732
+ for (const [blockIndex, wrapper] of architectureWrappers.entries()) {
1733
+ if (insideFallback(wrapper)) continue;
1734
+ const architecture = await collectArchitectureObjects(wrapper, deck, blockIndex);
1735
+ elements.push(...architecture.elements);
1736
+ fallbacks.push(...architecture.fallbacks);
1737
+ }
1738
+
1739
+ const layout = slide.titleSlide
1740
+ ? "title"
1741
+ : slide.sectionSlide
1742
+ ? "section"
1743
+ : slide.centerSlide
1744
+ ? "center"
1745
+ : slide.backcoverSlide
1746
+ ? "backcover"
1747
+ : "standard";
1748
+ const visibleTitle = deck.querySelector("h1, h2")?.textContent?.trim();
1749
+ return {
1750
+ index,
1751
+ layout,
1752
+ theme: slide.theme,
1753
+ title: visibleTitle || slide.title,
1754
+ width: OUTPUT_WIDTH,
1755
+ height: OUTPUT_HEIGHT,
1756
+ elements,
1757
+ fallbacks,
1758
+ };
1759
+ }
1760
+
1761
+ async function renderPptxDeck(
1762
+ slides,
1763
+ theme,
1764
+ customCss = "",
1765
+ themeMetadata = null,
1766
+ themeLocked = false,
1767
+ ) {
1768
+ deckTheme = normalizeTheme(theme);
1769
+ deckThemeLocked = Boolean(themeLocked);
1770
+ customThemeMeta = themeMetadata && typeof themeMetadata === "object" ? themeMetadata : null;
1771
+ applyCustomThemeCss(customCss);
1772
+ document.documentElement.setAttribute("data-theme", deckTheme);
1773
+ document.body.classList.add("pptx-mode", "fixed-output-mode", "mermaid-loading");
1774
+ const rendered = slides.map((markdown) => createSlide(markdown, deckTheme));
1775
+ const stage = document.getElementById("stage");
1776
+ stage.replaceChildren(...rendered.map((slide) => slide.deck));
1777
+ document.title = rendered[0]?.title || "MarkdStage";
1778
+
1779
+ if (document.fonts?.ready) await document.fonts.ready;
1780
+ await afterLayout();
1781
+ for (const slide of rendered) {
1782
+ if (
1783
+ slide.sizeMode === "auto" &&
1784
+ !slide.titleSlide &&
1785
+ !slide.sectionSlide &&
1786
+ !slide.backcoverSlide
1787
+ ) {
1788
+ applyAutoSize(slide.deck, slide.bodyEl);
1789
+ }
1790
+ }
1791
+ const token = ++renderToken;
1792
+ for (const slide of rendered) {
1793
+ await runMermaid(slide.bodyEl, slide.theme, token, false);
1794
+ }
1795
+ await waitForImages(stage);
1796
+ await afterLayout();
1797
+
1798
+ const pptxSlides = [];
1799
+ for (const [index, slide] of rendered.entries()) {
1800
+ pptxSlides.push(await collectPptxSlide(slide, index));
1801
+ }
1802
+ const model = {
1803
+ version: 1,
1804
+ width: OUTPUT_WIDTH,
1805
+ height: OUTPUT_HEIGHT,
1806
+ slides: pptxSlides,
1807
+ };
1808
+ window.__presentationPptxModel = JSON.parse(JSON.stringify(model));
1809
+ document.body.classList.add("pptx-artwork-mode");
1810
+ document.body.setAttribute("data-pptx-artwork", "ready");
1811
+ document.body.classList.remove("mermaid-loading");
1812
+ document.documentElement.setAttribute("data-pptx-ready", "true");
1813
+ return {
1814
+ model: window.__presentationPptxModel,
1815
+ layout: collectDeckLayout(rendered),
1816
+ };
1817
+ }
1818
+
1819
+ async function initPptx(params) {
1820
+ const token = params.get("token") || "";
1821
+ if (!token) throw new Error("Missing PowerPoint export token.");
1822
+ try {
1823
+ const response = await fetch(`./export-data?token=${encodeURIComponent(token)}`, {
1824
+ cache: "no-store",
1825
+ });
1826
+ if (!response.ok) throw new Error(`Could not load PowerPoint export data (${response.status}).`);
1827
+ const data = await response.json();
1828
+ if (
1829
+ !Array.isArray(data.slides) ||
1830
+ data.slides.length === 0 ||
1831
+ !data.slides.every((slide) => typeof slide === "string")
1832
+ ) {
1833
+ throw new Error("PowerPoint export data does not contain a valid deck.");
1834
+ }
1835
+ const output = await renderPptxDeck(
1836
+ data.slides,
1837
+ data.theme,
1838
+ data.customThemeCss,
1839
+ data.customThemeMeta,
1840
+ data.themeLocked,
1841
+ );
1842
+ await reportOutputStatus(token, "ready", "", output.layout);
1843
+ } catch (error) {
1844
+ const message = error?.message || "PowerPoint rendering failed.";
1845
+ console.error(message);
1846
+ document.body.classList.remove("mermaid-loading");
1847
+ document.documentElement.setAttribute("data-pptx-error", "true");
1848
+ await reportOutputStatus(token, "error", message).catch(() => {});
1849
+ }
1850
+ }
1851
+
860
1852
  async function renderPrintDeck(
861
1853
  slides,
862
1854
  theme,
@@ -1048,6 +2040,13 @@ function reportCaptureBootstrapFailure(error) {
1048
2040
  document.documentElement.setAttribute("data-capture-error", "true");
1049
2041
  }
1050
2042
 
2043
+ function reportPptxBootstrapFailure(error) {
2044
+ const message = error?.message || "PowerPoint rendering failed.";
2045
+ console.error(message);
2046
+ document.body.classList.remove("mermaid-loading");
2047
+ document.documentElement.setAttribute("data-pptx-error", "true");
2048
+ }
2049
+
1051
2050
  // --- live update -----------------------------------------------------------
1052
2051
  // /state is the single source of truth for *what to show* (latest slide markdown
1053
2052
  // + a monotonic version + the deck position). SSE is just a low-latency "version
@@ -1066,13 +2065,21 @@ let importOpen = false;
1066
2065
  let importPending = false;
1067
2066
  let importFiles = [];
1068
2067
  let sourceBacked = false;
2068
+ let sourceModeAvailable = false;
1069
2069
  let sourceMode = "snapshot";
1070
2070
  let sourceWatchStatus = "inactive";
1071
2071
  let sourceWatchError = "";
1072
2072
  let presenterRequestPending = false;
1073
2073
  let presenterRunning = false;
2074
+ let presenterWindowAvailable = false;
2075
+ let presenterViewAvailable = false;
2076
+ let pdfExportAvailable = false;
2077
+ let pptxExportAvailable = false;
2078
+ let markdownImportAvailable = false;
1074
2079
  let presenterViewOpen = false;
2080
+ let presenterViewRequested = false;
1075
2081
  let pdfExportPending = false;
2082
+ let pptxExportPending = false;
1076
2083
 
1077
2084
  // Derive a short overview title from a slide fragment: first heading, else first
1078
2085
  // non-empty body line, trimmed. Mirrors the skill's title rule.
@@ -1119,7 +2126,7 @@ async function fetchDeck() {
1119
2126
  * early in init and never reaches this code. Return true only when the state changes.
1120
2127
  */
1121
2128
  function setArchitectureEditMode(enabled) {
1122
- const next = Boolean(enabled) && !presenterMode;
2129
+ const next = Boolean(enabled) && architectureEditAvailable && !presenterMode;
1123
2130
  if (next === architectureEditMode) return false;
1124
2131
  architectureEditMode = next;
1125
2132
  document.body.classList.toggle("architecture-edit-mode", next);
@@ -1130,7 +2137,7 @@ function setArchitectureEditMode(enabled) {
1130
2137
  function updateArchitectureEditButton(enabled = architectureEditMode) {
1131
2138
  const button = document.getElementById("navEdit");
1132
2139
  if (!button) return;
1133
- button.hidden = presenterMode;
2140
+ button.hidden = presenterMode || !architectureEditAvailable;
1134
2141
  button.dataset.state = enabled && !presenterMode ? "active" : "";
1135
2142
  button.title = enabled ? "Exit shape editing mode" : "Shape editing mode";
1136
2143
  button.setAttribute("aria-label", button.title);
@@ -1149,7 +2156,7 @@ function updateSourceModeButton() {
1149
2156
  const button = document.getElementById("navSourceMode");
1150
2157
  const status = document.getElementById("sourceStatus");
1151
2158
  if (!button) return;
1152
- button.hidden = presenterMode || !sourceBacked;
2159
+ button.hidden = presenterMode || !sourceBacked || !sourceModeAvailable;
1153
2160
  if (!sourceBacked) {
1154
2161
  button.dataset.state = "";
1155
2162
  if (status) status.textContent = "";
@@ -1197,7 +2204,7 @@ async function requestSourceMode(mode) {
1197
2204
  }
1198
2205
 
1199
2206
  async function toggleSourceMode() {
1200
- if (presenterMode || !sourceBacked) return;
2207
+ if (presenterMode || !sourceBacked || !sourceModeAvailable) return;
1201
2208
  await requestSourceMode(sourceMode === "live" ? "snapshot" : "live");
1202
2209
  }
1203
2210
 
@@ -1218,7 +2225,7 @@ async function requestArchitectureEditMode(enabled) {
1218
2225
  }
1219
2226
 
1220
2227
  async function toggleArchitectureEditMode() {
1221
- if (presenterMode) return;
2228
+ if (presenterMode || !architectureEditAvailable) return;
1222
2229
  await requestArchitectureEditMode(!architectureEditMode);
1223
2230
  await fetchState();
1224
2231
  }
@@ -1230,7 +2237,23 @@ async function toggleArchitectureEditMode() {
1230
2237
  * Always return save success or failure to the caller. Swallowing it would make
1231
2238
  * an unsaved edit look successful, recreating the silent-ignore behavior fixed in Phase 5.
1232
2239
  */
1233
- async function saveArchitectureBlock(index, block, source) {
2240
+ function saveArchitectureBlock(index, block, source, editorRenderToken) {
2241
+ const pending = architectureSaveQueue
2242
+ .catch(() => {})
2243
+ .then(() => {
2244
+ if (editorRenderToken !== renderToken) {
2245
+ return {
2246
+ ok: false,
2247
+ message: "The displayed deck was replaced. Select the diagram again",
2248
+ };
2249
+ }
2250
+ return saveArchitectureBlockNow(index, block, source);
2251
+ });
2252
+ architectureSaveQueue = pending;
2253
+ return pending;
2254
+ }
2255
+
2256
+ async function saveArchitectureBlockNow(index, block, source) {
1234
2257
  let res;
1235
2258
  try {
1236
2259
  res = await fetch("./edit", {
@@ -1283,6 +2306,9 @@ async function saveArchitectureBlock(index, block, source) {
1283
2306
  }
1284
2307
 
1285
2308
  async function openDetailedArchitectureEditor(index, block) {
2309
+ const pendingWindow =
2310
+ architectureDetailedEditTarget === "window" ? window.open("", "_blank") : null;
2311
+ if (pendingWindow) pendingWindow.opener = null;
1286
2312
  let response;
1287
2313
  try {
1288
2314
  response = await fetch("./architecture-editor/open", {
@@ -1291,10 +2317,22 @@ async function openDetailedArchitectureEditor(index, block) {
1291
2317
  body: JSON.stringify({ index, block }),
1292
2318
  });
1293
2319
  } catch (_) {
2320
+ pendingWindow?.close();
1294
2321
  return { ok: false, message: "Could not connect to the server." };
1295
2322
  }
1296
2323
  const result = await response.json().catch(() => ({}));
1297
- if (response.ok && result.ok === true) return result;
2324
+ if (response.ok && result.ok === true) {
2325
+ if (typeof result.url === "string" && result.url) {
2326
+ if (pendingWindow) pendingWindow.location.replace(result.url);
2327
+ else if (!window.open(result.url, "_blank", "noopener")) {
2328
+ return { ok: false, message: "Allow pop-ups to open the Architecture Editor." };
2329
+ }
2330
+ } else {
2331
+ pendingWindow?.close();
2332
+ }
2333
+ return result;
2334
+ }
2335
+ pendingWindow?.close();
1298
2336
  if (result.error === "source_not_available") {
1299
2337
  return {
1300
2338
  ok: false,
@@ -1320,10 +2358,29 @@ async function fetchState() {
1320
2358
  data.customThemeMeta && typeof data.customThemeMeta === "object"
1321
2359
  ? data.customThemeMeta
1322
2360
  : null;
2361
+ if (typeof data.presenterWindowAvailable === "boolean") {
2362
+ presenterWindowAvailable = data.presenterWindowAvailable;
2363
+ }
2364
+ if (typeof data.presenterViewAvailable === "boolean") {
2365
+ presenterViewAvailable = data.presenterViewAvailable;
2366
+ }
2367
+ if (typeof data.pdfExportAvailable === "boolean") {
2368
+ pdfExportAvailable = data.pdfExportAvailable;
2369
+ }
2370
+ if (typeof data.pptxExportAvailable === "boolean") {
2371
+ pptxExportAvailable = data.pptxExportAvailable;
2372
+ }
2373
+ if (typeof data.markdownImportAvailable === "boolean") {
2374
+ markdownImportAvailable = data.markdownImportAvailable;
2375
+ }
1323
2376
  if (typeof data.presenterRunning === "boolean") {
1324
2377
  updatePresenterButton(data.presenterRunning);
1325
2378
  }
2379
+ updateHostActionButtons();
1326
2380
  if (typeof data.sourceBacked === "boolean") sourceBacked = data.sourceBacked;
2381
+ if (typeof data.sourceModeAvailable === "boolean") {
2382
+ sourceModeAvailable = data.sourceModeAvailable;
2383
+ }
1327
2384
  sourceMode = data.sourceMode === "live" ? "live" : "snapshot";
1328
2385
  sourceWatchStatus =
1329
2386
  data.sourceWatchStatus === "watching" || data.sourceWatchStatus === "error"
@@ -1331,6 +2388,14 @@ async function fetchState() {
1331
2388
  : "inactive";
1332
2389
  sourceWatchError = typeof data.sourceWatchError === "string" ? data.sourceWatchError : "";
1333
2390
  updateSourceModeButton();
2391
+ const editAvailabilityChanged =
2392
+ typeof data.architectureEditAvailable === "boolean" &&
2393
+ data.architectureEditAvailable !== architectureEditAvailable;
2394
+ if (typeof data.architectureEditAvailable === "boolean") {
2395
+ architectureEditAvailable = data.architectureEditAvailable;
2396
+ }
2397
+ architectureDetailedEditTarget =
2398
+ data.architectureDetailedEditTarget === "window" ? "window" : "canvas";
1334
2399
  const detailedEditChanged =
1335
2400
  typeof data.architectureDetailedEdit === "boolean" &&
1336
2401
  data.architectureDetailedEdit !== architectureDetailedEdit;
@@ -1338,7 +2403,15 @@ async function fetchState() {
1338
2403
  architectureDetailedEdit = data.architectureDetailedEdit;
1339
2404
  }
1340
2405
  // Editing-mode changes do not increment the version, so process them before the version guard.
2406
+ let availabilityDisabledEditMode = false;
2407
+ if (editAvailabilityChanged) {
2408
+ if (!architectureEditAvailable) {
2409
+ availabilityDisabledEditMode = setArchitectureEditMode(false);
2410
+ }
2411
+ updateArchitectureEditButton();
2412
+ }
1341
2413
  if (
2414
+ availabilityDisabledEditMode ||
1342
2415
  (typeof data.architectureEdit === "boolean" &&
1343
2416
  setArchitectureEditMode(data.architectureEdit)) ||
1344
2417
  (architectureEditMode && detailedEditChanged)
@@ -1360,6 +2433,10 @@ async function fetchState() {
1360
2433
  navMode = data.mode === "adhoc" ? "adhoc" : "deck";
1361
2434
  renderSlide(typeof data.markdown === "string" ? data.markdown : "");
1362
2435
  updateNav();
2436
+ if (presenterViewRequested) {
2437
+ presenterViewRequested = false;
2438
+ openPresenterView();
2439
+ }
1363
2440
  }
1364
2441
 
1365
2442
  // --- navigation ------------------------------------------------------------
@@ -1391,7 +2468,7 @@ function goToIndex(i) {
1391
2468
  }
1392
2469
 
1393
2470
  async function setPresenterRunning(running) {
1394
- if (presenterRequestPending) return;
2471
+ if (!presenterWindowAvailable || presenterRequestPending) return;
1395
2472
  presenterRequestPending = true;
1396
2473
  const button = document.getElementById("navPresent");
1397
2474
  const status = document.getElementById("presentStatus");
@@ -1445,6 +2522,23 @@ function togglePresenterWindow() {
1445
2522
  return setPresenterRunning(!presenterRunning);
1446
2523
  }
1447
2524
 
2525
+ function updateHostActionButtons() {
2526
+ const present = document.getElementById("navPresent");
2527
+ if (present) present.hidden = presenterMode || !presenterWindowAvailable;
2528
+ const presenterView = document.getElementById("navPresenterView");
2529
+ if (presenterView) presenterView.hidden = presenterMode || !presenterViewAvailable;
2530
+ const presenterToggle = document.getElementById("presenterToggleButton");
2531
+ if (presenterToggle) presenterToggle.hidden = !presenterWindowAvailable;
2532
+ const exportButton = document.getElementById("navExport");
2533
+ if (exportButton) exportButton.hidden = presenterMode || !pdfExportAvailable;
2534
+ const pptxButton = document.getElementById("navExportPptx");
2535
+ if (pptxButton) pptxButton.hidden = presenterMode || !pptxExportAvailable;
2536
+ const importButton = document.getElementById("navImport");
2537
+ if (importButton) importButton.hidden = presenterMode || !markdownImportAvailable;
2538
+ if (!presenterViewAvailable && presenterViewOpen) closePresenterView();
2539
+ if (!markdownImportAvailable && importOpen) closeImportPicker();
2540
+ }
2541
+
1448
2542
  function updatePresenterButton(running, message = "") {
1449
2543
  presenterRunning = running;
1450
2544
  const button = document.getElementById("navPresent");
@@ -1462,11 +2556,13 @@ function updatePresenterButton(running, message = "") {
1462
2556
  }
1463
2557
 
1464
2558
  async function exportPdfFromCanvas() {
1465
- if (pdfExportPending) return;
2559
+ if (!pdfExportAvailable || pdfExportPending || pptxExportPending) return;
1466
2560
  pdfExportPending = true;
1467
2561
  const button = document.getElementById("navExport");
2562
+ const pptxButton = document.getElementById("navExportPptx");
1468
2563
  const status = document.getElementById("exportStatus");
1469
2564
  if (button) button.disabled = true;
2565
+ if (pptxButton) pptxButton.disabled = true;
1470
2566
  if (status) status.textContent = "Saving PDF.";
1471
2567
 
1472
2568
  try {
@@ -1497,6 +2593,51 @@ async function exportPdfFromCanvas() {
1497
2593
  } finally {
1498
2594
  pdfExportPending = false;
1499
2595
  if (button) button.disabled = false;
2596
+ if (pptxButton) pptxButton.disabled = false;
2597
+ }
2598
+ }
2599
+
2600
+ async function exportPptxFromCanvas() {
2601
+ if (!pptxExportAvailable || pdfExportPending || pptxExportPending) return;
2602
+ pptxExportPending = true;
2603
+ const button = document.getElementById("navExportPptx");
2604
+ const pdfButton = document.getElementById("navExport");
2605
+ const status = document.getElementById("exportStatus");
2606
+ if (button) button.disabled = true;
2607
+ if (pdfButton) pdfButton.disabled = true;
2608
+ if (status) status.textContent = "Saving editable PowerPoint.";
2609
+
2610
+ try {
2611
+ const response = await fetch("./export-pptx", {
2612
+ method: "POST",
2613
+ headers: { Accept: "application/json" },
2614
+ cache: "no-store",
2615
+ });
2616
+ const data = await response.json().catch(() => ({}));
2617
+ if (!response.ok) {
2618
+ throw new Error(data.message || `PowerPoint export failed (${response.status}).`);
2619
+ }
2620
+ const filename = data.path ? data.path.split(/[\\/]/).pop() : "PowerPoint";
2621
+ const fallback =
2622
+ data.fallbackCount > 0 ? ` ${data.fallbackCount} fallback item(s) preserved.` : "";
2623
+ const message = `Saved ${filename}.${fallback}`;
2624
+ if (status) status.textContent = message;
2625
+ if (button) {
2626
+ button.dataset.state = "active";
2627
+ button.title = message;
2628
+ }
2629
+ } catch (error) {
2630
+ const message = error?.message || "Could not save the PowerPoint presentation.";
2631
+ console.error("PowerPoint export failed", error);
2632
+ if (status) status.textContent = message;
2633
+ if (button) {
2634
+ button.dataset.state = "error";
2635
+ button.title = message;
2636
+ }
2637
+ } finally {
2638
+ pptxExportPending = false;
2639
+ if (button) button.disabled = false;
2640
+ if (pdfButton) pdfButton.disabled = false;
1500
2641
  }
1501
2642
  }
1502
2643
 
@@ -1529,10 +2670,10 @@ function toggleFixedPreviewMode() {
1529
2670
  function updateNav() {
1530
2671
  const nav = document.getElementById("nav");
1531
2672
  if (!nav) return;
1532
- // Outside presenter view, show only the load button even when no deck exists,
1533
- // allowing Markdown import before any slide has been loaded.
2673
+ // Outside presenter view, show only the load button when the host supports
2674
+ // Markdown import before any slide has been loaded.
1534
2675
  const empty = navTotal <= 0;
1535
- nav.hidden = previewMode || (empty && presenterMode);
2676
+ nav.hidden = previewMode || (empty && (presenterMode || !markdownImportAvailable));
1536
2677
  nav.classList.toggle("nav-empty", empty);
1537
2678
  const counter = document.getElementById("navCounter");
1538
2679
  if (counter) {
@@ -1549,7 +2690,7 @@ function updateNav() {
1549
2690
  }
1550
2691
 
1551
2692
  function openPresenterView() {
1552
- if (presenterMode || navTotal <= 0) return;
2693
+ if (presenterMode || !presenterViewAvailable || navTotal <= 0) return;
1553
2694
  presenterViewOpen = true;
1554
2695
  document.body.classList.add("presenter-view-mode");
1555
2696
  const view = document.getElementById("presenterView");
@@ -1770,7 +2911,7 @@ async function importMarkdown(path) {
1770
2911
  }
1771
2912
 
1772
2913
  function openImportPicker() {
1773
- if (presenterMode) return;
2914
+ if (presenterMode || !markdownImportAvailable) return;
1774
2915
  importOpen = true;
1775
2916
  const el = document.getElementById("importPicker");
1776
2917
  if (el) el.hidden = false;
@@ -1911,6 +3052,7 @@ function wireControls() {
1911
3052
  bind("navPresenterView", openPresenterView);
1912
3053
  bind("navFixedPreview", toggleFixedPreviewMode);
1913
3054
  bind("navExport", exportPdfFromCanvas);
3055
+ bind("navExportPptx", exportPptxFromCanvas);
1914
3056
  bind("navImport", toggleImportPicker);
1915
3057
  bind("navSourceMode", toggleSourceMode);
1916
3058
  bind("navList", toggleOverview);
@@ -2014,6 +3156,11 @@ function init() {
2014
3156
  } catch (_) {}
2015
3157
 
2016
3158
  const params = new URLSearchParams(window.location.search);
3159
+ presenterViewRequested = params.get("presenter") === "1";
3160
+ if (params.get("pptx") === "1") {
3161
+ initPptx(params).catch(reportPptxBootstrapFailure);
3162
+ return;
3163
+ }
2017
3164
  if (params.get("capture") === "1") {
2018
3165
  initCapture(params).catch(reportCaptureBootstrapFailure);
2019
3166
  return;