pi-studio 0.9.36 → 0.9.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.37] — 2026-08-01
8
+
9
+ ### Added
10
+ - Added lazy Lucide and Logos icon-pack support for Mermaid diagrams in live previews, standalone HTML exports, and PDF exports through Mermaid CLI.
11
+ - Added browser regression coverage for real SVG icon rendering and dark/light contrast correction.
12
+
13
+ ### Changed
14
+ - Pinned browser Mermaid rendering to 11.16.0 and pinned the icon-pack data used by browser and PDF exports.
15
+ - Made Mermaid icon paints and node labels meet a 4.5:1 contrast target against their rendered surfaces while retaining readable semantic colours; PDF preprocessing now corrects `classDef`/`style` label colours and targets literal Logos SVG fills over solid custom fills.
16
+ - Resolve Mermaid CLI from `MERMAID_CLI_PATH`, Pi's shared npm binary directory, or `PATH`, in that order.
17
+
18
+ ### Fixed
19
+ - Surface Mermaid icon-pack load failures as visible diagram errors instead of silently rendering empty icon nodes.
20
+
7
21
  ## [0.9.36] — 2026-07-13
8
22
 
9
23
  ### Changed
package/README.md CHANGED
@@ -50,7 +50,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
50
50
  - shows/hides annotation markers in preview
51
51
  - strips markers before send (optional)
52
52
  - saves `.annotated.md`
53
- - Renders Markdown/LaTeX/code previews (math + Mermaid) plus lightweight CSV/TSV table previews, theme-synced with pi, with copy buttons for code blocks and blockquotes
53
+ - Renders Markdown/LaTeX/code previews (math + Mermaid) plus lightweight CSV/TSV table previews, theme-synced with pi, with copy buttons for code blocks and blockquotes; Mermaid previews include Lucide/Logos icon nodes and accessible label contrast over custom fills
54
54
  - Renders straight, unfenced interactive HTML in preview via a sandboxed browser iframe with zoom controls, while fenced `html` blocks remain source code
55
55
  - Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks, with a Focus action for temporarily enlarging the embedded viewer
56
56
  - Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
@@ -122,6 +122,30 @@ caption: Optional caption
122
122
 
123
123
  `path` must point to a local `.pdf` within the current Studio resource directory. Relative paths resolve from the opened document's directory, or from Studio's working dir for non-file-backed content. `page` is an initial page hint for the browser PDF viewer, and `height` controls the embedded frame height in pixels. Use normal Markdown links for PDFs when embedding is not useful.
124
124
 
125
+ ### Mermaid icons
126
+
127
+ Studio's live previews and standalone HTML exports support Mermaid's `lucide:*` and `logos:*` icon nodes. The corresponding Iconify packs load lazily, so Studio does not fetch either pack unless a diagram uses it. Mermaid and the browser icon-pack URLs are pinned to tested versions.
128
+
129
+ ````md
130
+ ```mermaid
131
+ flowchart LR
132
+ source@{ icon: "lucide:file-code-2", form: "rounded", label: "Source", pos: "b", h: 56 }
133
+ github@{ icon: "logos:github-icon", form: "rounded", label: "GitHub", pos: "b", h: 56 }
134
+ source -->|publish| github
135
+ ```
136
+ ````
137
+
138
+ Studio also corrects low-contrast Mermaid icon paints and node labels after browser rendering while preserving authored colours that already meet a 4.5:1 contrast ratio. For PDF output, Studio adds or corrects label colours on `classDef` and `style` declarations with solid hex/RGB fills and supplies targeted icon CSS to Mermaid CLI; this also covers Logos icons whose SVG paths use literal fills.
139
+
140
+ PDF icon nodes require Mermaid CLI 11.12+ so Studio can use pinned icon-pack URLs. Install a current CLI, or set `MERMAID_CLI_PATH` to one:
141
+
142
+ ```bash
143
+ npm install -g @mermaid-js/mermaid-cli@11.16.0
144
+ mmdc --version
145
+ ```
146
+
147
+ Studio only passes icon-pack arguments when a diagram actually references `lucide:` or `logos:`, so ordinary Mermaid PDF diagrams remain compatible with older CLI versions.
148
+
125
149
  ## Notes
126
150
 
127
151
  - Local-only server (`127.0.0.1`) with tokenized Studio URLs.
@@ -135,7 +159,7 @@ caption: Optional caption
135
159
  - `brew install pandoc`
136
160
  - install TeX Live/MacTeX for PDF export
137
161
  - Export subprocess timeouts default to bounded values and can be tuned with `PI_STUDIO_PANDOC_TIMEOUT_MS`, `PI_STUDIO_LATEX_TIMEOUT_MS`, `PI_STUDIO_MERMAID_TIMEOUT_MS`, and `PI_STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES` for unusually large embedded-asset HTML exports.
138
- - Mermaid diagrams in exported PDFs may also require Mermaid CLI (`mmdc` / `@mermaid-js/mermaid-cli`) when you want diagram blocks rendered as diagrams rather than left as code.
162
+ - Mermaid diagrams in exported PDFs require Mermaid CLI (`mmdc` / `@mermaid-js/mermaid-cli`) when you want diagram blocks rendered as diagrams rather than left as code; PDF icon nodes require Mermaid CLI 11.12+.
139
163
 
140
164
  ## License
141
165
 
@@ -2731,7 +2731,10 @@
2731
2731
  throw new Error("Studio annotation helpers failed to load.");
2732
2732
  }
2733
2733
  const EMPTY_OVERLAY_LINE = "\u200b";
2734
- const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
2734
+ const mermaidHelpers = globalThis.PiStudioMermaidHelpers || null;
2735
+ const MERMAID_CDN_URL = mermaidHelpers && mermaidHelpers.MERMAID_CDN_URL
2736
+ ? mermaidHelpers.MERMAID_CDN_URL
2737
+ : "https://cdn.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.esm.min.mjs";
2735
2738
  const MATHJAX_CDN_URL = "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js";
2736
2739
  const PDFJS_CDN_URL = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.min.mjs";
2737
2740
  const PDFJS_WORKER_CDN_URL = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/legacy/build/pdf.worker.min.mjs";
@@ -2749,6 +2752,9 @@
2749
2752
  const PDF_PREVIEW_RENDER_FAIL_MESSAGE = "PDF figure preview could not be rendered.";
