pi-studio 0.9.36 → 0.9.38
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 +19 -0
- package/README.md +26 -2
- package/client/studio-client.js +26 -7
- package/client/studio-mermaid-helpers.js +267 -0
- package/client/studio.css +12 -0
- package/index.ts +77 -15
- package/package.json +5 -1
- package/shared/studio-mermaid.js +290 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.38] — 2026-08-01
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Preserved corrected Mermaid node colours during Chromium PDF printing and gave external icon-node labels a theme-aware backdrop, preventing washed-out labels beside custom icon fills.
|
|
11
|
+
|
|
12
|
+
## [0.9.37] — 2026-08-01
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Added lazy Lucide and Logos icon-pack support for Mermaid diagrams in live previews, standalone HTML exports, and PDF exports through Mermaid CLI.
|
|
16
|
+
- Added browser regression coverage for real SVG icon rendering and dark/light contrast correction.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Pinned browser Mermaid rendering to 11.16.0 and pinned the icon-pack data used by browser and PDF exports.
|
|
20
|
+
- 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.
|
|
21
|
+
- Resolve Mermaid CLI from `MERMAID_CLI_PATH`, Pi's shared npm binary directory, or `PATH`, in that order.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Surface Mermaid icon-pack load failures as visible diagram errors instead of silently rendering empty icon nodes.
|
|
25
|
+
|
|
7
26
|
## [0.9.36] — 2026-07-13
|
|
8
27
|
|
|
9
28
|
### 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
|
|
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
|
|
package/client/studio-client.js
CHANGED
|
@@ -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
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
8322
|
-
} catch (
|
|
8323
|
-
console.
|
|
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 =
|
|
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 contrastCssPath = join(workDir, `mermaid-diagram-${blockNumber}.css`);
|
|
5828
5847
|
|
|
5829
|
-
|
|
5848
|
+
const preparedSource = ensureStudioMermaidSourceContrast(source);
|
|
5849
|
+
const contrastCss = buildStudioMermaidPdfIconContrastCss(preparedSource, { theme: mermaidTheme });
|
|
5850
|
+
await writeFile(inputPath, preparedSource, "utf-8");
|
|
5851
|
+
if (contrastCss) await writeFile(contrastCssPath, contrastCss, "utf-8");
|
|
5830
5852
|
const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
|
|
5853
|
+
if (contrastCss) args.push("-C", contrastCssPath);
|
|
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
|
-
|
|
6336
|
-
|
|
6337
|
-
const source =
|
|
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
|
|
6351
|
-
if (
|
|
6383
|
+
const wrappers = prepareMermaidBlocks();
|
|
6384
|
+
if (wrappers.length === 0) {
|
|
6385
|
+
setMermaidRenderResult("skipped");
|
|
6386
|
+
return;
|
|
6387
|
+
}
|
|
6388
|
+
setMermaidRenderResult("pending");
|
|
6352
6389
|
try {
|
|
6353
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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.
|
|
3
|
+
"version": "0.9.38",
|
|
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,290 @@
|
|
|
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
|
+
function buildStudioMermaidPdfPrintColorRule() {
|
|
236
|
+
return [
|
|
237
|
+
".node, .node *, .icon-shape, .icon-shape * {",
|
|
238
|
+
" -webkit-print-color-adjust: exact !important;",
|
|
239
|
+
" print-color-adjust: exact !important;",
|
|
240
|
+
"}",
|
|
241
|
+
].join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function buildStudioMermaidPdfIconLabelRule(selectors, theme) {
|
|
245
|
+
if (selectors.length === 0) return "";
|
|
246
|
+
const darkTheme = String(theme || "default").toLowerCase() === "dark";
|
|
247
|
+
const background = darkTheme ? "#1f2937" : "#ffffff";
|
|
248
|
+
const foreground = darkTheme ? "#ffffff" : "#000000";
|
|
249
|
+
const backgroundSelectors = selectors.map((selector) => `${selector} .labelBkg`).join(",\n");
|
|
250
|
+
const textSelectors = selectors.flatMap((selector) => [
|
|
251
|
+
`${selector} .nodeLabel`,
|
|
252
|
+
`${selector} .nodeLabel *`,
|
|
253
|
+
]).join(",\n");
|
|
254
|
+
return [
|
|
255
|
+
`${backgroundSelectors} { background-color: ${background} !important; }`,
|
|
256
|
+
`${textSelectors} {`,
|
|
257
|
+
` color: ${foreground} !important;`,
|
|
258
|
+
` fill: ${foreground} !important;`,
|
|
259
|
+
` -webkit-text-fill-color: ${foreground} !important;`,
|
|
260
|
+
" opacity: 1 !important;",
|
|
261
|
+
"}",
|
|
262
|
+
].join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function buildStudioMermaidPdfIconContrastCss(source, options = {}) {
|
|
266
|
+
const preparedSource = ensureStudioMermaidSourceContrast(source);
|
|
267
|
+
const { iconNodeIds, classNamesByNode } = collectStudioMermaidIconStyleTargets(preparedSource);
|
|
268
|
+
const printColorRule = buildStudioMermaidPdfPrintColorRule();
|
|
269
|
+
if (iconNodeIds.size === 0) return printColorRule;
|
|
270
|
+
const classStyles = collectStudioMermaidStylesByTarget(preparedSource, "classDef");
|
|
271
|
+
const directStyles = collectStudioMermaidStylesByTarget(preparedSource, "style");
|
|
272
|
+
const iconSelectors = Array.from(iconNodeIds, (iconNodeId) => `.icon-shape[id*="flowchart-${iconNodeId}-"]`);
|
|
273
|
+
const rulesBySelector = new Map();
|
|
274
|
+
|
|
275
|
+
for (const iconNodeId of iconNodeIds) {
|
|
276
|
+
const classNames = classNamesByNode.get(iconNodeId) || [];
|
|
277
|
+
const classPaint = [...classNames].reverse()
|
|
278
|
+
.map((className) => getStudioMermaidIconPaintColor(classStyles.get(className)))
|
|
279
|
+
.find(Boolean);
|
|
280
|
+
const directPaint = getStudioMermaidIconPaintColor(directStyles.get(iconNodeId));
|
|
281
|
+
const paint = directPaint || classPaint;
|
|
282
|
+
if (paint) rulesBySelector.set(`.icon-shape[id*="flowchart-${iconNodeId}-"]`, paint);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return [
|
|
286
|
+
printColorRule,
|
|
287
|
+
buildStudioMermaidPdfIconLabelRule(iconSelectors, options.theme),
|
|
288
|
+
...Array.from(rulesBySelector, ([selector, color]) => buildStudioMermaidIconPaintRule(selector, color)),
|
|
289
|
+
].filter(Boolean).join("\n");
|
|
290
|
+
}
|