Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/core/handlers/mermaid.ts
6471 views
1
/*
2
* mermaid.ts
3
*
4
* Copyright (C) 2022 Posit Software, PBC
5
*/
6
7
import {
8
LanguageCellHandlerContext,
9
LanguageCellHandlerOptions,
10
LanguageHandler,
11
} from "./types.ts";
12
import { baseHandler, install } from "./base.ts";
13
import { formatResourcePath } from "../resources.ts";
14
import { join } from "../../deno_ral/path.ts";
15
import {
16
isIpynbOutput,
17
isJavascriptCompatible,
18
isLatexOutput,
19
isMarkdownOutput,
20
isRevealjsOutput,
21
} from "../../config/format.ts";
22
import { QuartoMdCell } from "../lib/break-quarto-md.ts";
23
import { asMappedString, mappedConcat } from "../lib/mapped-text.ts";
24
import {
25
fixupAlignment,
26
makeResponsive,
27
resolveSize,
28
setSvgSize,
29
} from "../svg.ts";
30
import {
31
kFigAlign,
32
kFigHeight,
33
kFigResponsive,
34
kFigWidth,
35
kMermaidFormat,
36
} from "../../config/constants.ts";
37
import { Element } from "../deno-dom.ts";
38
import { convertFromYaml } from "../lib/yaml-schema/from-yaml.ts";
39
import { readYamlFromString } from "../yaml.ts";
40
import { pandocHtmlBlock, pandocRawStr } from "../pandoc/codegen.ts";
41
import { LocalizedError } from "../lib/located-error.ts";
42
import { info, warning } from "../../deno_ral/log.ts";
43
import { FormatDependency } from "../../config/types.ts";
44
import { mappedDiff } from "../mapped-text.ts";
45
import { escape } from "../../core/lodash.ts";
46
47
const mermaidHandler: LanguageHandler = {
48
...baseHandler,
49
50
schema() {
51
return Promise.resolve(convertFromYaml(readYamlFromString(`
52
object:
53
properties:
54
mermaid-format:
55
enum: [png, svg, js]
56
theme:
57
anyOf:
58
- null
59
- string
60
`)));
61
},
62
63
type: "cell",
64
stage: "post-engine",
65
66
languageName: "mermaid",
67
languageClass: (options: LanguageCellHandlerOptions) => {
68
if (isMarkdownOutput(options.format, ["gfm"])) {
69
return "mermaid-source"; // sidestep github's in-band signaling of mermaid diagrams
70
} else {
71
return "default"; // no pandoc highlighting yet so we use 'default' to get grey shading
72
}
73
},
74
75
defaultOptions: {
76
echo: false,
77
eval: true,
78
include: true,
79
},
80
81
comment: "%%",
82
83
async cell(
84
handlerContext: LanguageCellHandlerContext,
85
cell: QuartoMdCell, // this has unmerged cell options
86
options: Record<string, unknown>, // this merges default and cell options, we have to be careful.
87
) {
88
const mermaidOpts: Record<string, string> = {};
89
if (
90
typeof handlerContext.options.format.metadata.mermaid === "object" &&
91
handlerContext.options.format.metadata.mermaid
92
) {
93
const { mermaid } = handlerContext.options.format.metadata as {
94
mermaid: Record<string, string>;
95
};
96
if (mermaid.theme) {
97
mermaidOpts.theme = mermaid.theme;
98
} else {
99
mermaidOpts.theme = "neutral";
100
}
101
}
102
103
const cellContent = handlerContext.cellContent(cell);
104
// TODO escaping removes MappedString information.
105
// create puppeteer target page
106
const content = `<html>
107
<head>
108
<script src="./mermaid.min.js"></script>
109
</head>
110
<body>
111
<pre class="mermaid">\n${escape(cellContent.value)}\n</pre>
112
<script>
113
mermaid.initialize(${JSON.stringify(mermaidOpts)});
114
</script>
115
</html>`;
116
const selector = "pre.mermaid svg";
117
const resources: [string, string][] = [[
118
"mermaid.min.js",
119
Deno.readTextFileSync(
120
formatResourcePath("html", join("mermaid", "mermaid.min.js")),
121
),
122
]];
123
124
const setupMermaidSvgJsRuntime = () => {
125
if (handlerContext.getState().hasSetupMermaidSvgJsRuntime) {
126
return;
127
}
128
handlerContext.getState().hasSetupMermaidSvgJsRuntime = true;
129
130
const dep: FormatDependency = {
131
name: "quarto-diagram",
132
scripts: [
133
{
134
name: "mermaid-postprocess-shim.js",
135
path: formatResourcePath(
136
"html",
137
join("mermaid", "mermaid-postprocess-shim.js"),
138
),
139
afterBody: true,
140
},
141
],
142
};
143
handlerContext.addHtmlDependency(dep);
144
};
145
146
const setupMermaidJsRuntime = () => {
147
if (handlerContext.getState().hasSetupMermaidJsRuntime) {
148
return;
149
}
150
handlerContext.getState().hasSetupMermaidJsRuntime = true;
151
152
const jsName =
153
handlerContext.options.context.format.metadata?.["mermaid-debug"]
154
? "mermaid.js"
155
: "mermaid.min.js";
156
157
if (mermaidOpts.theme) {
158
const mermaidMeta: Record<string, string> = {};
159
mermaidMeta["mermaid-theme"] = mermaidOpts.theme;
160
handlerContext.addHtmlDependency({
161
name: "quarto-mermaid-conf",
162
meta: mermaidMeta,
163
});
164
}
165
166
const dep: FormatDependency = {
167
name: "quarto-diagram",
168
scripts: [
169
{
170
name: jsName,
171
path: formatResourcePath("html", join("mermaid", jsName)),
172
},
173
{
174
name: "mermaid-init.js",
175
path: formatResourcePath(
176
"html",
177
join("mermaid", "mermaid-init.js"),
178
),
179
afterBody: true,
180
},
181
],
182
stylesheets: [
183
{
184
name: "mermaid.css",
185
path: formatResourcePath("html", join("mermaid", "mermaid.css")),
186
},
187
],
188
};
189
handlerContext.addHtmlDependency(dep);
190
};
191
192
const makeFigLink = (
193
sourceName: string,
194
width?: number,
195
height?: number,
196
includeCaption?: boolean,
197
) => {
198
const figEnvSpecifier =
199
isLatexOutput(handlerContext.options.format.pandoc)
200
? ` fig-env='${cell.options?.["fig-env"] || "figure"}'`
201
: "";
202
let posSpecifier = "";
203
if (
204
isLatexOutput(handlerContext.options.format.pandoc) &&
205
cell.options?.["fig-pos"] !== false
206
) {
207
const v = Array.isArray(cell.options?.["fig-pos"])
208
? cell.options?.["fig-pos"].join("")
209
: cell.options?.["fig-pos"];
210
posSpecifier = ` fig-pos='${v || "H"}'`;
211
}
212
const idSpecifier = (cell.options?.label && includeCaption)
213
? ` #${cell.options?.label}`
214
: "";
215
const widthSpecifier = width
216
? `width="${Math.round(width * 100) / 100}in"`
217
: "";
218
const heightSpecifier = height
219
? ` height="${Math.round(height * 100) / 100}in"`
220
: "";
221
const captionSpecifier = includeCaption
222
? (cell.options?.["fig-cap"] || "")
223
: "";
224
225
return `\n![${captionSpecifier}](${sourceName}){${widthSpecifier}${heightSpecifier}${posSpecifier}${figEnvSpecifier}${idSpecifier}}\n`;
226
};
227
const responsive = handlerContext.options.context.format.metadata
228
?.[kFigResponsive];
229
230
const makeSvg = async () => {
231
// Extract and process SVG
232
let svg = asMappedString(
233
(await handlerContext.extractHtml({
234
html: content,
235
selector,
236
resources,
237
}))[0],
238
);
239
240
const fixupRevealAlignment = (svg: Element) => {
241
if (isRevealjsOutput(handlerContext.options.context.format.pandoc)) {
242
const align = (options?.[kFigAlign] as string) ?? "center";
243
fixupAlignment(svg, align);
244
}
245
};
246
247
let newId: string | undefined = undefined;
248
const idsToPatch: string[] = [];
249
250
const fixupMermaidSvg = (svg: Element) => {
251
// replace mermaid id with a consistent one.
252
const { baseName: newMermaidId } = handlerContext.uniqueFigureName(
253
"mermaid-figure-",
254
"",
255
);
256
newId = newMermaidId;
257
fixupRevealAlignment(svg);
258
const oldId = svg.getAttribute("id") as string;
259
svg.setAttribute("id", newMermaidId);
260
const style = svg.querySelector("style")!;
261
style.innerHTML = style.innerHTML.replaceAll(oldId, () => newMermaidId);
262
263
for (const defNode of svg.querySelectorAll("defs")) {
264
const defEl = defNode as Element;
265
// because this is a defs node and deno-dom doesn't like non-html elements,
266
// we can't use the standard API
267
const m = defEl.innerHTML.match(/id="([^\"]+)"/);
268
if (m) {
269
const id = m[1];
270
idsToPatch.push(id);
271
}
272
}
273
};
274
275
if (
276
responsive && options[kFigWidth] === undefined &&
277
options[kFigHeight] === undefined
278
) {
279
svg = await makeResponsive(svg, fixupMermaidSvg);
280
} else {
281
svg = await setSvgSize(svg, options, (svg: Element) => {
282
// mermaid comes with too much styling wrt to max width. remove it.
283
svg.removeAttribute("style");
284
285
fixupMermaidSvg(svg);
286
});
287
}
288
289
// This is a preposterously ugly fix to a mermaid issue where
290
// duplicate definition ids are emitted, which causes diagrams to step
291
// on one another's toes.
292
if (idsToPatch.length) {
293
let oldSvgSrc = svg.value;
294
for (const idToPatch of idsToPatch) {
295
const to = `${newId}-${idToPatch}`;
296
// this string substitution is fraught, but I don't know how else to fix the problem.
297
oldSvgSrc = oldSvgSrc.replaceAll(
298
`"${idToPatch}"`,
299
() => `"${to}"`,
300
);
301
oldSvgSrc = oldSvgSrc.replaceAll(
302
`#${idToPatch}`,
303
() => `#${to}`,
304
);
305
}
306
svg = mappedDiff(svg, oldSvgSrc);
307
}
308
309
// For formats that don't support JavaScript runtime (LaTeX/PDF, DOCX, Typst, etc.),
310
// write SVG file directly without postprocess script. This avoids LaTeX compilation
311
// errors from HTML script tags but may result in text clipping in diagrams with
312
// multi-line labels (see https://github.com/quarto-dev/quarto-cli/issues/1622).
313
if (
314
isMarkdownOutput(handlerContext.options.format, ["gfm"]) ||
315
!isJavascriptCompatible(handlerContext.options.format)
316
) {
317
// Emit info message for non-JS formats (excluding GFM which doesn't have the issue)
318
if (!isMarkdownOutput(handlerContext.options.format, ["gfm"])) {
319
let warning = `Using mermaid-format: svg with ${
320
handlerContext.options.format.pandoc.to ?? "non-HTML"
321
} format. Note: diagrams with multi-line text labels may experience clipping`;
322
323
// LaTeX-based formats also require external tooling
324
if (isLatexOutput(handlerContext.options.format.pandoc)) {
325
warning += " and requires external tooling (rsvg-convert or Inkscape)";
326
}
327
328
warning += ". Consider using mermaid-format: png if issues occur.";
329
330
info(warning);
331
}
332
const { sourceName, fullName } = handlerContext
333
.uniqueFigureName(
334
"mermaid-figure-",
335
".svg",
336
);
337
Deno.writeTextFileSync(fullName, svg.value);
338
339
const {
340
widthInInches,
341
heightInInches,
342
} = await resolveSize(svg.value, cell.options ?? {});
343
344
return asMappedString(
345
makeFigLink(sourceName, widthInInches, heightInInches, true),
346
);
347
} else {
348
// For JavaScript-compatible formats, use runtime postprocessing
349
setupMermaidSvgJsRuntime();
350
return this.build(
351
handlerContext,
352
cell,
353
svg,
354
options,
355
undefined,
356
new Set([kFigWidth, kFigHeight, kMermaidFormat]),
357
);
358
}
359
};
360
361
const makePng = async () => {
362
const {
363
filenames: [sourceName],
364
elements: [svgText],
365
} = await handlerContext.createPngsFromHtml({
366
prefix: "mermaid-figure-",
367
selector,
368
count: 1,
369
deviceScaleFactor: Number(options.deviceScaleFactor) || 4,
370
html: content,
371
resources,
372
});
373
374
const {
375
widthInInches,
376
heightInInches,
377
} = await resolveSize(svgText, cell.options ?? {});
378
379
if (isMarkdownOutput(handlerContext.options.format, ["gfm"])) {
380
return asMappedString(makeFigLink(
381
sourceName,
382
widthInInches,
383
heightInInches,
384
true,
385
));
386
} else {
387
return this.build(
388
handlerContext,
389
cell,
390
asMappedString(makeFigLink(
391
sourceName,
392
widthInInches,
393
heightInInches,
394
)),
395
options,
396
undefined,
397
new Set([kFigWidth, kFigHeight, kMermaidFormat]),
398
);
399
}
400
};
401
402
// deno-lint-ignore require-await
403
const makeJs = async () => {
404
setupMermaidJsRuntime();
405
// removed until we use mermaid 10.0.0
406
//
407
// const { baseName: tooltipName } = handlerContext
408
// .uniqueFigureName(
409
// "mermaid-tooltip-",
410
// "",
411
// );
412
const preAttrs = [];
413
if (options.label) {
414
preAttrs.push(`label="${options.label}"`);
415
}
416
const preEl = pandocHtmlBlock("pre")({
417
classes: ["mermaid", "mermaid-js"],
418
attrs: preAttrs,
419
});
420
421
const content = handlerContext.cellContent(cell);
422
preEl.push(pandocRawStr(escape(content.value))); // TODO escaping removes MappedString information.
423
424
const attrs: Record<string, unknown> = {};
425
if (isRevealjsOutput(handlerContext.options.context.format.pandoc)) {
426
attrs.reveal = true;
427
}
428
429
return this.build(
430
handlerContext,
431
cell,
432
mappedConcat([
433
preEl.mappedString(),
434
// tooltips appear to be broken in mermaid 9.2.2?
435
// They don't even work on their website: https://mermaid-js.github.io/mermaid/#/flowchart
436
// we drop them for now.
437
// `\n<div id="${tooltipName}" class="mermaidTooltip"></div>`,
438
]),
439
options,
440
attrs,
441
new Set([kMermaidFormat]),
442
);
443
};
444
445
const makeDefault = async () => {
446
if (isIpynbOutput(handlerContext.options.format.pandoc)) {
447
return await makePng();
448
} else if (isJavascriptCompatible(handlerContext.options.format)) {
449
return await makeJs();
450
} else if (
451
isMarkdownOutput(handlerContext.options.format, ["gfm"])
452
) {
453
return mappedConcat([
454
"\n``` mermaid\n",
455
cellContent,
456
"\n```\n",
457
]);
458
} else {
459
return await makePng();
460
}
461
};
462
463
const format = options[kMermaidFormat] ||
464
handlerContext.options.format.execute[kMermaidFormat];
465
466
if (format === "svg") {
467
return await makeSvg();
468
} else if (format === "png") {
469
return await makePng();
470
} else if (format === "js") {
471
if (!isJavascriptCompatible(handlerContext.options.format)) {
472
const error = new LocalizedError(
473
"IncompatibleOutput",
474
`\`mermaid-format: js\` not supported in format ${
475
handlerContext.options.format.pandoc.to ?? ""
476
}`,
477
cell.sourceVerbatim,
478
0,
479
);
480
warning(error.message);
481
console.log("");
482
return await makeDefault();
483
} else {
484
if (isRevealjsOutput(handlerContext.options.context.format.pandoc)) {
485
const error = new LocalizedError(
486
"NotRecommended",
487
`\`mermaid-format: js\` not recommended in format ${
488
handlerContext.options.format.pandoc.to ?? ""
489
}`,
490
cell.sourceVerbatim,
491
0,
492
);
493
warning(error.message);
494
console.log("");
495
}
496
return await makeJs();
497
}
498
} else {
499
return await makeDefault();
500
}
501
},
502
};
503
504
install(mermaidHandler);
505
506