@markdstage/markdstage 3.2.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.
@@ -0,0 +1,265 @@
1
+ import { SceneGraphError, validateScene } from "./scene-graph.mjs";
2
+
3
+ const DEFAULT_Z_ORDER_BASE = 0;
4
+ const DEFAULT_Z_ORDER_STEP = 1 / 1000;
5
+ // Scene groups carry no preset, so the producer picks the frame geometry:
6
+ // Architecture group frames are rounded, Mermaid subgraph clusters are square.
7
+ const DEFAULT_GROUP_PRESET = "roundedRect";
8
+ const DEFAULT_TEXT_WRAP = undefined;
9
+ const DEFAULT_EMIT_PATH = true;
10
+ const DEFAULT_EMIT_Z_ORDER = true;
11
+
12
+ function finiteNumberOr(value, fallback) {
13
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
14
+ }
15
+
16
+ function positiveNumberOr(value, fallback) {
17
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
18
+ }
19
+
20
+ function nonEmptyStringOr(value, fallback) {
21
+ return typeof value === "string" && value ? value : fallback;
22
+ }
23
+
24
+ function prefixedPath(prefix, path) {
25
+ if (!prefix) return path;
26
+ return path ? `${prefix}.${path}` : prefix;
27
+ }
28
+
29
+ function nodePath(node, index, prefix) {
30
+ return prefixedPath(prefix, nonEmptyStringOr(node.sourcePath, `nodes[${index}]`));
31
+ }
32
+
33
+ function hasVisiblePaint(value) {
34
+ if (value === undefined || value === null) return false;
35
+ const text = String(value).trim().toLowerCase();
36
+ return text !== "" && text !== "transparent" && text !== "rgba(0, 0, 0, 0)";
37
+ }
38
+
39
+ function hasVisibleStroke(style = {}) {
40
+ if (!hasVisiblePaint(style.stroke)) return false;
41
+ return style.strokeWidth === undefined || style.strokeWidth > 0;
42
+ }
43
+
44
+ function hasVisibleStyle(style = {}) {
45
+ return hasVisiblePaint(style.fill) || hasVisibleStroke(style);
46
+ }
47
+
48
+ function copyDefined(target, source, keys) {
49
+ for (const key of keys) {
50
+ if (source[key] !== undefined) target[key] = source[key];
51
+ }
52
+ return target;
53
+ }
54
+
55
+ function copyTextLayout(target, textLayout = {}, keys) {
56
+ copyDefined(target, textLayout, keys);
57
+ if (target.textWrap === undefined) target.textWrap = DEFAULT_TEXT_WRAP;
58
+ if (target.textWrap === undefined) delete target.textWrap;
59
+ return target;
60
+ }
61
+
62
+ function styleFields(style = {}) {
63
+ const mapped = {};
64
+ copyDefined(mapped, style, ["fill", "stroke", "opacity", "cornerRadius"]);
65
+ if (style.strokeWidth !== undefined && style.strokeWidth > 0) {
66
+ mapped.strokeWidth = style.strokeWidth;
67
+ } else if (style.strokeWidth === 0) {
68
+ mapped.stroke = null;
69
+ }
70
+ if (style.dash !== undefined && style.dash !== "" && style.dash !== "solid") {
71
+ mapped.dash = style.dash;
72
+ }
73
+ return mapped;
74
+ }
75
+
76
+ function geometryFields(node) {
77
+ return {
78
+ x: node.bounds.x,
79
+ y: node.bounds.y,
80
+ width: node.bounds.width,
81
+ height: node.bounds.height,
82
+ };
83
+ }
84
+
85
+ function connectorBounds(node) {
86
+ if (node.bounds) return node.bounds;
87
+ const xs = node.points.map((point) => point.x);
88
+ const ys = node.points.map((point) => point.y);
89
+ const x = Math.min(...xs);
90
+ const y = Math.min(...ys);
91
+ return {
92
+ x,
93
+ y,
94
+ width: Math.max(...xs) - x,
95
+ height: Math.max(...ys) - y,
96
+ };
97
+ }
98
+
99
+ function cloneText(text) {
100
+ if (typeof text === "string") return text;
101
+ return {
102
+ paragraphs: text.paragraphs.map((paragraph) => ({
103
+ ...paragraph,
104
+ runs: paragraph.runs.map((run) => {
105
+ const next = { ...run };
106
+ if (next.fontSize !== undefined && next.fontSize <= 0) delete next.fontSize;
107
+ return next;
108
+ }),
109
+ })),
110
+ };
111
+ }
112
+
113
+ function zOrderFor(node, options) {
114
+ return options.zOrderBase + node.z * options.zOrderStep;
115
+ }
116
+
117
+ function elementBase(node, index, options) {
118
+ return {
119
+ ...(node.meta || {}),
120
+ ...(options.emitPath ? { path: nodePath(node, index, options.pathPrefix) } : {}),
121
+ ...(options.emitZOrder ? { zOrder: zOrderFor(node, options) } : {}),
122
+ };
123
+ }
124
+
125
+ function fallbackReason(node, fallback) {
126
+ return nonEmptyStringOr(
127
+ node.reason,
128
+ nonEmptyStringOr(node.capability?.reason, fallback),
129
+ );
130
+ }
131
+
132
+ function fallbackFor(node, index, options, reason) {
133
+ const bounds = node.bounds || { x: 0, y: 0, width: 0, height: 0 };
134
+ const fallback = {
135
+ type: options.fallbackType,
136
+ path: nodePath(node, index, options.pathPrefix),
137
+ sourcePath: nonEmptyStringOr(node.sourcePath, `nodes[${index}]`),
138
+ reason,
139
+ x: bounds.x,
140
+ y: bounds.y,
141
+ width: bounds.width,
142
+ height: bounds.height,
143
+ zOrder: zOrderFor(node, options),
144
+ };
145
+ if (bounds.width === 0 || bounds.height === 0) fallback.artwork = false;
146
+ return fallback;
147
+ }
148
+
149
+ function shapeElement(node, index, options, shape) {
150
+ const element = {
151
+ ...elementBase(node, index, options),
152
+ type: "shape",
153
+ shape,
154
+ ...geometryFields(node),
155
+ ...styleFields(node.style),
156
+ };
157
+ if (node.text !== undefined) element.text = cloneText(node.text);
158
+ copyTextLayout(element, node.textLayout, ["verticalAlignment", "textWrap", "textInsets"]);
159
+ return element;
160
+ }
161
+
162
+ function groupElement(node, index, options) {
163
+ if (!hasVisibleStyle(node.style) && node.text === undefined) return null;
164
+ return shapeElement(node, index, options, options.groupPreset);
165
+ }
166
+
167
+ function textElement(node, index, options) {
168
+ const text = cloneText(node.text);
169
+ const element = {
170
+ ...elementBase(node, index, options),
171
+ type: "text",
172
+ ...geometryFields(node),
173
+ paragraphs: text.paragraphs,
174
+ };
175
+ copyDefined(element, node.textLayout || {}, ["textInsets", "textWrap"]);
176
+ return element;
177
+ }
178
+
179
+ function imageElement(node, index, options) {
180
+ const element = {
181
+ ...elementBase(node, index, options),
182
+ type: "image",
183
+ ...geometryFields(node),
184
+ src: node.src,
185
+ alt: node.alt,
186
+ fit: node.fit,
187
+ shape: "rect",
188
+ };
189
+ copyDefined(element, node, ["opacity"]);
190
+ return element;
191
+ }
192
+
193
+ function connectorElement(node, index, options) {
194
+ if (node.style?.stroke === null || node.style?.strokeWidth === 0) {
195
+ return fallbackFor(node, index, options, "connector-stroke-not-representable");
196
+ }
197
+ const element = {
198
+ ...elementBase(node, index, options),
199
+ type: "connector",
200
+ points: node.points.map((point) => ({ ...point })),
201
+ ...connectorBounds(node),
202
+ arrowStart: node.arrowStart,
203
+ arrowEnd: node.arrowEnd,
204
+ ...styleFields(node.style),
205
+ };
206
+ if (node.label !== undefined) {
207
+ element.label = cloneText(node.label.text);
208
+ element.labelBounds = { ...node.label.bounds };
209
+ }
210
+ return element;
211
+ }
212
+
213
+ function normalizeOptions(scene, options = {}) {
214
+ const sourceKind = scene?.source?.kind;
215
+ return {
216
+ pathPrefix: typeof options.pathPrefix === "string" ? options.pathPrefix : "",
217
+ zOrderBase: finiteNumberOr(options.zOrderBase, DEFAULT_Z_ORDER_BASE),
218
+ zOrderStep: positiveNumberOr(options.zOrderStep, DEFAULT_Z_ORDER_STEP),
219
+ fallbackType: nonEmptyStringOr(options.fallbackType, nonEmptyStringOr(sourceKind, "scene")),
220
+ groupPreset: nonEmptyStringOr(options.groupPreset, DEFAULT_GROUP_PRESET),
221
+ emitPath: options.emitPath === undefined ? DEFAULT_EMIT_PATH : options.emitPath !== false,
222
+ emitZOrder: options.emitZOrder === undefined ? DEFAULT_EMIT_Z_ORDER : options.emitZOrder !== false,
223
+ };
224
+ }
225
+
226
+ function mappedNode(node, index, options) {
227
+ if (node.capability?.pptx === "fallback") {
228
+ return fallbackFor(node, index, options, fallbackReason(node, "node-rendered-as-artwork"));
229
+ }
230
+ if (node.kind === "fallback") {
231
+ return fallbackFor(node, index, options, fallbackReason(node, "node-rendered-as-artwork"));
232
+ }
233
+ if (node.kind === "group") return groupElement(node, index, options);
234
+ if (node.kind === "shape") return shapeElement(node, index, options, node.preset);
235
+ if (node.kind === "text") return textElement(node, index, options);
236
+ if (node.kind === "image") return imageElement(node, index, options);
237
+ if (node.kind === "connector") return connectorElement(node, index, options);
238
+ return fallbackFor(node, index, options, `unsupported-node-kind: ${String(node.kind)}`);
239
+ }
240
+
241
+ export function sceneToPptxElements(scene, options = {}) {
242
+ try {
243
+ validateScene(scene);
244
+ } catch (error) {
245
+ if (error instanceof SceneGraphError) throw error;
246
+ throw new SceneGraphError(error?.message || "scene is malformed");
247
+ }
248
+
249
+ const normalizedOptions = normalizeOptions(scene, options);
250
+ const elements = [];
251
+ const fallbacks = [];
252
+ scene.nodes.forEach((node, index) => {
253
+ const mapped = mappedNode(node, index, normalizedOptions);
254
+ if (!mapped) return;
255
+ if (mapped.reason !== undefined && !["shape", "text", "image", "connector"].includes(mapped.type)) {
256
+ fallbacks.push(mapped);
257
+ } else {
258
+ elements.push(mapped);
259
+ }
260
+ });
261
+ const byZOrder = (left, right) => (left.zOrder ?? 0) - (right.zOrder ?? 0);
262
+ elements.sort(byZOrder);
263
+ fallbacks.sort(byZOrder);
264
+ return { elements, fallbacks };
265
+ }
@@ -0,0 +1,321 @@
1
+ import { MAX_STRING_LENGTH, normalizeScene, validateScene } from "./scene-graph.mjs";
2
+
3
+ const SVG_NS = "http://www.w3.org/2000/svg";
4
+ const HTML_NS = "http://www.w3.org/1999/xhtml";
5
+ const MATH_NS = "http://www.w3.org/1998/Math/MathML";
6
+ const SVG_TAGS = new Set("svg g defs symbol switch title desc path rect circle ellipse polygon polyline line text tspan textPath image use marker clipPath mask pattern linearGradient radialGradient stop filter feDropShadow feGaussianBlur feOffset feBlend feColorMatrix feComposite feFlood feMerge feMergeNode feComponentTransfer feFuncR feFuncG feFuncB feFuncA feConvolveMatrix feDisplacementMap feTurbulence feMorphology feImage feDiffuseLighting feSpecularLighting feDistantLight fePointLight feSpotLight feTile foreignObject".split(" "));
7
+ const HTML_TAGS = new Set("div span p br b strong i em s u small sub sup ul ol li code pre img table thead tbody tr th td".split(" "));
8
+ const MATH_TAGS = new Set("math mrow mi mn mo mtext mspace ms mfrac msqrt mroot mstyle merror mpadded mphantom mfenced menclose msub msup msubsup munder mover munderover mmultiscripts mprescripts none mtable mtr mtd semantics annotation".split(" "));
9
+ const ATTRIBUTES = new Set("id class x y x1 y1 x2 y2 dx dy width height cx cy r rx ry d points viewBox preserveAspectRatio transform fill fill-opacity fill-rule stroke stroke-width stroke-opacity stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit opacity color font-family font-size font-weight font-style text-anchor dominant-baseline alignment-baseline textLength lengthAdjust letter-spacing word-spacing text-decoration visibility display overflow pointer-events role tabindex focusable marker-start marker-mid marker-end markerWidth markerHeight markerUnits refX refY orient clip-path clipPathUnits mask maskUnits maskContentUnits filter filterUnits primitiveUnits in in2 result stdDeviation mode type values operator k1 k2 k3 k4 flood-color flood-opacity offset stop-color stop-opacity gradientUnits gradientTransform spreadMethod patternUnits patternContentUnits patternTransform".split(" "));
10
+ const CSS_PROPERTIES = new Set("fill fill-opacity fill-rule stroke stroke-width stroke-opacity stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit opacity color font-family font-size font-weight font-style font-variant line-height text-align text-anchor dominant-baseline alignment-baseline text-decoration letter-spacing word-spacing white-space overflow-wrap word-break display visibility overflow box-sizing width height min-width min-height max-width max-height padding padding-top padding-right padding-bottom padding-left margin margin-top margin-right margin-bottom margin-left border border-radius background-color vertical-align marker-start marker-mid marker-end clip-path filter".split(" "));
11
+ for (const attribute of "alt colspan rowspan systemLanguage startOffset method spacing baseFrequency numOctaves seed stitchTiles scale xChannelSelector yChannelSelector radius order kernelMatrix divisor bias targetX targetY edgeMode preserveAlpha tableValues slope intercept amplitude exponent surfaceScale diffuseConstant specularConstant specularExponent azimuth elevation limitingConeAngle pointsAtX pointsAtY pointsAtZ z mathvariant mathsize mathcolor mathbackground columnalign rowalign columnspacing rowspacing stretchy fence separator accent accentunder largeop movablelimits lspace rspace encoding".split(" ")) ATTRIBUTES.add(attribute);
12
+ for (const property of "position top right bottom left z-index transform transform-origin border-top border-right border-bottom border-left border-top-width border-right-width border-bottom-width border-left-width border-top-style border-right-style border-bottom-style border-left-style border-top-color border-right-color border-bottom-color border-left-color object-fit object-position flex flex-direction flex-wrap align-items align-content justify-content gap float clear".split(" ")) CSS_PROPERTIES.add(property);
13
+ let renderSequence = 0;
14
+
15
+ function portableString(value) {
16
+ const text = String(value);
17
+ if (text.length <= MAX_STRING_LENGTH) return text;
18
+ // Rough.js and icon paths can exceed the scene's per-string ceiling. Chunks
19
+ // preserve exact geometry without weakening the shared scene validation limit.
20
+ const chunks = [];
21
+ for (let index = 0; index < text.length; index += MAX_STRING_LENGTH) chunks.push(text.slice(index, index + MAX_STRING_LENGTH));
22
+ return chunks;
23
+ }
24
+
25
+ function stringValue(value) {
26
+ return Array.isArray(value) ? value.join("") : String(value);
27
+ }
28
+
29
+ function safeUrl(value, image = false) {
30
+ const text = String(value).trim();
31
+ if (!text || /[\u0000-\u0020\u007f]/.test(text)) return false;
32
+ if (text.startsWith("#")) return true;
33
+ if (!image) return false;
34
+ if (/^data:image\/(?:png|jpeg|gif|webp|avif|svg\+xml)[;,]/i.test(text)) return true;
35
+ return !/^[a-z][a-z0-9+.-]*:/i.test(text) || /^https?:/i.test(text);
36
+ }
37
+
38
+ function safeCss(value) {
39
+ const text = String(value);
40
+ if (/[\\<>]|\/\*|@|expression\s*\(|javascript\s*:|data\s*:/i.test(text)) return false;
41
+ return !/url\s*\(/i.test(text) || /^url\(["']?#[\w:.-]+["']?\)$/i.test(text.trim());
42
+ }
43
+
44
+ function localPaint(value, source) {
45
+ const match = /^url\(["']?([^"')]+)["']?\)$/i.exec(value);
46
+ if (!match || match[1].startsWith("#")) return value;
47
+ try {
48
+ const base = new URL(source.baseURI);
49
+ const url = new URL(match[1], base);
50
+ if (url.hash && url.origin === base.origin && url.pathname === base.pathname && url.search === base.search) return `url(${url.hash})`;
51
+ } catch (error) {
52
+ if (!(error instanceof TypeError)) throw error;
53
+ }
54
+ return value;
55
+ }
56
+
57
+ function inlineGeometry(source, property) {
58
+ // CSSOM serializes lengths with fewer digits than Mermaid's raw declaration.
59
+ // Rounding a max-width down can change the used width by a whole layout unit.
60
+ const declarations = source.getAttribute?.("style") || "";
61
+ const pattern = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "gi");
62
+ const matches = [...declarations.matchAll(pattern)];
63
+ return matches.at(-1)?.[1].replace(/\s*!important\s*$/i, "").trim()
64
+ || source.style?.getPropertyValue(property);
65
+ }
66
+
67
+ /** A JSON-serializable primitive builder; it never creates or parses DOM. */
68
+ export function svgPrimitive(tag, attributes = {}) {
69
+ const primitive = { tag, attributes: {}, children: [] };
70
+ for (const [name, value] of Object.entries(attributes)) {
71
+ if (value !== undefined && value !== "") primitive.attributes[name] = String(value);
72
+ }
73
+ Object.defineProperty(primitive, "appendChild", {
74
+ value(child) { this.children.push(child); return child; },
75
+ });
76
+ return primitive;
77
+ }
78
+
79
+ /**
80
+ * Preserve source geometry, labels and unsupported SVG subtrees as inert primitives.
81
+ * Computed styles replace stylesheet text; no HTML parsing, scripts or event handlers
82
+ * cross this boundary. Slots keep the visible tree tied to the scene's source nodes.
83
+ */
84
+ export function captureSvgTree(element, { slots = new Map(), computedStyle = globalThis.getComputedStyle } = {}) {
85
+ let count = 0;
86
+ function capture(source, depth, root = false) {
87
+ if (++count > 50000 || depth > 128) throw new Error("SVG primitive limit exceeded");
88
+ if (source.nodeType === 3) return { text: portableString(source.textContent || "") };
89
+ if (source.nodeType !== 1) return null;
90
+ if (!root && slots.has(source)) return { sceneNode: slots.get(source) };
91
+ let tag = source.localName || source.tagName;
92
+ const html = source.namespaceURI === HTML_NS;
93
+ const math = source.namespaceURI === MATH_NS;
94
+ if (["script", "style", "iframe", "object", "embed", "animate", "animateTransform", "set"].includes(tag)) return null;
95
+ if (!(math ? MATH_TAGS : html ? HTML_TAGS : SVG_TAGS).has(tag)) tag = math ? "mrow" : html ? "span" : "g";
96
+ const primitive = { tag, ...(math ? { namespace: "math" } : html ? { namespace: "html" } : {}), attributes: {}, children: [] };
97
+ for (const attribute of source.attributes || []) {
98
+ if (attribute.name !== "style") primitive.attributes[attribute.name] = portableString(attribute.value);
99
+ }
100
+ if (computedStyle) {
101
+ const style = computedStyle(source);
102
+ primitive.style = {};
103
+ for (const property of CSS_PROPERTIES) {
104
+ // SVG geometry and the root's responsive CSS must not become screen pixels.
105
+ if (!html && !math && /^(?:min-|max-)?(?:width|height)$/.test(property)) continue;
106
+ let value = localPaint(style.getPropertyValue(property), source);
107
+ if (property === "transform") {
108
+ // Typed OM retains matrix precision and includes stylesheet overrides.
109
+ const transform = source.computedStyleMap?.().get("transform");
110
+ if (transform?.toMatrix && !transform.toString().includes("%")) value = transform.toMatrix().toString();
111
+ }
112
+ if (value && safeCss(value)) primitive.style[property] = value;
113
+ }
114
+ if (root) {
115
+ for (const property of ["width", "height", "max-width", "max-height"]) {
116
+ const value = inlineGeometry(source, property);
117
+ if (value && safeCss(value)) primitive.style[property] = value;
118
+ }
119
+ }
120
+ }
121
+ for (const child of source.childNodes || []) {
122
+ const captured = capture(child, depth + 1);
123
+ if (captured) primitive.children.push(captured);
124
+ }
125
+ return primitive;
126
+ }
127
+ return capture(element, 0, true);
128
+ }
129
+
130
+ function setAttributes(element, attributes = {}) {
131
+ for (const [name, rawValue] of Object.entries(attributes)) {
132
+ const value = Array.isArray(rawValue) ? stringValue(rawValue) : rawValue;
133
+ if (value === undefined || value === null) continue;
134
+ if (name === "href" || name === "xlink:href" || name === "src") {
135
+ const tag = element.localName || element.tagName;
136
+ if (safeUrl(value, tag === "image" || tag === "img" || tag === "feImage")) {
137
+ element.setAttribute(name === "src" ? "src" : "href", String(value));
138
+ }
139
+ } else if (
140
+ (ATTRIBUTES.has(name) || /^(?:aria|data)-[\w-]+$/.test(name)) &&
141
+ (!CSS_PROPERTIES.has(name) || safeCss(value))
142
+ ) {
143
+ element.setAttribute(name, String(value));
144
+ }
145
+ }
146
+ }
147
+
148
+ /** Render a validated shared scene. All SVG is constructed with DOM APIs. */
149
+ export function sceneToSvg(scene, {
150
+ document: documentRef = globalThis.document,
151
+ template,
152
+ attributes = {},
153
+ resolveFallback,
154
+ } = {}) {
155
+ validateScene(scene);
156
+ template ||= scene.meta?.svgRoot;
157
+ if (!template && scene.nodes.some((node) => node.kind === "group" && node.children.length)) {
158
+ scene = normalizeScene(scene).scene;
159
+ }
160
+ if (!documentRef?.createElementNS) throw new Error("sceneToSvg requires a DOM document");
161
+ const renderId = ++renderSequence;
162
+ const rendered = new Set();
163
+ let primitiveCount = 0;
164
+ function dom(tag, attrs = {}, namespace = SVG_NS) {
165
+ const element = documentRef.createElementNS(namespace, tag);
166
+ setAttributes(element, attrs);
167
+ return element;
168
+ }
169
+ function primitive(value, depth = 0) {
170
+ if (!value || ++primitiveCount > 50000 || depth > 128) throw new Error("Invalid SVG primitive tree");
171
+ if (Number.isInteger(value.sceneNode)) return renderNode(scene.nodes[value.sceneNode], value.sceneNode);
172
+ if (value.text !== undefined && !value.tag) return documentRef.createTextNode(stringValue(value.text));
173
+ const html = value.namespace === "html";
174
+ const math = value.namespace === "math";
175
+ if (!(math ? MATH_TAGS : html ? HTML_TAGS : SVG_TAGS).has(value.tag)) throw new Error(`Unsupported SVG primitive: ${value.tag}`);
176
+ const element = dom(value.tag, value.attributes, math ? MATH_NS : html ? HTML_NS : SVG_NS);
177
+ for (const [property, content] of Object.entries(value.style || {})) {
178
+ if (CSS_PROPERTIES.has(property) && safeCss(content)) element.style?.setProperty(property, String(content));
179
+ }
180
+ if (value.textContent !== undefined) element.textContent = String(value.textContent);
181
+ for (const child of value.children || []) {
182
+ const renderedChild = primitive(child, depth + 1);
183
+ if (renderedChild) element.appendChild(renderedChild);
184
+ }
185
+ return element;
186
+ }
187
+ function paint(style = {}) {
188
+ return {
189
+ fill: style.fill ?? "none", stroke: style.stroke ?? "none",
190
+ "stroke-width": style.strokeWidth ?? 0, opacity: style.opacity ?? 1,
191
+ "stroke-dasharray": { dash: "8 5", dashDot: "8 4 2 4", dot: "2 4" }[style.dash],
192
+ };
193
+ }
194
+ function shape(node) {
195
+ const { x, y, width: w, height: h } = node.bounds;
196
+ const attrs = paint(node.style);
197
+ if (node.preset === "ellipse") return dom("ellipse", { cx: x + w / 2, cy: y + h / 2, rx: w / 2, ry: h / 2, ...attrs });
198
+ const presets = {
199
+ diamond: [[.5, 0], [1, .5], [.5, 1], [0, .5]],
200
+ triangle: [[.5, 0], [1, 1], [0, 1]],
201
+ hexagon: [[.25, 0], [.75, 0], [1, .5], [.75, 1], [.25, 1], [0, .5]],
202
+ parallelogram: [[.2, 0], [1, 0], [.8, 1], [0, 1]],
203
+ };
204
+ if (presets[node.preset]) return dom("polygon", {
205
+ points: presets[node.preset].map(([px, py]) => `${x + px * w},${y + py * h}`).join(" "), ...attrs,
206
+ });
207
+ if (node.preset === "cylinder") {
208
+ const r = Math.min(h / 4, w / 8);
209
+ return dom("path", { d: `M ${x} ${y + r} A ${w / 2} ${r} 0 0 1 ${x + w} ${y + r} L ${x + w} ${y + h - r} A ${w / 2} ${r} 0 0 1 ${x} ${y + h - r} Z M ${x} ${y + r} A ${w / 2} ${r} 0 0 0 ${x + w} ${y + r}`, ...attrs });
210
+ }
211
+ if (node.preset && !["rect", "roundedRect"].includes(node.preset)) throw new Error(`Unsupported scene SVG preset: ${node.preset}`);
212
+ return dom("rect", { x, y, width: w, height: h, rx: node.preset === "roundedRect" ? node.style?.cornerRadius ?? 8 : 0, ...attrs });
213
+ }
214
+ function text(parent, content, bounds, layout = {}) {
215
+ if (!content?.paragraphs?.length) return;
216
+ const lines = content.paragraphs.flatMap((paragraph) => {
217
+ const output = [{ ...paragraph, runs: [] }];
218
+ for (const run of paragraph.runs) {
219
+ run.text.split("\n").forEach((part, index) => {
220
+ if (index) output.push({ ...paragraph, runs: [] });
221
+ output.at(-1).runs.push({ ...run, text: part });
222
+ });
223
+ }
224
+ return output;
225
+ });
226
+ const heights = lines.map((line) => Math.max(1, ...line.runs.map((run) => run.fontSize ?? 16)) * 1.2);
227
+ const insets = layout.textInsets || {};
228
+ const availableHeight = bounds.height - (insets.top || 0) - (insets.bottom || 0);
229
+ const total = heights.reduce((a, b) => a + b, 0);
230
+ let y = bounds.y + (insets.top || 0) + (layout.verticalAlignment === "bottom" ? availableHeight - total : layout.verticalAlignment === "middle" ? (availableHeight - total) / 2 : 0);
231
+ lines.forEach((line, index) => {
232
+ const alignment = line.alignment || layout.alignment || "left";
233
+ const left = bounds.x + (insets.left || 0);
234
+ const right = bounds.x + bounds.width - (insets.right || 0);
235
+ const x = alignment === "center" ? (left + right) / 2 : alignment === "right" ? right : left;
236
+ const label = dom("text", { x, y: y + heights[index] / 2, "dominant-baseline": "middle", "text-anchor": alignment === "center" ? "middle" : alignment === "right" ? "end" : "start" });
237
+ for (const run of line.runs) {
238
+ const span = dom("tspan", {
239
+ fill: run.color === null ? "none" : run.color ?? "#000000", "font-size": run.fontSize ?? 16,
240
+ "font-family": run.fontFace, "font-weight": run.fontWeight || (run.bold ? 700 : 400),
241
+ "font-style": run.italic ? "italic" : "normal", opacity: run.opacity,
242
+ });
243
+ span.textContent = run.text;
244
+ label.appendChild(span);
245
+ }
246
+ parent.appendChild(label);
247
+ y += heights[index];
248
+ });
249
+ }
250
+ function renderNode(node, index) {
251
+ if (!node) throw new Error("Unknown SVG scene slot");
252
+ if (rendered.has(index)) throw new Error("Duplicate SVG scene slot");
253
+ rendered.add(index);
254
+ if (node.meta?.svgOwner !== undefined) return null;
255
+ let element;
256
+ if (node.meta?.svg) element = primitive(node.meta.svg);
257
+ else if (node.kind === "fallback") {
258
+ const fallback = resolveFallback?.(node);
259
+ if (!fallback) throw new Error(`SVG fallback unavailable: ${node.sourcePath}: ${node.reason}`);
260
+ element = primitive(fallback);
261
+ } else {
262
+ element = dom("g");
263
+ if (node.kind === "shape" || node.kind === "group") element.appendChild(shape(node));
264
+ if (node.kind === "image") element.appendChild(dom("image", {
265
+ ...node.bounds, href: node.src, opacity: node.opacity,
266
+ preserveAspectRatio: node.fit === "fill" ? "none" : node.fit === "cover" ? "xMidYMid slice" : "xMidYMid meet",
267
+ role: "img", "aria-label": node.alt,
268
+ }));
269
+ if (node.kind === "connector") {
270
+ const attrs = { ...paint(node.style), fill: "none", "stroke-linejoin": "round", "stroke-linecap": "round" };
271
+ const defs = dom("defs");
272
+ for (const [end, arrow] of [["start", node.arrowStart], ["end", node.arrowEnd]]) {
273
+ if (!arrow || arrow === "none") continue;
274
+ const id = `scene-arrow-${renderId}-${index}-${end}`;
275
+ const marker = dom("marker", { id, viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse", markerUnits: "strokeWidth" });
276
+ marker.appendChild(arrow === "oval"
277
+ ? dom("circle", { cx: 5, cy: 5, r: 4, fill: node.style?.stroke })
278
+ : dom("path", { d: arrow === "diamond" ? "M 0 5 L 5 0 L 10 5 L 5 10 Z" : arrow === "stealth" ? "M 0 0 L 10 5 L 0 10 L 3 5 Z" : "M 0 0 L 10 5 L 0 10 Z", fill: node.style?.stroke }));
279
+ defs.appendChild(marker);
280
+ attrs[`marker-${end}`] = `url(#${id})`;
281
+ }
282
+ element.appendChild(defs);
283
+ element.appendChild(dom("path", { d: node.points.map((point, i) => `${i ? "L" : "M"} ${point.x} ${point.y}`).join(" "), ...attrs }));
284
+ if (node.label) text(element, node.label.text, node.label.bounds);
285
+ }
286
+ if (node.text) text(element, node.text, node.bounds, node.textLayout);
287
+ if (node.accessibility?.label) setAttributes(element, { role: node.accessibility.role || "img", "aria-label": node.accessibility.label });
288
+ }
289
+ setAttributes(element, { "data-scene-node": node.kind, "data-scene-source-path": node.sourcePath, "data-scene-id": node.id });
290
+ return element;
291
+ }
292
+ let svg;
293
+ if (template) {
294
+ svg = primitive(template);
295
+ for (const [index, node] of scene.nodes.entries()) {
296
+ if (!rendered.has(index) && node.meta?.svgOwner === undefined) throw new Error(`Missing SVG scene slot: ${node.sourcePath}`);
297
+ }
298
+ } else {
299
+ svg = dom("svg", { viewBox: `0 0 ${scene.width} ${scene.height}`, preserveAspectRatio: "xMidYMid meet", role: "img" });
300
+ if (scene.accessibility?.title) {
301
+ const title = dom("title", { id: `scene-title-${renderId}` });
302
+ title.textContent = scene.accessibility.title;
303
+ svg.appendChild(title);
304
+ svg.setAttribute("aria-labelledby", `scene-title-${renderId}`);
305
+ }
306
+ if (scene.accessibility?.description) {
307
+ const description = dom("desc", { id: `scene-description-${renderId}` });
308
+ description.textContent = scene.accessibility.description;
309
+ svg.appendChild(description);
310
+ svg.setAttribute("aria-describedby", `scene-description-${renderId}`);
311
+ }
312
+ [...scene.nodes.entries()].sort((a, b) => a[1].z - b[1].z || a[0] - b[0]).forEach(([index, node]) => {
313
+ const element = renderNode(node, index);
314
+ if (element) svg.appendChild(element);
315
+ });
316
+ }
317
+ if ((svg.localName || svg.tagName) !== "svg" || svg.namespaceURI !== SVG_NS) throw new Error("Scene SVG root must be an SVG element");
318
+ setAttributes(svg, { ...attributes, "data-scene-backend": "svg", "data-scene-source": scene.source.kind });
319
+ Object.defineProperty(svg, "__presentationScene", { value: scene });
320
+ return svg;
321
+ }
@@ -447,6 +447,24 @@ footer .page{color:var(--accent-strong);font-weight:600;background:var(--accent-
447
447
  color:#fff;background:#a4262c;border:1px solid #f1aeb5;
448
448
  box-shadow:0 6px 22px rgba(0,0,0,.25);font-size:14px;font-weight:650;}
449
449
  .layout-warning[hidden]{display:none;}
450
+ .export-notification{position:fixed;top:14px;left:14px;z-index:45;
451
+ display:flex;align-items:flex-start;gap:12px;width:min(420px,calc(100vw - 28px));
452
+ padding:14px;border:1px solid var(--border);border-left:4px solid var(--accent-strong);
453
+ border-radius:8px;background:var(--surface);color:var(--fg);
454
+ box-shadow:0 6px 22px rgba(0,0,0,.25);font-size:14px;line-height:1.5;}
455
+ .export-notification[hidden],.export-notification-path[hidden]{display:none;}
456
+ .export-notification[data-state="error"]{border-left-color:#d13438;}
457
+ .export-notification-content{min-width:0;max-height:min(40vh,320px);overflow:auto;
458
+ overflow-wrap:anywhere;user-select:text;}
459
+ .export-notification-message{margin:0;font-weight:650;}
460
+ .export-notification-path{margin:8px 0 0;}
461
+ .export-notification-path:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;}
462
+ .export-notification>.nav-btn{flex:none;margin-left:auto;}
463
+ body.presenter-mode .export-feedback,
464
+ body.preview-mode .export-feedback,
465
+ body.capture-mode .export-feedback,
466
+ body.pptx-mode .export-feedback,
467
+ body.print-mode .export-feedback{display:none!important;}
450
468
  body.presenter-mode .nav{opacity:0;}
451
469
  body.presenter-mode .nav:hover,body.presenter-mode .nav:focus-within{opacity:1;}
452
470
  body.presenter-mode #navPresent{display:none;}
@@ -697,6 +715,7 @@ body.pptx-slide-artwork-mode .deck:not(.pptx-layout-template)>.theme-cover-logo{
697
715
  /* ===== Headless PDF export ===== */
698
716
  @page{size:13.333333in 7.5in;margin:0;}
699
717
  @media print{
718
+ .export-feedback{display:none!important;}
700
719
  /* The screen rules clip everything to one viewport; printing needs the whole
701
720
  stack of pages to flow, so the scroll containment is lifted here. */
702
721
  html,body{width:1280px;height:auto;overflow:visible;}
@@ -717,6 +736,10 @@ body.pptx-slide-artwork-mode .deck:not(.pptx-layout-template)>.theme-cover-logo{
717
736
  }
718
737
 
719
738
  @media (forced-colors:active){
739
+ .export-notification{background:Canvas;color:CanvasText;border-color:CanvasText;}
740
+ .export-notification[data-state="error"]{border-left-color:CanvasText;}
741
+ .export-notification .nav-btn:focus-visible,
742
+ .export-notification-path:focus-visible{outline-color:Highlight;}
720
743
  body{background:Canvas;color:CanvasText;}
721
744
  .deck{background:Canvas!important;color:CanvasText;border:1px solid CanvasText;}
722
745
  .deck::before{background:Highlight;}