2750
2753
  let mermaidModulePromise = null;
2751
2754
  let mermaidInitialized = false;
2755
+ const mermaidIconRegistry = mermaidHelpers && typeof mermaidHelpers.createIconPackRegistry === "function"
2756
+ ? mermaidHelpers.createIconPackRegistry()
2757
+ : null;
2752
2758
  let mathJaxPromise = null;
2753
2759
  let pdfJsPromise = null;
2754
2760
 
@@ -8243,8 +8249,12 @@
8243
8249
  if (!mermaidApi) {
8244
8250
  throw new Error("Mermaid module did not expose a default export.");
8245
8251
  }
8252
+ if (!mermaidHelpers || !mermaidIconRegistry) {
8253
+ throw new Error("Studio Mermaid helpers failed to load.");
8254
+ }
8246
8255
 
8247
8256
  if (!mermaidInitialized) {
8257
+ mermaidIconRegistry.register(mermaidApi);
8248
8258
  mermaidApi.initialize(MERMAID_CONFIG);
8249
8259
  mermaidInitialized = true;
8250
8260
  }
@@ -8274,7 +8284,7 @@
8274
8284
  return;
8275
8285
  }
8276
8286
 
8277
- mermaidBlocks.forEach((preEl) => {
8287
+ const wrappers = Array.from(mermaidBlocks).map((preEl) => {
8278
8288
  const codeEl = preEl.querySelector("code");
8279
8289
  const source = codeEl ? codeEl.textContent : preEl.textContent;
8280
8290
 
@@ -8309,20 +8319,29 @@
8309
8319
  wrapper.appendChild(toolbarEl);
8310
8320
  wrapper.appendChild(diagramEl);
8311
8321
  preEl.replaceWith(wrapper);
8322
+ return wrapper;
8312
8323
  });
8313
8324
 
8314
- const diagramNodes = Array.from(targetEl.querySelectorAll(".mermaid"));
8325
+ const diagramNodes = wrappers
8326
+ .map((wrapper) => wrapper.querySelector(".mermaid"))
8327
+ .filter(Boolean);
8315
8328
  if (diagramNodes.length === 0) return;
8316
8329
 
8317
8330
  try {
8331
+ mermaidIconRegistry.clearError();
8318
8332
  await mermaidApi.run({ nodes: diagramNodes });
8333
+ const iconPackError = mermaidIconRegistry.getError();
8334
+ if (iconPackError) throw iconPackError;
8335
+ mermaidHelpers.applyAccessibleColors(targetEl);
8319
8336
  } catch (error) {
8320
8337
  try {
8321
- await mermaidApi.run();
8322
- } catch (fallbackError) {
8323
- console.error("Mermaid render failed:", fallbackError || error);
8324
- appendMermaidNotice(targetEl, MERMAID_RENDER_FAIL_MESSAGE);
8338
+ mermaidHelpers.applyAccessibleColors(targetEl);
8339
+ } catch (contrastError) {
8340
+ console.warn("Mermaid contrast correction failed after a partial render:", contrastError);
8325
8341
  }
8342
+ mermaidHelpers.renderFailures(wrappers, error);
8343
+ console.error("Mermaid render failed:", error);
8344
+ appendMermaidNotice(targetEl, MERMAID_RENDER_FAIL_MESSAGE);
8326
8345
  }
8327
8346
  }
8328
8347
 
@@ -0,0 +1,267 @@
1
+ (() => {
2
+ const MERMAID_VERSION = "11.16.0";
3
+ const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@" + MERMAID_VERSION + "/dist/mermaid.esm.min.mjs";
4
+ const ICON_PACKS = Object.freeze([
5
+ Object.freeze({
6
+ name: "lucide",
7
+ url: "https://cdn.jsdelivr.net/npm/@iconify-json/lucide@1.2.120/icons.json",
8
+ }),
9
+ Object.freeze({
10
+ name: "logos",
11
+ url: "https://cdn.jsdelivr.net/npm/@iconify-json/logos@1.2.11/icons.json",
12
+ }),
13
+ ]);
14
+ const MINIMUM_TEXT_CONTRAST = 4.5;
15
+
16
+ function normalizeError(error) {
17
+ return error instanceof Error ? error : new Error(String(error));
18
+ }
19
+
20
+ function parseRgb(value) {
21
+ const match = String(value || "").match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/);
22
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
23
+ }
24
+
25
+ function isOpaqueColor(value) {
26
+ if (!parseRgb(value)) return false;
27
+ const alphaMatch = String(value || "").match(/^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/);
28
+ return !alphaMatch || Number(alphaMatch[1]) >= 1;
29
+ }
30
+
31
+ function relativeLuminance(color) {
32
+ const linear = color.map(function(channel) {
33
+ const value = channel / 255;
34
+ return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
35
+ });
36
+ return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
37
+ }
38
+
39
+ function contrastRatio(foreground, background) {
40
+ const foregroundRgb = Array.isArray(foreground) ? foreground : parseRgb(foreground);
41
+ const backgroundRgb = Array.isArray(background) ? background : parseRgb(background);
42
+ if (!foregroundRgb || !backgroundRgb) return 1;
43
+ const lighter = Math.max(relativeLuminance(foregroundRgb), relativeLuminance(backgroundRgb));
44
+ const darker = Math.min(relativeLuminance(foregroundRgb), relativeLuminance(backgroundRgb));
45
+ return (lighter + 0.05) / (darker + 0.05);
46
+ }
47
+
48
+ function toRgb(color) {
49
+ return "rgb(" + color.map(function(channel) { return Math.round(channel); }).join(", ") + ")";
50
+ }
51
+
52
+ function ensureReadableColor(foregroundCss, backgroundCss, minimumContrast) {
53
+ const foreground = parseRgb(foregroundCss);
54
+ const background = parseRgb(backgroundCss);
55
+ const targetContrast = Number.isFinite(minimumContrast) ? minimumContrast : MINIMUM_TEXT_CONTRAST;
56
+ if (!foreground || !background || contrastRatio(foreground, background) >= targetContrast) {
57
+ return foregroundCss;
58
+ }
59
+
60
+ const readableCandidates = [[0, 0, 0], [255, 255, 255]].flatMap(function(target) {
61
+ for (let step = 1; step <= 20; step += 1) {
62
+ const amount = step / 20;
63
+ const color = foreground.map(function(channel, index) {
64
+ return channel + (target[index] - channel) * amount;
65
+ });
66
+ if (contrastRatio(color, background) >= targetContrast) return [{ amount: amount, color: color }];
67
+ }
68
+ return [];
69
+ });
70
+ readableCandidates.sort(function(left, right) { return left.amount - right.amount; });
71
+ if (readableCandidates.length > 0) return toRgb(readableCandidates[0].color);
72
+
73
+ const black = [0, 0, 0];
74
+ const white = [255, 255, 255];
75
+ return toRgb(contrastRatio(black, background) >= contrastRatio(white, background) ? black : white);
76
+ }
77
+
78
+ function createIconPackRegistry(options) {
79
+ const config = options && typeof options === "object" ? options : {};
80
+ const packs = Array.isArray(config.packs) ? config.packs : ICON_PACKS;
81
+ const fetchImpl = typeof config.fetch === "function"
82
+ ? config.fetch
83
+ : (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : null);
84
+ const pending = new Map();
85
+ let loadError = null;
86
+ let registeredApi = null;
87
+
88
+ function load(pack) {
89
+ if (!fetchImpl) {
90
+ const error = new Error("Browser fetch API is unavailable for Mermaid icon packs.");
91
+ loadError = error;
92
+ return Promise.reject(error);
93
+ }
94
+ if (!pending.has(pack.name)) {
95
+ const request = Promise.resolve()
96
+ .then(function() { return fetchImpl(pack.url); })
97
+ .then(function(response) {
98
+ if (!response || !response.ok) {
99
+ const status = response && Number.isFinite(response.status) ? response.status : "unknown";
100
+ throw new Error("Failed to load Mermaid icon pack " + pack.name + ": HTTP " + status);
101
+ }
102
+ return response.json();
103
+ })
104
+ .catch(function(error) {
105
+ const normalized = normalizeError(error);
106
+ loadError = normalized;
107
+ pending.delete(pack.name);
108
+ throw normalized;
109
+ });
110
+ pending.set(pack.name, request);
111
+ }
112
+ return pending.get(pack.name);
113
+ }
114
+
115
+ function register(mermaidApi) {
116
+ if (registeredApi === mermaidApi) return;
117
+ if (!mermaidApi || typeof mermaidApi.registerIconPacks !== "function") {
118
+ throw new Error("This Mermaid build does not support registered icon packs.");
119
+ }
120
+ mermaidApi.registerIconPacks(packs.map(function(pack) {
121
+ return { name: pack.name, loader: function() { return load(pack); } };
122
+ }));
123
+ registeredApi = mermaidApi;
124
+ }
125
+
126
+ return Object.freeze({
127
+ clearError: function() { loadError = null; },
128
+ getError: function() { return loadError; },
129
+ register: register,
130
+ });
131
+ }
132
+
133
+ function getDomContext(root) {
134
+ const documentRef = root && root.ownerDocument
135
+ ? root.ownerDocument
136
+ : (typeof document === "object" ? document : null);
137
+ const windowRef = documentRef && documentRef.defaultView
138
+ ? documentRef.defaultView
139
+ : (typeof window === "object" ? window : null);
140
+ if (!documentRef || !windowRef || typeof windowRef.getComputedStyle !== "function") return null;
141
+ return {
142
+ documentRef: documentRef,
143
+ windowRef: windowRef,
144
+ ElementCtor: windowRef.Element,
145
+ HTMLElementCtor: windowRef.HTMLElement,
146
+ getStyle: windowRef.getComputedStyle.bind(windowRef),
147
+ };
148
+ }
149
+
150
+ function findOpaqueFill(root, context) {
151
+ if (!context || !(root instanceof context.ElementCtor)) return null;
152
+ const shape = Array.from(root.querySelectorAll("rect, polygon, path, circle, ellipse")).find(function(candidate) {
153
+ return isOpaqueColor(context.getStyle(candidate).fill);
154
+ });
155
+ return shape ? context.getStyle(shape).fill : null;
156
+ }
157
+
158
+ function findOpaqueBackground(element, fallback, context) {
159
+ if (!context) return fallback;
160
+ let current = element instanceof context.ElementCtor ? element : null;
161
+ while (current) {
162
+ const background = context.getStyle(current).backgroundColor;
163
+ if (current instanceof context.HTMLElementCtor && isOpaqueColor(background)) return background;
164
+ current = current.parentElement;
165
+ }
166
+ return fallback;
167
+ }
168
+
169
+ function applyAccessibleColors(root) {
170
+ if (!root || typeof root.querySelectorAll !== "function") return { iconNodes: 0, shapeNodes: 0 };
171
+ const context = getDomContext(root);
172
+ if (!context) return { iconNodes: 0, shapeNodes: 0 };
173
+ const body = context.documentRef.body;
174
+ const pageBackground = body ? context.getStyle(body).backgroundColor : "rgb(255, 255, 255)";
175
+ let iconNodes = 0;
176
+ let shapeNodes = 0;
177
+
178
+ root.querySelectorAll(".mermaid-container .icon-shape").forEach(function(node) {
179
+ const icon = node.querySelector("svg");
180
+ if (!icon) return;
181
+ iconNodes += 1;
182
+ const semanticColor = context.getStyle(icon).color;
183
+ const iconSurface = findOpaqueFill(node.firstElementChild, context)
184
+ || findOpaqueBackground(icon, pageBackground, context);
185
+ const iconColor = ensureReadableColor(semanticColor, iconSurface);
186
+ icon.style.setProperty("color", iconColor, "important");
187
+ icon.querySelectorAll("g, path, rect, polygon, circle, ellipse, line, polyline").forEach(function(paintedElement) {
188
+ const paintedStyle = context.getStyle(paintedElement);
189
+ if (isOpaqueColor(paintedStyle.fill)) {
190
+ paintedElement.style.setProperty("fill", ensureReadableColor(paintedStyle.fill, iconSurface), "important");
191
+ }
192
+ if (isOpaqueColor(paintedStyle.stroke)) {
193
+ paintedElement.style.setProperty("stroke", ensureReadableColor(paintedStyle.stroke, iconSurface), "important");
194
+ }
195
+ });
196
+ node.querySelectorAll(".labelBkg, .nodeLabel").forEach(function(label) {
197
+ if (!(label instanceof context.HTMLElementCtor)) return;
198
+ const labelSurface = findOpaqueBackground(label, pageBackground, context);
199
+ const labelColor = ensureReadableColor(semanticColor, labelSurface);
200
+ label.style.setProperty("color", labelColor, "important");
201
+ });
202
+ });
203
+
204
+ root.querySelectorAll(".mermaid-container .node:not(.icon-shape)").forEach(function(node) {
205
+ const shape = Array.from(node.querySelectorAll("rect, polygon, path, circle, ellipse")).find(function(candidate) {
206
+ const fill = context.getStyle(candidate).fill;
207
+ return fill && fill !== "none" && fill !== "rgba(0, 0, 0, 0)";
208
+ });
209
+ if (!shape) return;
210
+ shapeNodes += 1;
211
+ const shapeFill = context.getStyle(shape).fill;
212
+ node.querySelectorAll(".nodeLabel").forEach(function(label) {
213
+ if (!(label instanceof context.HTMLElementCtor)) return;
214
+ const labelColor = ensureReadableColor(context.getStyle(label).color, shapeFill);
215
+ label.style.setProperty("color", labelColor, "important");
216
+ });
217
+ });
218
+
219
+ return { iconNodes: iconNodes, shapeNodes: shapeNodes };
220
+ }
221
+
222
+ function getFailedIconPackName(message) {
223
+ const match = String(message || "").match(/Mermaid icon pack\s+(lucide|logos)\b/i);
224
+ return match ? match[1].toLowerCase() : null;
225
+ }
226
+
227
+ function sourceUsesIconPack(source, packName) {
228
+ if (!packName) return false;
229
+ const escapedName = packName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
230
+ return new RegExp("\\bicon\\s*:\\s*[\\\"']" + escapedName + ":", "i").test(String(source || ""));
231
+ }
232
+
233
+ function renderFailures(wrappers, error) {
234
+ const message = normalizeError(error).message;
235
+ const failedIconPack = getFailedIconPackName(message);
236
+ Array.from(wrappers || []).forEach(function(wrapper) {
237
+ if (!wrapper || typeof wrapper.querySelector !== "function") return;
238
+ const diagram = wrapper.querySelector(".mermaid");
239
+ const documentRef = wrapper.ownerDocument || (typeof document === "object" ? document : null);
240
+ if (!diagram || !documentRef) return;
241
+ const source = wrapper.dataset && typeof wrapper.dataset.mermaidSource === "string"
242
+ ? wrapper.dataset.mermaidSource
243
+ : "";
244
+ const hasRenderedSvg = Boolean(diagram.querySelector("svg"));
245
+ if (hasRenderedSvg && !sourceUsesIconPack(source, failedIconPack)) return;
246
+ const failure = documentRef.createElement("pre");
247
+ failure.className = "mermaid-error";
248
+ failure.setAttribute("role", "alert");
249
+ failure.textContent = "Mermaid render failed: " + message + (source ? "\n\n" + source : "");
250
+ diagram.replaceWith(failure);
251
+ });
252
+ return message;
253
+ }
254
+
255
+ globalThis.PiStudioMermaidHelpers = Object.freeze({
256
+ ICON_PACKS: ICON_PACKS,
257
+ MERMAID_CDN_URL: MERMAID_CDN_URL,
258
+ MERMAID_VERSION: MERMAID_VERSION,
259
+ MINIMUM_TEXT_CONTRAST: MINIMUM_TEXT_CONTRAST,
260
+ applyAccessibleColors: applyAccessibleColors,
261
+ contrastRatio: contrastRatio,
262
+ createIconPackRegistry: createIconPackRegistry,
263
+ ensureReadableColor: ensureReadableColor,
264
+ parseRgb: parseRgb,
265
+ renderFailures: renderFailures,
266
+ });
267
+ })();
package/client/studio.css CHANGED
@@ -2819,6 +2819,18 @@
2819
2819
  height: auto;
