@markdstage/markdstage 3.1.0 → 3.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.
@@ -1,7 +1,12 @@
1
1
  import { powerPointDashStyle, renderArchitectureBlock } from "./architecture.mjs";
2
+ import { architectureSnapshotToScene } from "./architecture-scene.mjs";
3
+ import { mermaidSvgToScene } from "./mermaid-scene.mjs";
4
+ import { captureSvgTree, sceneToSvg } from "./scene-svg.mjs";
5
+ import { sceneToPptxElements } from "./scene-pptx.mjs";
2
6
  import { attachArchitectureEditor } from "./architecture-editor.mjs";
3
7
  import {
4
8
  DEFAULT_THEME,
9
+ mermaidThemeVariables,
5
10
  normalizeTheme,
6
11
  parseFrontMatter,
7
12
  } from "./theme.mjs";
@@ -74,11 +79,6 @@ function localAssetUrl(path, documentRef = document) {
74
79
  // The deck theme is chosen by the agent (load_deck `theme`) and delivered via
75
80
  // /state; slide front matter may override it unless the deck theme was explicit.
76
81
  // Anything unrecognized falls back to the default so a slide is never unstyled.
77
- const MERMAID_THEME = {
78
- dark: "dark",
79
- light: "default",
80
- microsoft: "neutral",
81
- };
82
82
  const SIZE_MODES = new Set(["auto", "normal", "large", "xlarge"]);
83
83
  const DEFAULT_SIZE_MODE = "auto";
84
84
  let deckTheme = DEFAULT_THEME;
@@ -88,7 +88,7 @@ let customThemeMeta = null;
88
88
  // Bumped on every render so a late mermaid finish from a previous slide can't
89
89
  // reveal a newer, still-rendering one.
90
90
  let renderToken = 0;
91
- let lastMermaidTheme = null;
91
+ let lastMermaidThemeVariables = null;
92
92
  let pptxFallbackSequence = 0;
93
93
  const pptxFallbackCaptureElements = new Map();
94
94
  // Editing mode is available only in normal view, not presenter or print mode.
@@ -564,9 +564,12 @@ function applySyntaxHighlighting(root) {
564
564
  // --- mermaid ---------------------------------------------------------------
565
565
  // Render every <pre class="mermaid"> in `scope` to SVG. Resilient: a slide with
566
566
  // no diagrams, a missing library, or an invalid diagram must never leave the
567
- // slide blank, so the body is always revealed in the end. The mermaid theme is
568
- // matched to the slide theme, re-initialized only when it actually changes.
569
- function runMermaid(scope, theme, token, revealWhenDone = true) {
567
+ // slide blank, so the body is always revealed in the end. Mermaid's palette is
568
+ // derived from the rendered deck's theme custom properties (see
569
+ // theme.mjs#mermaidThemeVariables) so diagrams match the slide instead of
570
+ // picking the closest built-in Mermaid theme, and it is re-initialized only
571
+ // when the resolved colors actually change.
572
+ function runMermaid(scope, deckEl, token, revealWhenDone = true) {
570
573
  // Only the latest render may lift the loading veil; a stale finish is ignored.
571
574
  const reveal = () => {
572
575
  if (revealWhenDone && token === renderToken) {
@@ -579,13 +582,30 @@ function runMermaid(scope, theme, token, revealWhenDone = true) {
579
582
  return Promise.resolve();
580
583
  }
581
584
  try {
582
- const wanted = MERMAID_THEME[theme] || "neutral";
583
- if (wanted !== lastMermaidTheme) {
584
- window.mermaid.initialize({ startOnLoad: false, theme: wanted, securityLevel: "strict" });
585
- lastMermaidTheme = wanted;
585
+ const themeVariables = mermaidThemeVariables(getComputedStyle(deckEl));
586
+ const serializedThemeVariables = JSON.stringify(themeVariables);
587
+ if (serializedThemeVariables !== lastMermaidThemeVariables) {
588
+ window.mermaid.initialize({
589
+ startOnLoad: false,
590
+ theme: "base",
591
+ themeVariables,
592
+ securityLevel: "strict",
593
+ });
594
+ lastMermaidThemeVariables = serializedThemeVariables;
586
595
  }
587
596
  return Promise.resolve(window.mermaid.run({ nodes }))
588
597
  .catch((e) => console.error("Mermaid render failed", e))
598
+ .then(() => {
599
+ for (const [index, host] of [...nodes].entries()) {
600
+ const source = host.querySelector("svg");
601
+ if (!source || source.hasAttribute("data-scene-backend")) continue;
602
+ try {
603
+ renderMermaidScene(source, deckEl, index);
604
+ } catch (e) {
605
+ console.error("Mermaid scene render failed", e);
606
+ }
607
+ }
608
+ })
589
609
  .finally(reveal);
590
610
  } catch (e) {
591
611
  console.error("Mermaid init failed", e);
@@ -594,6 +614,79 @@ function runMermaid(scope, theme, token, revealWhenDone = true) {
594
614
  }
595
615
  }
596
616
 
617
+ function renderMermaidScene(svg, deck, blockIndex) {
618
+ const result = mermaidSvgToScene(svg, {
619
+ path: `mermaid[${blockIndex}]`,
620
+ deck,
621
+ includeSourceElements: true,
622
+ resolveColor: (value) => resolveModelColor(value, deck),
623
+ });
624
+ let { scene } = result;
625
+ const computedStyle = (element) => {
626
+ const style = getComputedStyle(element);
627
+ return {
628
+ getPropertyValue(property) {
629
+ // The loading veil is inherited by every SVG descendant, not diagram style.
630
+ if (property === "visibility" && document.body.classList.contains("mermaid-loading")) {
631
+ return element.style?.visibility || element.getAttribute("visibility") || "";
632
+ }
633
+ return style.getPropertyValue(property);
634
+ },
635
+ };
636
+ };
637
+ const slots = new Map();
638
+ try {
639
+ for (const [index, node] of scene.nodes.entries()) {
640
+ const exactSource = result.sourceElements?.get(node.sourcePath) ||
641
+ mermaidElementForSourcePath(svg, node.sourcePath);
642
+ const owner = scene.nodes.find((candidate) => {
643
+ if (candidate === node || !node.sourcePath.startsWith(`${candidate.sourcePath}.`)) return false;
644
+ const candidateSource = result.sourceElements?.get(candidate.sourcePath) ||
645
+ mermaidElementForSourcePath(svg, candidate.sourcePath);
646
+ return candidateSource && (!exactSource || candidateSource.contains(exactSource));
647
+ });
648
+ if (owner) {
649
+ node.meta = { ...node.meta, svgOwner: owner.sourcePath };
650
+ continue;
651
+ }
652
+ const source = exactSource ||
653
+ mermaidFallbackElementForBounds(svg, deck, node.bounds);
654
+ if (!source) throw new Error(`Mermaid SVG source unavailable: ${node.sourcePath}`);
655
+ if (slots.has(source)) {
656
+ node.meta = { ...node.meta, svgOwner: slots.get(source) };
657
+ continue;
658
+ }
659
+ node.meta = { ...node.meta, svg: captureSvgTree(source, { computedStyle }) };
660
+ slots.set(source, index);
661
+ }
662
+ for (const [source, index] of slots) {
663
+ const ancestor = [...slots.keys()].find((candidate) => candidate !== source && candidate.contains(source));
664
+ if (ancestor) {
665
+ scene.nodes[index].meta.svgOwner = slots.get(ancestor);
666
+ slots.delete(source);
667
+ }
668
+ }
669
+ } catch (error) {
670
+ // A new Mermaid structure must stay visible even if its source mapping is
671
+ // not yet understood. Preserve the full safe artwork and report the reason.
672
+ const reason = `mermaid-svg-source-fallback: ${error.message}`;
673
+ console.warn(reason);
674
+ scene = { ...scene, nodes: [{
675
+ kind: "fallback", sourcePath: "svg", z: 0,
676
+ bounds: { x: 0, y: 0, width: scene.width, height: scene.height },
677
+ capability: { pptx: "fallback", reason }, reason,
678
+ meta: { svg: captureSvgTree(svg, { computedStyle }) },
679
+ }] };
680
+ slots.clear();
681
+ slots.set(svg, 0);
682
+ }
683
+ const template = slots.has(svg)
684
+ ? { sceneNode: slots.get(svg) }
685
+ : captureSvgTree(svg, { slots, computedStyle });
686
+ scene.meta = { ...scene.meta, svgRoot: template };
687
+ svg.replaceWith(sceneToSvg(scene, { document, template }));
688
+ }
689
+
597
690
  // --- slide rendering -------------------------------------------------------
598
691
  function moveLeadingSlideTitle(header, bodyEl, specialLayout) {
599
692
  if (specialLayout) return null;
@@ -826,7 +919,7 @@ function renderSlide(markdown) {
826
919
  const images = waitForImages(slide.deck).then(() => {
827
920
  if (token === renderToken) scheduleLayoutRefresh();
828
921
  });
829
- const mermaid = runMermaid(slide.bodyEl, slide.theme, token, false).finally(() => {
922
+ const mermaid = runMermaid(slide.bodyEl, slide.deck, token, false).finally(() => {
830
923
  if (token === renderToken) scheduleLayoutRefresh();
831
924
  });
832
925
  Promise.all([mermaid, images]).finally(() => {
@@ -1666,51 +1759,7 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1666
1759
  width: roundedMetric(object.width * scale),
1667
1760
  height: roundedMetric(object.height * scale),
1668
1761
  });
1669
- const mapColorFields = (object) => {
1670
- const mapped = { ...object };
1671
- const mapParagraphs = (paragraphs) =>
1672
- paragraphs.map((paragraph) => ({
1673
- ...paragraph,
1674
- runs: paragraph.runs.map((run) => ({
1675
- ...run,
1676
- color: resolveModelColor(run.color, deck),
1677
- fontFace: getComputedStyle(svg).fontFamily
1678
- .split(",")[0]
1679
- .trim()
1680
- .replace(/^["']|["']$/g, ""),
1681
- fontSize: roundedMetric(run.fontSize * scale),
1682
- bold: Number(run.fontWeight) >= 600,
1683
- })),
1684
- }));
1685
- if (mapped.dash !== undefined) mapped.dash = powerPointDashStyle(mapped.dash);
1686
- for (const key of ["fill", "stroke", "color"]) {
1687
- if (key in mapped) mapped[key] = resolveModelColor(mapped[key], deck);
1688
- }
1689
- if (Array.isArray(mapped.paragraphs)) {
1690
- mapped.paragraphs = mapParagraphs(mapped.paragraphs);
1691
- }
1692
- if (mapped.text?.paragraphs) {
1693
- mapped.text = {
1694
- ...mapped.text,
1695
- paragraphs: mapParagraphs(mapped.text.paragraphs),
1696
- };
1697
- }
1698
- if (mapped.textInsets) {
1699
- mapped.textInsets = Object.fromEntries(
1700
- Object.entries(mapped.textInsets).map(([key, value]) => [
1701
- key,
1702
- roundedMetric(value * scale),
1703
- ]),
1704
- );
1705
- }
1706
- return mapped;
1707
- };
1708
- const fallbacks = snapshot.fallbacks.map((fallback) => ({
1709
- ...fallback,
1710
- path: `architecture[${blockIndex}].${fallback.path}`,
1711
- ...mapBounds(fallback),
1712
- }));
1713
- const elements = [];
1762
+ const fallbacks = [];
1714
1763
  const architectureGroups = [...wrapper.querySelectorAll("[data-architecture-type]")];
1715
1764
  const findById = (id) =>
1716
1765
  architectureGroups.find((element) => element.getAttribute("data-architecture-id") === id);
@@ -1741,13 +1790,6 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1741
1790
  width: bounds.width,
1742
1791
  height: bounds.height,
1743
1792
  },
1744
- architecture: {
1745
- kind: "icon-picture",
1746
- id: icon.id,
1747
- sourcePath: icon.sourcePath,
1748
- order: icon.order,
1749
- z: icon.z,
1750
- },
1751
1793
  });
1752
1794
  }
1753
1795
  }
@@ -1776,10 +1818,6 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1776
1818
  width: bounds.width,
1777
1819
  height: bounds.height,
1778
1820
  },
1779
- architecture: {
1780
- ...object.architecture,
1781
- kind: "image-picture",
1782
- },
1783
1821
  });
1784
1822
  }
1785
1823
  }
@@ -1819,15 +1857,6 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1819
1857
  ),
1820
1858
  );
1821
1859
  }