2820
2820
  }
2821
2821
 
2822
+ .rendered-markdown .mermaid-error {
2823
+ margin: 0;
2824
+ padding: 12px 14px;
2825
+ border: 1px solid var(--error);
2826
+ border-radius: 8px;
2827
+ background: var(--panel-2);
2828
+ color: var(--error);
2829
+ text-align: left;
2830
+ white-space: pre-wrap;
2831
+ overflow-wrap: anywhere;
2832
+ }
2833
+
2822
2834
  .plain-markdown {
2823
2835
  margin: 0;
2824
2836
  white-space: pre-wrap;
package/index.ts CHANGED
@@ -31,6 +31,11 @@ import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js"
31
31
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
32
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
33
33
  import { renderStudioAnnotationInlineHtml } from "./shared/studio-annotation-render.js";
34
+ import {
35
+ buildStudioMermaidCliIconArgs,
36
+ buildStudioMermaidPdfIconContrastCss,
37
+ ensureStudioMermaidSourceContrast,
38
+ } from "./shared/studio-mermaid.js";
34
39
 
35
40
  type Lens = "writing" | "code";
36
41
  type RequestedLens = Lens | "auto";
@@ -48,6 +53,7 @@ type StudioPiThinkingLevel = ModelThinkingLevel | "max";
48
53
 
49
54
  const STUDIO_CSS_URL = new URL("./client/studio.css", import.meta.url);
50
55
  const STUDIO_ANNOTATION_HELPERS_URL = new URL("./client/studio-annotation-helpers.js", import.meta.url);
56
+ const STUDIO_MERMAID_HELPERS_URL = new URL("./client/studio-mermaid-helpers.js", import.meta.url);
51
57
  const STUDIO_CLIENT_URL = new URL("./client/studio-client.js", import.meta.url);
52
58
 
53
59
  interface StudioServerState {
@@ -644,6 +650,13 @@ const STUDIO_PROMPT_METADATA_CUSTOM_TYPE = "pi-studio/direct-prompt";
644
650
  const STUDIO_DEFAULT_SCRATCHPAD_DOCUMENT_KEY = "doc:blank:blank";
645
651
  const STUDIO_PERSISTENT_STATE_DIR = join(getAgentDir(), "pi-studio");
646
652
  const STUDIO_PERSISTENT_STATE_PATH = join(STUDIO_PERSISTENT_STATE_DIR, "local-state.json");
653
+ const PI_STUDIO_MERMAID_CLI_PATH = join(
654
+ getAgentDir(),
655
+ "npm",
656
+ "node_modules",
657
+ ".bin",
658
+ process.platform === "win32" ? "mmdc.cmd" : "mmdc",
659
+ );
647
660
 
648
661
  type StudioSubprocessResult = {
649
662
  code: number | null;
@@ -5820,14 +5833,25 @@ function getStudioMermaidPdfTheme(): "default" | "forest" | "dark" | "neutral" {
5820
5833
  return "default";
5821
5834
  }
5822
5835
 
5836
+ function getStudioMermaidCliCommand(): string {
5837
+ return process.env.MERMAID_CLI_PATH?.trim()
5838
+ || (existsSync(PI_STUDIO_MERMAID_CLI_PATH) ? PI_STUDIO_MERMAID_CLI_PATH : "mmdc");
5839
+ }
5840
+
5823
5841
  async function renderStudioMermaidDiagramForPdf(source: string, workDir: string, blockNumber: number): Promise<string> {
5824
- const mermaidCommand = process.env.MERMAID_CLI_PATH?.trim() || "mmdc";
5842
+ const mermaidCommand = getStudioMermaidCliCommand();
5825
5843
  const mermaidTheme = getStudioMermaidPdfTheme();
5826
5844
  const inputPath = join(workDir, `mermaid-diagram-${blockNumber}.mmd`);
5827
5845
  const outputPath = join(workDir, `mermaid-diagram-${blockNumber}.pdf`);
5846
+ const iconCssPath = join(workDir, `mermaid-diagram-${blockNumber}.css`);
5828
5847
 
5829
- await writeFile(inputPath, source, "utf-8");
5848
+ const preparedSource = ensureStudioMermaidSourceContrast(source);
5849
+ const iconContrastCss = buildStudioMermaidPdfIconContrastCss(preparedSource);
5850
+ await writeFile(inputPath, preparedSource, "utf-8");
5851
+ if (iconContrastCss) await writeFile(iconCssPath, iconContrastCss, "utf-8");
5830
5852
  const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
5853
+ if (iconContrastCss) args.push("-C", iconCssPath);
5854
+ args.push(...buildStudioMermaidCliIconArgs(preparedSource));
5831
5855
  const result = await runStudioSubprocess(mermaidCommand, args, {
5832
5856
  timeoutMs: STUDIO_MERMAID_TIMEOUT_MS,
5833
5857
  label: "Mermaid CLI",
@@ -6317,10 +6341,18 @@ function buildStudioHtmlMermaidConfig(vars: Record<string, string>): Record<stri
6317
6341
 
6318
6342
  function buildStudioStandaloneHtmlMermaidScript(vars: Record<string, string>): string {
6319
6343
  const mermaidConfigJson = JSON.stringify(buildStudioHtmlMermaidConfig(vars)).replace(/</g, "\\u003c");
6344
+ const mermaidHelpersSource = readFileSync(STUDIO_MERMAID_HELPERS_URL, "utf-8").replace(/<\/script/gi, "<\\/script");
6320
6345
  return `<script>
6346
+ ${mermaidHelpersSource}
6347
+ </script>
6348
+ <script>
6321
6349
  (() => {
6322
- const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
6323
6350
  const MERMAID_CONFIG = ${mermaidConfigJson};
6351
+ const mermaidHelpers = globalThis.PiStudioMermaidHelpers || null;
6352
+
6353
+ function setMermaidRenderResult(status, error) {
6354
+ window.__piStudioMermaidRenderResult = error ? { status, error } : { status };
6355
+ }
6324
6356
 
6325
6357
  function appendMermaidWarning(message) {
6326
6358
  const documentEl = document.querySelector(".studio-export-document") || document.body;
@@ -6332,32 +6364,56 @@ function buildStudioStandaloneHtmlMermaidScript(vars: Record<string, string>): s
6332
6364
  }
6333
6365
 
6334
6366
  function prepareMermaidBlocks() {
6335
- const preBlocks = Array.from(document.querySelectorAll("pre.mermaid"));
6336
- preBlocks.forEach((preEl) => {
6337
- const source = preEl.querySelector("code") ? preEl.querySelector("code").textContent : preEl.textContent;
6367
+ return Array.from(document.querySelectorAll("pre.mermaid")).map((preEl) => {
6368
+ const codeEl = preEl.querySelector("code");
6369
+ const source = codeEl ? codeEl.textContent : preEl.textContent;
6338
6370
  const wrapper = document.createElement("div");
6339
6371
  wrapper.className = "mermaid-container";
6372
+ wrapper.dataset.mermaidSource = source || "";
6340
6373
  const diagramEl = document.createElement("div");
6341
6374
  diagramEl.className = "mermaid";
6342
6375
  diagramEl.textContent = source || "";
6343
6376
  wrapper.appendChild(diagramEl);
6344
6377
  preEl.replaceWith(wrapper);
6378
+ return wrapper;
6345
6379
  });
6346
- return Array.from(document.querySelectorAll(".mermaid"));
6347
6380
  }
6348
6381
 
6349
6382
  async function renderMermaid() {
6350
- const nodes = prepareMermaidBlocks();
6351
- if (nodes.length === 0) return;
6383
+ const wrappers = prepareMermaidBlocks();
6384
+ if (wrappers.length === 0) {
6385
+ setMermaidRenderResult("skipped");
6386
+ return;
6387
+ }
6388
+ setMermaidRenderResult("pending");
6352
6389
  try {
6353
- const module = await import(MERMAID_CDN_URL);
6390
+ if (!mermaidHelpers) throw new Error("Studio Mermaid helpers failed to load.");
6391
+ const module = await import(mermaidHelpers.MERMAID_CDN_URL);
6354
6392
  const mermaidApi = module && module.default ? module.default : null;
6355
6393
  if (!mermaidApi) throw new Error("Mermaid module did not expose a default export.");
6394
+ const iconRegistry = mermaidHelpers.createIconPackRegistry();
6395
+ iconRegistry.register(mermaidApi);
6356
6396
  mermaidApi.initialize(MERMAID_CONFIG);
6357
- await mermaidApi.run({ nodes });
6397
+ iconRegistry.clearError();
6398
+ await mermaidApi.run({ nodes: wrappers.map((wrapper) => wrapper.querySelector(".mermaid")) });
6399
+ const iconPackError = iconRegistry.getError();
6400
+ if (iconPackError) throw iconPackError;
6401
+ mermaidHelpers.applyAccessibleColors(document);
6402
+ setMermaidRenderResult("success");
6358
6403
  } catch (error) {
6404
+ if (mermaidHelpers) {
6405
+ try {
6406
+ mermaidHelpers.applyAccessibleColors(document);
6407
+ } catch (contrastError) {
6408
+ console.warn("Mermaid contrast correction failed after a partial render:", contrastError);
6409
+ }
6410
+ }
6411
+ const message = mermaidHelpers
6412
+ ? mermaidHelpers.renderFailures(wrappers, error)
6413
+ : (error instanceof Error ? error.message : String(error));
6414
+ setMermaidRenderResult("failed", message);
6359
6415
  console.error("Mermaid render failed:", error);
6360
- appendMermaidWarning("Mermaid renderer unavailable. Showing mermaid source text.");
6416
+ appendMermaidWarning("Mermaid render failed. Showing diagram source text.");
6361
6417
  }
6362
6418
  }
6363
6419
 
@@ -10362,6 +10418,7 @@ function buildStudioHtml(
10362
10418
  const cssVarsBlock = Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join("\n");
10363
10419
  const stylesheetHref = `/studio.css?token=${encodeURIComponent(studioToken ?? "")}`;
10364
10420
  const annotationHelpersScriptHref = `/studio-annotation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10421
+ const mermaidHelpersScriptHref = `/studio-mermaid-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10365
10422
  const clientScriptHref = `/studio-client.js?token=${encodeURIComponent(studioToken ?? "")}`;
10366
10423
  const faviconHref = buildStudioFaviconDataUri(style);
10367
10424
  const bootConfigJson = JSON.stringify({ mermaidConfig }).replace(/</g, "\\u003c");
@@ -10778,6 +10835,7 @@ ${cssVarsBlock}
10778
10835
  window.__PI_STUDIO_BOOT__ = ${bootConfigJson};
10779
10836
  </script>
10780
10837
  <script src="${annotationHelpersScriptHref}"></script>
10838
+ <script src="${mermaidHelpersScriptHref}"></script>
10781
10839
  <script src="${clientScriptHref}"></script>
10782
10840
  </body>
10783
10841
  </html>`;
@@ -13962,7 +14020,7 @@ export default function (pi: ExtensionAPI) {
13962
14020
  return;
13963
14021
  }
13964
14022
 
13965
- if (requestUrl.pathname === "/studio-annotation-helpers.js" || requestUrl.pathname === "/studio-client.js") {
14023
+ if (requestUrl.pathname === "/studio-annotation-helpers.js" || requestUrl.pathname === "/studio-mermaid-helpers.js" || requestUrl.pathname === "/studio-client.js") {
13966
14024
  const token = requestUrl.searchParams.get("token") ?? "";
13967
14025
  if (token !== serverState.token) {
13968
14026
  respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
@@ -13978,10 +14036,14 @@ export default function (pi: ExtensionAPI) {
13978
14036
 
13979
14037
  const targetUrl = requestUrl.pathname === "/studio-annotation-helpers.js"
13980
14038
  ? STUDIO_ANNOTATION_HELPERS_URL
13981
- : STUDIO_CLIENT_URL;
14039
+ : requestUrl.pathname === "/studio-mermaid-helpers.js"
14040
+ ? STUDIO_MERMAID_HELPERS_URL
14041
+ : STUDIO_CLIENT_URL;
13982
14042
  const targetLabel = requestUrl.pathname === "/studio-annotation-helpers.js"
13983
14043
  ? "studio annotation helper script"
13984
- : "studio client script";
14044
+ : requestUrl.pathname === "/studio-mermaid-helpers.js"
14045
+ ? "studio Mermaid helper script"
14046
+ : "studio client script";
13985
14047
 
13986
14048
  try {
13987
14049
  const clientScript = readFileSync(targetUrl, "utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.36",
3
+ "version": "0.9.37",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,8 +50,12 @@
50
50
  },
51
51
  "devDependencies": {
52
52
  "@earendil-works/pi-coding-agent": "^0.74.0",
53
+ "@iconify-json/logos": "1.2.11",
54
+ "@iconify-json/lucide": "1.2.120",
53
55
  "@types/node": "^24.3.0",
54
56
  "@types/ws": "^8.18.1",
57
+ "mermaid": "11.16.0",
58
+ "puppeteer-core": "24.22.3",
55
59
  "typescript": "^5.7.3"
56
60
  }
57
61
  }
@@ -0,0 +1,254 @@
1
+ export const STUDIO_MERMAID_ICON_PACKS = Object.freeze([
2
+ Object.freeze({
3
+ name: "lucide",
4
+ packageName: "@iconify-json/lucide",
5
+ url: "https://cdn.jsdelivr.net/npm/@iconify-json/lucide@1.2.120/icons.json",
6
+ }),
7
+ Object.freeze({
8
+ name: "logos",
9
+ packageName: "@iconify-json/logos",
10
+ url: "https://cdn.jsdelivr.net/npm/@iconify-json/logos@1.2.11/icons.json",
11
+ }),
12
+ ]);
13
+
14
+ export function usesSupportedStudioMermaidIconPack(source) {
15
+ return /@\{(?:(?!\}).)*\bicon\s*:\s*["'](?:lucide|logos):[^"']+["'](?:(?!\}).)*\}/is.test(String(source || ""));
16
+ }
17
+
18
+ export function buildStudioMermaidCliIconArgs(source) {
19
+ return usesSupportedStudioMermaidIconPack(source)
20
+ ? [
21
+ "--iconPacksNamesAndUrls",
22
+ ...STUDIO_MERMAID_ICON_PACKS.map((pack) => `${pack.name}#${pack.url}`),
23
+ ]
24
+ : [];
25
+ }
26
+
27
+ function parseStudioMermaidCssColor(value) {
28
+ const source = String(value || "").trim().replace(/\s*!important\s*$/i, "");
29
+ const hex = source.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
30
+ if (hex) {
31
+ const digits = hex[1].length === 3
32
+ ? hex[1].split("").map((digit) => digit + digit).join("")
33
+ : hex[1];
34
+ return [
35
+ Number.parseInt(digits.slice(0, 2), 16),
36
+ Number.parseInt(digits.slice(2, 4), 16),
37
+ Number.parseInt(digits.slice(4, 6), 16),
38
+ ];
39
+ }
40
+ const rgb = source.match(/^rgb\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/i);
41
+ return rgb
42
+ ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])].map((channel) => Math.max(0, Math.min(255, channel)))
43
+ : null;
44
+ }
45
+
46
+ function studioMermaidRelativeLuminance(color) {
47
+ const linear = color.map((channel) => {
48
+ const value = channel / 255;
49
+ return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
50
+ });
51
+ return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
52
+ }
53
+
54
+ function studioMermaidContrastRatio(foreground, background) {
55
+ const lighter = Math.max(studioMermaidRelativeLuminance(foreground), studioMermaidRelativeLuminance(background));
56
+ const darker = Math.min(studioMermaidRelativeLuminance(foreground), studioMermaidRelativeLuminance(background));
57
+ return (lighter + 0.05) / (darker + 0.05);
58
+ }
59
+
60
+ function formatStudioMermaidHexColor(color) {
61
+ return `#${color.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`;
62
+ }
63
+
64
+ function ensureStudioMermaidReadableColor(foreground, background) {
65
+ if (studioMermaidContrastRatio(foreground, background) >= 4.5) return foreground;
66
+ const candidates = [[0, 0, 0], [255, 255, 255]].flatMap((target) => {
67
+ for (let step = 1; step <= 20; step += 1) {
68
+ const amount = step / 20;
69
+ const color = foreground.map((channel, index) => channel + (target[index] - channel) * amount);
70
+ if (studioMermaidContrastRatio(color, background) >= 4.5) return [{ amount, color }];
71
+ }
72
+ return [];
73
+ });
74
+ candidates.sort((left, right) => left.amount - right.amount);
75
+ if (candidates.length > 0) return candidates[0].color;
76
+ const black = [0, 0, 0];
77
+ const white = [255, 255, 255];
78
+ return studioMermaidContrastRatio(black, background) >= studioMermaidContrastRatio(white, background)
79
+ ? black
80
+ : white;
81
+ }
82
+
83
+ function splitStudioMermaidStyleDeclarations(source) {
84
+ source = String(source || "");
85
+ const declarations = [];
86
+ let start = 0;
87
+ let depth = 0;
88
+ for (let index = 0; index < source.length; index += 1) {
89
+ const char = source[index];
90
+ if (char === "(") depth += 1;
91
+ if (char === ")" && depth > 0) depth -= 1;
92
+ if ((char === "," || char === ";") && depth === 0) {
93
+ const declaration = source.slice(start, index).trim();
94
+ if (declaration) declarations.push(declaration);
95
+ start = index + 1;
96
+ }
97
+ }
98
+ const tail = source.slice(start).trim();
99
+ if (tail) declarations.push(tail);
100
+ return declarations;
101
+ }
102
+
103
+ function getStudioMermaidStyleDeclaration(declarations, property) {
104
+ const propertyLower = property.toLowerCase();
105
+ return declarations.find((declaration) => {
106
+ const separator = declaration.indexOf(":");
107
+ return separator >= 0 && declaration.slice(0, separator).trim().toLowerCase() === propertyLower;
108
+ }) || null;
109
+ }
110
+
111
+ function getStudioMermaidStyleValue(declaration) {
112
+ if (!declaration) return "";
113
+ const separator = declaration.indexOf(":");
114
+ return separator >= 0 ? declaration.slice(separator + 1).trim() : "";
115
+ }
116
+
117
+ function upsertStudioMermaidStyleDeclaration(declarations, property, value) {
118
+ const existing = getStudioMermaidStyleDeclaration(declarations, property);
119
+ const corrected = `${property}:${value}`;
120
+ if (existing) {
121
+ declarations[declarations.indexOf(existing)] = corrected;
122
+ } else {
123
+ declarations.push(corrected);
124
+ }
125
+ }
126
+
127
+ function correctStudioMermaidStyleContrast(styleSource, options = {}) {
128
+ const hadSemicolon = /;\s*$/.test(styleSource);
129
+ const declarations = splitStudioMermaidStyleDeclarations(styleSource);
130
+ const fillDeclaration = getStudioMermaidStyleDeclaration(declarations, "fill");
131
+ const background = parseStudioMermaidCssColor(getStudioMermaidStyleValue(fillDeclaration));
132
+ if (!background) return styleSource;
133
+
134
+ const colorDeclaration = getStudioMermaidStyleDeclaration(declarations, "color");
135
+ const authoredForeground = colorDeclaration
136
+ ? parseStudioMermaidCssColor(getStudioMermaidStyleValue(colorDeclaration))
137
+ : null;
138
+ if (colorDeclaration && !authoredForeground) return styleSource;
139
+
140
+ const fallbackForeground = studioMermaidContrastRatio([0, 0, 0], background) >= studioMermaidContrastRatio([255, 255, 255], background)
141
+ ? [0, 0, 0]
142
+ : [255, 255, 255];
143
+ const readable = ensureStudioMermaidReadableColor(authoredForeground || fallbackForeground, background);
144
+ upsertStudioMermaidStyleDeclaration(declarations, "color", formatStudioMermaidHexColor(readable));
145
+
146
+ if (options.correctIconStroke) {
147
+ const strokeDeclaration = getStudioMermaidStyleDeclaration(declarations, "stroke");
148
+ const authoredStroke = parseStudioMermaidCssColor(getStudioMermaidStyleValue(strokeDeclaration));
149
+ if (!strokeDeclaration || authoredStroke) {
150
+ const readableStroke = ensureStudioMermaidReadableColor(authoredStroke || readable, background);
151
+ upsertStudioMermaidStyleDeclaration(declarations, "stroke", formatStudioMermaidHexColor(readableStroke));
152
+ }
153
+ }
154
+
155
+ return declarations.join(",") + (hadSemicolon ? ";" : "");
156
+ }
157
+
158
+ function collectStudioMermaidIconStyleTargets(source) {
159
+ const iconNodeIds = new Set();
160
+ const iconNodePattern = /\b([A-Za-z_][\w-]*)\s*@\{(?:(?!\}).)*\bicon\s*:\s*["'](?:lucide|logos):[^"']+["'](?:(?!\}).)*\}/gis;
161
+ let iconNodeMatch;
162
+ while ((iconNodeMatch = iconNodePattern.exec(source)) !== null) {
163
+ iconNodeIds.add(iconNodeMatch[1]);
164
+ }
165
+
166
+ const iconClassNames = new Set();
167
+ const classNamesByNode = new Map();
168
+ for (const line of source.split(/\r?\n/)) {
169
+ const classMatch = line.match(/^\s*class\s+(\S+)\s+(\S+)\s*;?\s*$/i);
170
+ if (!classMatch) continue;
171
+ const nodeIds = classMatch[1].split(",").map((value) => value.trim()).filter(Boolean);
172
+ const classNames = classMatch[2].replace(/;$/, "").split(",").map((value) => value.trim()).filter(Boolean);
173
+ nodeIds.forEach((nodeId) => {
174
+ const assigned = classNamesByNode.get(nodeId) || [];
175
+ classNamesByNode.set(nodeId, [...assigned, ...classNames]);
176
+ });
177
+ if (!nodeIds.some((nodeId) => iconNodeIds.has(nodeId))) continue;
178
+ classNames.forEach((className) => {
179
+ iconClassNames.add(className);
180
+ });
181
+ }
182
+ return { iconNodeIds, iconClassNames, classNamesByNode };
183
+ }
184
+
185
+ export function ensureStudioMermaidSourceContrast(source) {
186
+ const text = String(source || "");
187
+ const { iconNodeIds, iconClassNames } = collectStudioMermaidIconStyleTargets(text);
188
+ return text
189
+ .split(/(\r?\n)/)
190
+ .map((part) => {
191
+ if (/^\r?\n$/.test(part)) return part;
192
+ const match = part.match(/^(\s*)(classDef|style)\s+(\S+)\s+(.*?)(\s*)$/i);
193
+ if (!match) return part;
194
+ const targets = match[3].replace(/;$/, "").split(",").map((value) => value.trim()).filter(Boolean);
195
+ const correctIconStroke = match[2].toLowerCase() === "classdef"
196
+ ? targets.some((target) => iconClassNames.has(target))
197
+ : targets.some((target) => iconNodeIds.has(target));
198
+ const correctedStyles = correctStudioMermaidStyleContrast(match[4], { correctIconStroke });
199
+ return `${match[1]}${match[2]} ${match[3]} ${correctedStyles}${match[5]}`;
200
+ })
201
+ .join("");
202
+ }
203
+
204
+ function collectStudioMermaidStylesByTarget(source, directiveName) {
205
+ const stylesByTarget = new Map();
206
+ for (const line of source.split(/\r?\n/)) {
207
+ const match = line.match(/^\s*(classDef|style)\s+(\S+)\s+(.*?)\s*$/i);
208
+ if (!match || match[1].toLowerCase() !== directiveName.toLowerCase()) continue;
209
+ const targets = match[2].replace(/;$/, "").split(",").map((value) => value.trim()).filter(Boolean);
210
+ targets.forEach((target) => stylesByTarget.set(target, match[3]));
211
+ }
212
+ return stylesByTarget;
213
+ }
214
+
215
+ function getStudioMermaidIconPaintColor(styleSource) {
216
+ const declarations = splitStudioMermaidStyleDeclarations(styleSource);
217
+ const fill = parseStudioMermaidCssColor(getStudioMermaidStyleValue(getStudioMermaidStyleDeclaration(declarations, "fill")));
218
+ if (!fill) return null;
219
+ const stroke = parseStudioMermaidCssColor(getStudioMermaidStyleValue(getStudioMermaidStyleDeclaration(declarations, "stroke")));
220
+ const color = parseStudioMermaidCssColor(getStudioMermaidStyleValue(getStudioMermaidStyleDeclaration(declarations, "color")));
221
+ return stroke || color ? formatStudioMermaidHexColor(stroke || color) : null;
222
+ }
223
+
224
+ function buildStudioMermaidIconPaintRule(selector, color) {
225
+ const paintedElements = ["g", "path", "rect", "polygon", "circle", "ellipse", "line", "polyline"];
226
+ const fillSelectors = paintedElements.map((element) => `${selector} svg ${element}[fill]:not([fill="none"])`).join(",\n");
227
+ const strokeSelectors = paintedElements.map((element) => `${selector} svg ${element}[stroke]:not([stroke="none"])`).join(",\n");
228
+ return [
229
+ `${selector} svg { color: ${color} !important; }`,
230
+ `${fillSelectors} { fill: ${color} !important; }`,
231
+ `${strokeSelectors} { stroke: ${color} !important; }`,
232
+ ].join("\n");
233
+ }
234
+
235
+ export function buildStudioMermaidPdfIconContrastCss(source) {
236
+ const preparedSource = ensureStudioMermaidSourceContrast(source);
237
+ const { iconNodeIds, classNamesByNode } = collectStudioMermaidIconStyleTargets(preparedSource);
238
+ if (iconNodeIds.size === 0) return "";
239
+ const classStyles = collectStudioMermaidStylesByTarget(preparedSource, "classDef");
240
+ const directStyles = collectStudioMermaidStylesByTarget(preparedSource, "style");
241
+ const rulesBySelector = new Map();
242
+
243
+ for (const iconNodeId of iconNodeIds) {
244
+ const classNames = classNamesByNode.get(iconNodeId) || [];
245
+ const classPaint = [...classNames].reverse()
246
+ .map((className) => getStudioMermaidIconPaintColor(classStyles.get(className)))
247
+ .find(Boolean);
248
+ const directPaint = getStudioMermaidIconPaintColor(directStyles.get(iconNodeId));
249
+ const paint = directPaint || classPaint;
250
+ if (paint) rulesBySelector.set(`.icon-shape[id*="flowchart-${iconNodeId}-"]`, paint);
251
+ }
252
+
253
+ return Array.from(rulesBySelector, ([selector, color]) => buildStudioMermaidIconPaintRule(selector, color)).join("\n");
254
+ }