1822
- const foregroundElement = (layer) => ({
1823
- type: "image",
1824
- src: layer.src,
1825
- alt: layer.alt,
1826
- fit: "fill",
1827
- opacity: 1,
1828
- ...layer.bounds,
1829
- architecture: layer.architecture,
1830
- });
1831
1860
  if (!foregroundReady) {
1832
1861
  fallbacks.push(
1833
1862
  pptxFallback(
@@ -1841,84 +1870,25 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1841
1870
  }
1842
1871
 
1843
1872
  for (const sourceObject of snapshot.objects) {
1844
- const object = mapColorFields({
1845
- ...sourceObject,
1846
- ...mapBounds(sourceObject),
1847
- ...(sourceObject.points
1848
- ? {
1849
- points: sourceObject.points.map((point) => ({
1850
- x: roundedMetric(originX + point.x * scale),
1851
- y: roundedMetric(originY + point.y * scale),
1852
- })),
1853
- }
1854
- : {}),
1855
- ...(sourceObject.strokeWidth !== undefined
1856
- ? { strokeWidth: roundedMetric(sourceObject.strokeWidth * scale) }
1857
- : {}),
1858
- ...(sourceObject.cornerRadius !== undefined
1859
- ? { cornerRadius: roundedMetric(sourceObject.cornerRadius * scale) }
1860
- : {}),
1861
- });
1862
- if (object.type === "shape") {
1863
- const opacity = Number.isFinite(object.opacity) ? object.opacity : 1;
1864
- if (object.text?.paragraphs) {
1865
- object.text = {
1866
- ...object.text,
1867
- paragraphs: object.text.paragraphs.map((paragraph) => ({
1868
- ...paragraph,
1869
- runs: paragraph.runs.map((run) => ({
1870
- ...run,
1871
- opacity: (Number.isFinite(run.opacity) ? run.opacity : 1) * opacity,
1872
- })),
1873
- })),
1874
- };
1875
- }
1876
- }
1877
- if (object.type === "image") {
1878
- const layer = foregroundLayers.get(`image:${object.architecture.id}`);
1873
+ if (sourceObject.type === "image") {
1874
+ const layer = foregroundLayers.get(`image:${sourceObject.architecture.id}`);
1879
1875
  fallbacks.push({
1880
1876
  type: "architecture-image",
1881
- path: `architecture[${blockIndex}].${object.architecture.sourcePath}`,
1882
- reason: foregroundReady && layer
1877
+ path: `architecture[${blockIndex}].${sourceObject.architecture.sourcePath}`,
1878
+ reason: layer
1883
1879
  ? "architecture-image-rendered-as-foreground-picture"
1884
1880
  : "architecture-image-rendered-as-artwork",
1885
1881
  ...mapBounds(sourceObject),
1886
- ...(foregroundReady && layer ? { artwork: false } : {}),
1882
+ ...(layer ? { artwork: false } : {}),
1887
1883
  });
1888
- if (foregroundReady && layer) elements.push(foregroundElement(layer));
1889
- continue;
1890
- }
1891
- elements.push(object);
1892
- if (object.type === "shape" && object.architecture?.kind === "node" && sourceObject.icon) {
1893
- const layer = foregroundLayers.get(`icon:${object.architecture.id}`);
1894
- if (foregroundReady && layer) elements.push(foregroundElement(layer));
1895
1884
  }
1896
- }
1897
- for (const icon of snapshot.icons || []) {
1898
- const layer = foregroundLayers.get(`icon:${icon.id}`);
1899
- fallbacks.push({
1900
- type: "architecture-icon",
1901
- path: `architecture[${blockIndex}].${icon.sourcePath}`,
1902
- reason: foregroundReady && layer
1903
- ? "icon-rendered-as-foreground-picture"
1904
- : "icon-rendered-as-artwork",
1905
- icon: icon.icon,
1906
- ...mapBounds(icon),
1907
- ...(foregroundReady && layer ? { artwork: false } : {}),
1908
- });
1909
- }
1910
- for (const sourceObject of snapshot.objects) {
1911
1885
  const architecture = sourceObject.architecture;
1912
1886
  if (!architecture) continue;
1913
1887
  if (architecture.kind === "group" || architecture.kind === "node") {
1914
1888
  const group = findById(architecture.id);
1915
1889
  if (!group) continue;
1916
1890
  [...group.children]
1917
- .filter((child) =>
1918
- foregroundReady
1919
- ? child.matches("rect, ellipse, text")
1920
- : child.matches("text"),
1921
- )
1891
+ .filter((child) => child.matches("rect, ellipse, text"))
1922
1892
  .forEach((child) => child.setAttribute("data-pptx-native", sourceObject.type));
1923
1893
  } else if (architecture.kind === "connector") {
1924
1894
  architectureGroups
@@ -1940,13 +1910,24 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1940
1910
  .forEach((label) => label.setAttribute("data-pptx-native", sourceObject.type));
1941
1911
  }
1942
1912
  }
1943
- if (foregroundReady) {
1944
- foregroundCandidates.forEach((candidate) => {
1945
- if (foregroundLayers.has(candidate.key)) {
1946
- candidate.source.setAttribute("data-pptx-native", "image");
1947
- }
1913
+ for (const icon of snapshot.icons || []) {
1914
+ const layer = foregroundLayers.get(`icon:${icon.id}`);
1915
+ fallbacks.push({
1916
+ type: "architecture-icon",
1917
+ path: `architecture[${blockIndex}].${icon.sourcePath}`,
1918
+ reason: layer
1919
+ ? "icon-rendered-as-foreground-picture"
1920
+ : "icon-rendered-as-artwork",
1921
+ icon: icon.icon,
1922
+ ...mapBounds(icon),
1923
+ ...(layer ? { artwork: false } : {}),
1948
1924
  });
1949
1925
  }
1926
+ foregroundCandidates.forEach((candidate) => {
1927
+ if (foregroundLayers.has(candidate.key)) {
1928
+ candidate.source.setAttribute("data-pptx-native", "image");
1929
+ }
1930
+ });
1950
1931
  if (snapshot.routing.degraded) {
1951
1932
  fallbacks.push(
1952
1933
  pptxFallback(
@@ -1957,7 +1938,190 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1957
1938
  ),
1958
1939
  );
1959
1940
  }
1960
- return { elements, fallbacks };
1941
+
1942
+ const fontFace = getComputedStyle(svg).fontFamily
1943
+ .split(",")[0]
1944
+ .trim()
1945
+ .replace(/^["']|["']$/g, "");
1946
+ const { scene } = architectureSnapshotToScene(snapshot, {
1947
+ path: `architecture[${blockIndex}]`,
1948
+ resolveColor: (value) => resolveModelColor(value, deck),
1949
+ resolveDash: powerPointDashStyle,
1950
+ resolveImage: (entry, kind) => {
1951
+ const key = kind === "icon-picture"
1952
+ ? `icon:${entry.id}`
1953
+ : `image:${entry.architecture?.id || entry.id}`;
1954
+ return foregroundLayers.get(key) || "";
1955
+ },
1956
+ fontFace,
1957
+ scale,
1958
+ originX,
1959
+ originY,
1960
+ });
1961
+ const mapped = sceneToPptxElements(scene, {
1962
+ pathPrefix: `architecture[${blockIndex}]`,
1963
+ emitPath: false,
1964
+ emitZOrder: false,
1965
+ });
1966
+ return { elements: mapped.elements, fallbacks: [...fallbacks, ...mapped.fallbacks] };
1967
+ }
1968
+
1969
+ function unprefixScenePath(path, prefix) {
1970
+ return typeof path === "string" && path.startsWith(`${prefix}.`)
1971
+ ? path.slice(prefix.length + 1)
1972
+ : path;
1973
+ }
1974
+
1975
+ function mermaidEdgeLabelElement(svg, id) {
1976
+ if (!id) return null;
1977
+ const root = svg.querySelector("g.root");
1978
+ const labels = root ? [...root.querySelectorAll(":scope > g.edgeLabels > g.edgeLabel")] : [];
1979
+ return labels.find((label) => {
1980
+ const labelGroup = label.querySelector(":scope > g.label");
1981
+ return labelGroup?.getAttribute("data-id") === id;
1982
+ }) || null;
1983
+ }
1984
+
1985
+ function mermaidElementForSourcePath(svg, sourcePath) {
1986
+ const root = svg.querySelector("g.root");
1987
+ if (!root) return sourcePath === "svg" ? svg : null;
1988
+ const indexed = /^(nodes|clusters|edges)\[(\d+)\]$/.exec(sourcePath || "");
1989
+ if (indexed) {
1990
+ const [, kind, rawIndex] = indexed;
1991
+ const index = Number(rawIndex);
1992
+ const selectors = {
1993
+ nodes: ":scope > g.nodes > g.node",
1994
+ clusters: ":scope > g.clusters > g.cluster",
1995
+ edges: ":scope > g.edgePaths > path.flowchart-link",
1996
+ };
1997
+ return [...root.querySelectorAll(selectors[kind])][index] || null;
1998
+ }
1999
+ if (sourcePath === "svg") return svg;
2000
+ return null;
2001
+ }
2002
+
2003
+ function mermaidFallbackElementForBounds(svg, deck, bounds) {
2004
+ const ignored = new Set(["defs", "desc", "filter", "linearGradient", "marker", "metadata", "script", "style"]);
2005
+ const containerClasses = new Set(["root", "clusters", "edgePaths", "edgeLabels", "edgeLabel", "label", "nodes"]);
2006
+ const candidates = [...svg.querySelectorAll("circle, ellipse, foreignObject, g, image, line, path, polygon, polyline, rect, text, use")]
2007
+ .filter((candidate) => {
2008
+ if (candidate.closest("[data-pptx-native]")) return false;
2009
+ if (ignored.has(String(candidate.localName || candidate.tagName).toLowerCase())) return false;
2010
+ if (Array.from(candidate.classList || []).some((name) => containerClasses.has(name))) return false;
2011
+ const rect = candidate.getBoundingClientRect();
2012
+ return rect.width > 0 && rect.height > 0;
2013
+ });
2014
+ return candidates.find((candidate) => {
2015
+ const candidateBounds = fallbackBounds(candidate, deck);
2016
+ return (
2017
+ Math.abs(candidateBounds.x - bounds.x) <= 1 &&
2018
+ Math.abs(candidateBounds.y - bounds.y) <= 1 &&
2019
+ Math.abs(candidateBounds.width - bounds.width) <= 1 &&
2020
+ Math.abs(candidateBounds.height - bounds.height) <= 1
2021
+ );
2022
+ }) || null;
2023
+ }
2024
+
2025
+ function markMermaidNativeElements(svg, mappedElements, pathPrefix, sourceElements) {
2026
+ for (const element of mappedElements) {
2027
+ const sourcePath = unprefixScenePath(element.path, pathPrefix);
2028
+ let source = sourceElements?.get(sourcePath) || mermaidElementForSourcePath(svg, sourcePath);
2029
+ let ownerPath = sourcePath;
2030
+ while (!source && sourceElements && ownerPath.includes(".")) {
2031
+ ownerPath = ownerPath.slice(0, ownerPath.lastIndexOf("."));
2032
+ source = sourceElements.get(ownerPath);
2033
+ }
2034
+ if (source && !source.hasAttribute("data-pptx-native")) {
2035
+ const nativeKind = element.type === "shape" ? "shape" : element.type;
2036
+ source.setAttribute("data-pptx-native", nativeKind);
2037
+ }
2038
+ if (!sourceElements && element.type === "connector") {
2039
+ const id = element.mermaid?.id || source?.getAttribute("data-id") || source?.getAttribute("id") || "";
2040
+ mermaidEdgeLabelElement(svg, id)?.setAttribute("data-pptx-native", "text");
2041
+ }
2042
+ }
2043
+ }
2044
+
2045
+ function mermaidWholeElementFallbackRequired(scene, diagnostics) {
2046
+ const nodes = Array.isArray(scene?.nodes) ? scene.nodes : [];
2047
+ const reason = nodes[0]?.reason || diagnostics.find((entry) => entry?.reason)?.reason || "";
2048
+ return (
2049
+ nodes.length === 1 &&
2050
+ nodes[0]?.kind === "fallback" &&
2051
+ nodes[0]?.sourcePath === "svg" &&
2052
+ (
2053
+ reason.startsWith("mermaid-scene-adapter-failed:") ||
2054
+ reason.startsWith("mermaid-scene-limit-exceeded:") ||
2055
+ reason === "unsupported-mermaid-svg-structure"
2056
+ )
2057
+ );
2058
+ }
2059
+
2060
+ function collectMermaidObjects(element, deck, blockIndex) {
2061
+ const svg = element.querySelector("svg");
2062
+ if (!svg) {
2063
+ return {
2064
+ elements: [],
2065
+ fallbacks: [pptxFallback("mermaid", element, deck, "mermaid-rendered-as-artwork")],
2066
+ };
2067
+ }
2068
+ const pathPrefix = `mermaid[${blockIndex}]`;
2069
+ const { scene, diagnostics, sourceElements } = mermaidSvgToScene(svg, {
2070
+ path: pathPrefix,
2071
+ deck,
2072
+ resolveColor: (value) => resolveModelColor(value, deck),
2073
+ includeSourceElements: true,
2074
+ });
2075
+ if (mermaidWholeElementFallbackRequired(scene, diagnostics)) {
2076
+ return {
2077
+ elements: [],
2078
+ fallbacks: [pptxFallback("mermaid", element, deck, "mermaid-rendered-as-artwork")],
2079
+ };
2080
+ }
2081
+ const mapped = sceneToPptxElements(scene, {
2082
+ pathPrefix,
2083
+ groupPreset: "rect",
2084
+ zOrderBase: Number(element.dataset.pptxZOrder),
2085
+ });
2086
+ markMermaidNativeElements(svg, mapped.elements, pathPrefix, sourceElements);
2087
+ const fallbackNodes = new Map(
2088
+ scene.nodes
2089
+ .filter((node) => node.kind === "fallback")
2090
+ .map((node) => [node.sourcePath, node]),
2091
+ );
2092
+ const fallbacks = mapped.fallbacks.map((fallback) => {
2093
+ const sourcePath = fallback.sourcePath || unprefixScenePath(fallback.path, pathPrefix);
2094
+ const node = fallbackNodes.get(sourcePath);
2095
+ const source =
2096
+ sourceElements?.get(sourcePath) ||
2097
+ mermaidElementForSourcePath(svg, sourcePath) ||
2098
+ mermaidFallbackElementForBounds(svg, deck, fallback) ||
2099
+ svg;
2100
+ const sourceBounds = source.getBoundingClientRect();
2101
+ const padding = ["path", "line", "polyline"].includes(source.localName)
2102
+ ? Math.max(1, (fallback.width - sourceBounds.width) / 2, (fallback.height - sourceBounds.height) / 2)
2103
+ : 0;
2104
+ const captured = pptxFallback("mermaid", source, deck, fallback.reason, {
2105
+ captureElement: source,
2106
+ includeDescendants: true,
2107
+ artwork: fallback.artwork,
2108
+ padding,
2109
+ });
2110
+ return {
2111
+ ...captured,
2112
+ path: fallback.path,
2113
+ sourcePath,
2114
+ reason: fallback.reason,
2115
+ x: fallback.x,
2116
+ y: fallback.y,
2117
+ width: fallback.width,
2118
+ height: fallback.height,
2119
+ zOrder: fallback.zOrder,
2120
+ ...(node?.id ? { id: node.id } : {}),
2121
+ ...(fallback.artwork === false ? { artwork: false } : {}),
2122
+ };
2123
+ });
2124
+ return { elements: mapped.elements, fallbacks };
1961
2125
  }
1962
2126
 
1963
2127
  async function collectPptxSlide(slide, index) {
@@ -2064,10 +2228,17 @@ async function collectPptxSlide(slide, index) {
2064
2228
  });
2065
2229
  }
2066
2230
  });
2067
- deck.querySelectorAll("pre.mermaid").forEach((element) => {
2231
+ for (const [blockIndex, element] of [...deck.querySelectorAll("pre.mermaid")].entries()) {
2068
2232
  const covered = [...fallbackRoots].some((root) => root === element || root.contains(element));
2069
- if (!covered) addFallback("mermaid", element, "mermaid-rendered-as-artwork");
2070
- });
2233
+ if (covered) continue;
2234
+ try {
2235
+ const mermaid = collectMermaidObjects(element, deck, blockIndex);
2236
+ elements.push(...mermaid.elements);
2237
+ fallbacks.push(...mermaid.fallbacks);
2238
+ } catch (_) {
2239
+ addFallback("mermaid", element, "mermaid-rendered-as-artwork");
2240
+ }
2241
+ }
2071
2242
 
2072
2243
  const insideFallback = (element) =>
2073
2244
  [...fallbackRoots].some((root) => root === element || root.contains(element));
@@ -2079,6 +2250,7 @@ async function collectPptxSlide(slide, index) {
2079
2250
  (element) =>
2080
2251
  !insideFallback(element) &&
2081
2252
  !element.closest(".architecture-diagram") &&
2253
+ !element.closest("pre.mermaid, .mermaid") &&
2082
2254
  !element.closest("table") &&
2083
2255
  !(element.matches("p") && element.closest("blockquote, li")),
2084
2256
  );
@@ -2475,7 +2647,7 @@ async function renderPptxDeck(
2475
2647
  }
2476
2648
  const token = ++renderToken;
2477
2649
  for (const slide of rendered) {
2478
- await runMermaid(slide.bodyEl, slide.theme, token, false);
2650
+ await runMermaid(slide.bodyEl, slide.deck, token, false);
2479
2651
  }
2480
2652
  await waitForImages(stage);
2481
2653
  await afterLayout();
@@ -2590,7 +2762,7 @@ async function renderPrintDeck(
2590
2762
  }
2591
2763
  }
2592
2764
  for (const slide of rendered) {
2593
- await runMermaid(slide.bodyEl, slide.theme, renderToken, false);
2765
+ await runMermaid(slide.bodyEl, slide.deck, renderToken, false);
2594
2766
  }
2595
2767
  await waitForImages(stage);
2596
2768
  await afterLayout();
@@ -2669,7 +2841,7 @@ async function renderCaptureSlide(
2669
2841
  }
2670
2842
 
2671
2843
  const token = ++renderToken;
2672
- await runMermaid(slide.bodyEl, slide.theme, token, false);
2844
+ await runMermaid(slide.bodyEl, slide.deck, token, false);
2673
2845
  await waitForImages(stage);
2674
2846
  await afterLayout();
2675
2847
 
@@ -2790,8 +2962,10 @@ let pptxExportAvailable = false;
2790
2962
  let markdownImportAvailable = false;
2791
2963
  let presenterViewOpen = false;
2792
2964
  let presenterViewRequested = false;
2793
- let pdfExportPending = false;
2794
- let pptxExportPending = false;
2965
+ let exportPending = false;
2966
+ let exportNotificationTimer = null;
2967
+ let exportNotificationRemaining = 0;
2968
+ let exportNotificationStarted = 0;
2795
2969
 
2796
2970
  // Derive a short overview title from a slide fragment: first heading, else first
2797
2971
  // non-empty body line, trimmed. Mirrors the skill's title rule.
@@ -3450,93 +3624,109 @@ function updatePresenterButton(running, message = "") {
3450
3624
  syncMoreControls();
3451
3625
  }
3452
3626
 
3453
- async function exportPdfFromCanvas() {
3454
- if (!pdfExportAvailable || pdfExportPending || pptxExportPending) return;
3455
- pdfExportPending = true;
3456
- const button = document.getElementById("navExport");
3457
- const pptxButton = document.getElementById("navExportPptx");
3458
- const status = document.getElementById("exportStatus");
3459
- if (button) button.disabled = true;
3460
- if (pptxButton) pptxButton.disabled = true;
3461
- if (status) status.textContent = "Saving PDF.";
3627
+ function pauseExportNotification() {
3628
+ if (exportNotificationTimer === null) return;
3629
+ clearTimeout(exportNotificationTimer);
3630
+ exportNotificationTimer = null;
3631
+ exportNotificationRemaining = Math.max(
3632
+ 0,
3633
+ exportNotificationRemaining - (performance.now() - exportNotificationStarted),
3634
+ );
3635
+ }
3462
3636
 
3463
- try {
3464
- const response = await fetch("./export", {
3465
- method: "POST",
3466
- headers: { Accept: "application/json" },
3467
- cache: "no-store",
3468
- });
3469
- const data = await response.json().catch(() => ({}));
3470
- if (!response.ok) {
3471
- throw new Error(data.message || `PDF export failed (${response.status}).`);
3472
- }
3473
- const filename = data.path ? data.path.split(/[\\/]/).pop() : "PDF";
3474
- const message = `Saved ${filename}.`;
3475
- if (status) status.textContent = message;
3476
- if (button) {
3477
- button.dataset.state = "active";
3478
- button.title = message;
3479
- }
3480
- syncMoreControls();
3481
- } catch (error) {
3482
- const message = error?.message || "Could not save the PDF.";
3483
- console.error("PDF export failed", error);
3484
- if (status) status.textContent = message;
3485
- if (button) {
3486
- button.dataset.state = "error";
3487
- button.title = message;
3488
- }
3489
- syncMoreControls();
3490
- } finally {
3491
- pdfExportPending = false;
3492
- if (button) button.disabled = false;
3493
- if (pptxButton) pptxButton.disabled = false;
3637
+ function dismissExportNotification() {
3638
+ const notification = document.getElementById("exportNotification");
3639
+ const restoreFocus = notification.contains(document.activeElement);
3640
+ pauseExportNotification();
3641
+ exportNotificationRemaining = 0;
3642
+ notification.hidden = true;
3643
+ document.getElementById("exportStatus").textContent = "";
3644
+ document.getElementById("exportErrorStatus").textContent = "";
3645
+ if (restoreFocus) {
3646
+ const more = document.getElementById("navMore");
3647
+ (more.getClientRects().length ? more : document.body).focus();
3494
3648
  }
3495
3649
  }
3496
3650
 
3497
- async function exportPptxFromCanvas() {
3498
- if (!pptxExportAvailable || pdfExportPending || pptxExportPending) return;
3499
- pptxExportPending = true;
3500
- const button = document.getElementById("navExportPptx");
3651
+ function resumeExportNotification() {
3652
+ const notification = document.getElementById("exportNotification");
3653
+ if (
3654
+ notification.hidden ||
3655
+ notification.dataset.state !== "success" ||
3656
+ notification.matches(":hover, :focus-within") ||
3657
+ exportNotificationTimer !== null
3658
+ ) {
3659
+ return;
3660
+ }
3661
+ exportNotificationStarted = performance.now();
3662
+ exportNotificationTimer = setTimeout(dismissExportNotification, exportNotificationRemaining);
3663
+ }
3664
+
3665
+ function showExportNotification(state, message, path = "") {
3666
+ pauseExportNotification();
3667
+ const notification = document.getElementById("exportNotification");
3668
+ const location = document.getElementById("exportNotificationPath");
3669
+ notification.dataset.state = state;
3670
+ document.getElementById("exportNotificationMessage").textContent = message;
3671
+ location.textContent = path ? `Saved to: ${path}` : "";
3672
+ location.hidden = !path;
3673
+ document.getElementById("exportNotificationClose").hidden = state === "pending";
3674
+ notification.hidden = false;
3675
+ const announcement = path ? `${message} Saved to: ${path}` : message;
3676
+ document.getElementById("exportStatus").textContent = state === "error" ? "" : announcement;
3677
+ document.getElementById("exportErrorStatus").textContent = state === "error" ? announcement : "";
3678
+ exportNotificationRemaining = state === "success" ? 8000 : 0;
3679
+ resumeExportNotification();
3680
+ }
3681
+
3682
+ async function exportFromCanvas(format) {
3683
+ const isPdf = format === "pdf";
3684
+ if (exportPending || !(isPdf ? pdfExportAvailable : pptxExportAvailable)) return;
3685
+ exportPending = true;
3686
+ const label = isPdf ? "PDF" : "PowerPoint";
3501
3687
  const pdfButton = document.getElementById("navExport");
3502
- const status = document.getElementById("exportStatus");
3503
- if (button) button.disabled = true;
3504
- if (pdfButton) pdfButton.disabled = true;
3505
- if (status) status.textContent = "Saving editable PowerPoint.";
3688
+ const pptxButton = document.getElementById("navExportPptx");
3689
+ const button = isPdf ? pdfButton : pptxButton;
3690
+ const idleTitle = button.title;
3691
+ pdfButton.disabled = true;
3692
+ pptxButton.disabled = true;
3693
+ delete button.dataset.state;
3694
+ button.title = `Saving ${label}.`;
3695
+ showExportNotification("pending", `Saving ${label}...`);
3696
+ syncMoreControls();
3506
3697
 
3507
3698
  try {
3508
- const response = await fetch("./export-pptx", {
3699
+ const response = await fetch(isPdf ? "./export" : "./export-pptx", {
3509
3700
  method: "POST",
3510
3701
  headers: { Accept: "application/json" },
3511
3702
  cache: "no-store",
3512
3703
  });
3513
- const data = await response.json().catch(() => ({}));
3514
- if (!response.ok) {
3515
- throw new Error(data.message || `PowerPoint export failed (${response.status}).`);
3704
+ const data = await response.json();
3705
+ if (!response.ok || data?.ok !== true) {
3706
+ throw new Error(
3707
+ (typeof data?.message === "string" && data.message) ||
3708
+ `${label} export failed (${response.status}).`,
3709
+ );
3516
3710
  }
3517
- const filename = data.path ? data.path.split(/[\\/]/).pop() : "PowerPoint";
3518
- const fallback =
3519
- data.fallbackCount > 0 ? ` ${data.fallbackCount} fallback item(s) preserved.` : "";
3520
- const message = `Saved ${filename}.${fallback}`;
3521
- if (status) status.textContent = message;
3522
- if (button) {
3523
- button.dataset.state = "active";
3524
- button.title = message;
3711
+ if (typeof data.path !== "string" || !data.path.trim() || !data.path.split(/[\\/]/).pop()) {
3712
+ throw new Error(`${label} export returned an invalid save location.`);
3525
3713
  }
3526
- syncMoreControls();
3714
+ const filename = data.path.split(/[\\/]/).pop();
3715
+ const fallback =
3716
+ !isPdf && data.fallbackCount > 0 ? ` ${data.fallbackCount} fallback item(s) preserved.` : "";
3717
+ const message = `${label} saved: ${filename}.${fallback}`;
3718
+ showExportNotification("success", message, data.path);
3527
3719
  } catch (error) {
3528
- const message = error?.message || "Could not save the PowerPoint presentation.";
3529
- console.error("PowerPoint export failed", error);
3530
- if (status) status.textContent = message;
3531
- if (button) {
3532
- button.dataset.state = "error";
3533
- button.title = message;
3534
- }
3535
- syncMoreControls();
3720
+ const message = `Could not save ${label}. ${error?.message || "Export failed."}`;
3721
+ console.error(`${label} export failed`, error);
3722
+ showExportNotification("error", message);
3536
3723
  } finally {
3537
- pptxExportPending = false;
3538
- if (button) button.disabled = false;
3539
- if (pdfButton) pdfButton.disabled = false;
3724
+ exportPending = false;
3725
+ pdfButton.disabled = false;
3726
+ pptxButton.disabled = false;
3727
+ delete button.dataset.state;
3728
+ button.title = idleTitle;
3729
+ syncMoreControls();
3540
3730
  }
3541
3731
  }
3542
3732
 
@@ -3982,8 +4172,8 @@ function wireControls() {
3982
4172
  bind("navPresent", openPresenterWindow, { closeMore: true });
3983
4173
  bind("navPresenterView", openPresenterView, { closeMore: true });
3984
4174
  bind("navFixedPreview", toggleFixedPreviewMode, { closeMore: true });
3985
- bind("navExport", exportPdfFromCanvas, { closeMore: true });
3986
- bind("navExportPptx", exportPptxFromCanvas, { closeMore: true });
4175
+ bind("navExport", () => exportFromCanvas("pdf"), { closeMore: true });
4176
+ bind("navExportPptx", () => exportFromCanvas("pptx"), { closeMore: true });
3987
4177
  bind("navImport", toggleImportPicker, { closeMore: true });
3988
4178
  bind("navSourceMode", toggleSourceMode, { closeMore: true });
3989
4179
  bind("overviewClose", closeOverview);
@@ -3995,6 +4185,13 @@ function wireControls() {
3995
4185
  bind("presenterToggleButton", togglePresenterWindow);
3996
4186
  bind("presenterReturnButton", closePresenterView);
3997
4187
 
4188
+ const exportNotification = document.getElementById("exportNotification");
4189
+ document.getElementById("exportNotificationClose").addEventListener("click", dismissExportNotification);
4190
+ exportNotification.addEventListener("pointerenter", pauseExportNotification);
4191
+ exportNotification.addEventListener("pointerleave", resumeExportNotification);
4192
+ exportNotification.addEventListener("focusin", pauseExportNotification);
4193
+ exportNotification.addEventListener("focusout", () => queueMicrotask(resumeExportNotification));
4194
+
3998
4195
  const importFilter = document.getElementById("importFilter");
3999
4196
  if (importFilter) {
4000
4197
  importFilter.addEventListener("input", renderImportList);