@imontene/mdrender 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/options.js CHANGED
@@ -5,7 +5,9 @@ import { DEFAULT_LANG } from "./i18n.js";
5
5
  export const PAGE_SIZES = ["A3", "A4", "A5", "Letter", "Legal", "Tabloid"];
6
6
  export const ORIENTATIONS = ["portrait", "landscape"];
7
7
  export const MERMAID_THEMES = ["default", "neutral", "dark", "forest", "base"];
8
+ export const MERMAID_FLIPS = ["auto", "never"];
8
9
  export const BUILTIN_THEMES = ["light", "dark"];
10
+ export const TEXT_FONTS = ["manrope", "inter"];
9
11
  export const DENSITIES = ["normal", "compact"];
10
12
  export const DEFAULT_OPTIONS = {
11
13
  pageSize: "Letter",
@@ -14,6 +16,9 @@ export const DEFAULT_OPTIONS = {
14
16
  toc: false,
15
17
  tocDepth: 3,
16
18
  pageNumbers: true,
19
+ mermaidAutofit: true,
20
+ mermaidFlip: "auto",
21
+ font: "manrope",
17
22
  density: "normal",
18
23
  theme: "light",
19
24
  css: [],
@@ -110,6 +115,9 @@ const VALIDATORS = {
110
115
  header: text,
111
116
  footer: text,
112
117
  mermaidTheme: oneOf(MERMAID_THEMES),
118
+ mermaidAutofit: bool,
119
+ mermaidFlip: oneOf(MERMAID_FLIPS),
120
+ font: oneOf(TEXT_FONTS),
113
121
  density: oneOf(DENSITIES),
114
122
  fontSize,
115
123
  theme: (value, describe, baseDir) => {
@@ -1,10 +1,24 @@
1
- /** Inter Regular alone, for page headers/footers (Chrome renders them apart from the page). */
2
- export declare function interRegularFontFace(): string;
1
+ import type { TextFont } from "../options.js";
2
+ /**
3
+ * Static (non-variable) font files are used on purpose: Chromium embeds variable fonts in PDFs
4
+ * as Type 3 fonts, while static fonts are embedded as proper TrueType fonts.
5
+ *
6
+ * Text: Manrope (default) from Fontsource, or Inter 4 from the upstream release (full glyph
7
+ * set). Manrope covers Latin, Greek, Cyrillic and Vietnamese but has no italics (the browser
8
+ * slants it) nor arrows and math symbols such as → ✓ ≤: those fall back to Inter, which is
9
+ * embedded too in its regular and bold weights.
10
+ * Code: JetBrains Mono from Fontsource (split into unicode-range subsets).
11
+ */
12
+ export declare const FONT_FAMILIES: Record<TextFont, string>;
13
+ /** CSS font-family list for the text: the chosen font, Inter for missing glyphs, the system's. */
14
+ export declare function fontStack(font: TextFont): string;
3
15
  /**
4
16
  * Inline a Fontsource stylesheet: the woff2 source becomes a data URI (the legacy woff fallback is
5
17
  * dropped) so the document needs no network or file access. `font-display: block` makes the
6
18
  * browser wait for the real font instead of printing with a fallback.
7
19
  */
8
20
  export declare function inlineFontStylesheet(cssKey: string): string;
9
- /** @font-face rules for every bundled font, with the font files embedded. */
10
- export declare function fontFaceCss(): string;
21
+ /** @font-face rules for every font a document uses, with the font files embedded. */
22
+ export declare function fontFaceCss(font?: TextFont): string;
23
+ /** The text font's regular weight alone, for page headers/footers (rendered apart from the page). */
24
+ export declare function regularFontFace(font?: TextFont): string;
@@ -1,11 +1,21 @@
1
- import { INTER_DIR, INTER_FACES, MONO_STYLESHEETS, readAsset, readTextAsset, woff2References, } from "../assets.js";
1
+ import { INTER_DIR, INTER_FACES, MANROPE_STYLESHEETS, MONO_STYLESHEETS, readAsset, readTextAsset, woff2References, } from "../assets.js";
2
2
  /**
3
3
  * Static (non-variable) font files are used on purpose: Chromium embeds variable fonts in PDFs
4
4
  * as Type 3 fonts, while static fonts are embedded as proper TrueType fonts.
5
5
  *
6
- * Text: Inter 4 (the typeface served by Google Fonts) from the upstream release, full glyph set.
6
+ * Text: Manrope (default) from Fontsource, or Inter 4 from the upstream release (full glyph
7
+ * set). Manrope covers Latin, Greek, Cyrillic and Vietnamese but has no italics (the browser
8
+ * slants it) nor arrows and math symbols such as → ✓ ≤: those fall back to Inter, which is
9
+ * embedded too in its regular and bold weights.
7
10
  * Code: JetBrains Mono from Fontsource (split into unicode-range subsets).
8
11
  */
12
+ export const FONT_FAMILIES = { manrope: "Manrope", inter: "Inter" };
13
+ /** CSS font-family list for the text: the chosen font, Inter for missing glyphs, the system's. */
14
+ export function fontStack(font) {
15
+ return font === "inter"
16
+ ? `"Inter", system-ui, sans-serif`
17
+ : `"Manrope", "Inter", system-ui, sans-serif`;
18
+ }
9
19
  function woff2DataUri(key) {
10
20
  return `data:font/woff2;base64,${readAsset(key).toString("base64")}`;
11
21
  }
@@ -18,12 +28,6 @@ function interFace({ weight, style, file }) {
18
28
  src: url(${woff2DataUri(`${INTER_DIR}/${file}`)}) format("woff2");
19
29
  }`;
20
30
  }
21
- let regularFace;
22
- /** Inter Regular alone, for page headers/footers (Chrome renders them apart from the page). */
23
- export function interRegularFontFace() {
24
- regularFace ??= interFace(INTER_FACES[0]);
25
- return regularFace;
26
- }
27
31
  /**
28
32
  * Inline a Fontsource stylesheet: the woff2 source becomes a data URI (the legacy woff fallback is
29
33
  * dropped) so the document needs no network or file access. `font-display: block` makes the
@@ -38,10 +42,39 @@ export function inlineFontStylesheet(cssKey) {
38
42
  .replace(/,\s*url\([^)]+\.woff\)\s*format\(['"]woff['"]\)/g, "")
39
43
  .replace(/url\((['"]?)([^)'"]+\.woff2)\1\)/g, () => `url(${woff2DataUri(fonts[index++])})`);
40
44
  }
41
- let cached;
42
- /** @font-face rules for every bundled font, with the font files embedded. */
43
- export function fontFaceCss() {
44
- cached ??= [...INTER_FACES.map(interFace), ...MONO_STYLESHEETS.map(inlineFontStylesheet)].join("\n");
45
- return cached;
45
+ const interUpright = (weight) => INTER_FACES.find((face) => face.weight === weight && face.style === "normal");
46
+ /** @font-face rules for the text font and its fallback, with the font files embedded. */
47
+ function textFaces(font) {
48
+ if (font === "inter")
49
+ return INTER_FACES.map(interFace);
50
+ return [
51
+ ...MANROPE_STYLESHEETS.map(inlineFontStylesheet),
52
+ // Fallback for glyphs Manrope lacks; 600 text takes the nearest heavier face, 700.
53
+ interFace(interUpright(400)),
54
+ interFace(interUpright(700)),
55
+ ];
56
+ }
57
+ const cached = new Map();
58
+ /** @font-face rules for every font a document uses, with the font files embedded. */
59
+ export function fontFaceCss(font = "manrope") {
60
+ let css = cached.get(font);
61
+ if (css === undefined) {
62
+ css = [...textFaces(font), ...MONO_STYLESHEETS.map(inlineFontStylesheet)].join("\n");
63
+ cached.set(font, css);
64
+ }
65
+ return css;
66
+ }
67
+ const regularFaces = new Map();
68
+ /** The text font's regular weight alone, for page headers/footers (rendered apart from the page). */
69
+ export function regularFontFace(font = "manrope") {
70
+ let css = regularFaces.get(font);
71
+ if (css === undefined) {
72
+ css =
73
+ font === "inter"
74
+ ? interFace(interUpright(400))
75
+ : [inlineFontStylesheet(MANROPE_STYLESHEETS[0]), interFace(interUpright(400))].join("\n");
76
+ regularFaces.set(font, css);
77
+ }
78
+ return css;
46
79
  }
47
80
  //# sourceMappingURL=fonts.js.map
@@ -1,3 +1,4 @@
1
+ import type { TextFont } from "../options.js";
1
2
  /**
2
3
  * Header/footer text with placeholders, in up to three columns separated by `|`:
3
4
  *
@@ -13,10 +14,10 @@ export interface HeaderFooterValues {
13
14
  }
14
15
  /**
15
16
  * Chrome header/footer template. Chrome renders it apart from the page, so it carries its own
16
- * styles and its own copy of Inter.
17
+ * styles and its own copy of the text font.
17
18
  */
18
19
  export declare function headerFooterTemplate(spec: string | undefined, values: HeaderFooterValues, horizontalMargin: {
19
20
  left: string;
20
21
  right: string;
21
- }): string;
22
+ }, font?: TextFont): string;
22
23
  export declare const DEFAULT_FOOTER = "{page} / {pages}";
@@ -1,5 +1,5 @@
1
1
  import { escapeHtml } from "../html.js";
2
- import { interRegularFontFace } from "./fonts.js";
2
+ import { fontStack, regularFontFace } from "./fonts.js";
3
3
  const CHROME_CLASSES = { page: "pageNumber", pages: "totalPages" };
4
4
  function renderText(text, values) {
5
5
  return escapeHtml(text.trim()).replace(/\{(\w+)\}/g, (match, name) => {
@@ -21,15 +21,15 @@ function columns(spec) {
21
21
  }
22
22
  /**
23
23
  * Chrome header/footer template. Chrome renders it apart from the page, so it carries its own
24
- * styles and its own copy of Inter.
24
+ * styles and its own copy of the text font.
25
25
  */
26
- export function headerFooterTemplate(spec, values, horizontalMargin) {
26
+ export function headerFooterTemplate(spec, values, horizontalMargin, font = "manrope") {
27
27
  if (!spec)
28
28
  return "<span></span>";
29
29
  const [left, center, right] = columns(spec).map((c) => renderText(c, values));
30
- return `<style>${interRegularFontFace()}</style>
30
+ return `<style>${regularFontFace(font)}</style>
31
31
  <div style="box-sizing: border-box; width: 100%; display: flex; padding: 0 ${horizontalMargin.right} 0 ${horizontalMargin.left};
32
- font-family: Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 8pt; color: #59636e;">
32
+ font-family: ${fontStack(font).replaceAll('"', "'")}; font-size: 8pt; color: #59636e;">
33
33
  <span style="flex: 1; text-align: left;">${left}</span>
34
34
  <span style="flex: 1; text-align: center;">${center}</span>
35
35
  <span style="flex: 1; text-align: right;">${right}</span>
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Autofit for Mermaid flowcharts: pick, among several layouts of the same diagram, the one whose
3
+ * text prints largest once the diagram is scaled to the text column and the page height.
4
+ *
5
+ * Mermaid wraps label lines at `flowchart.wrappingWidth`, and a flowchart laid out along one axis
6
+ * can be far wider (or taller) than the page; the theme then shrinks the whole drawing, text
7
+ * included. Wider wrapping makes tall diagrams shorter; turning a long left-to-right chain
8
+ * top-to-bottom makes it fit the column. Neither has one value that suits every diagram, so each
9
+ * flowchart is rendered a few times and measured. Everything here is pure; the rendering loop is
10
+ * in render/mermaid.ts.
11
+ */
12
+ export declare const FLOW_DIRECTIONS: readonly ["TB", "TD", "BT", "LR", "RL"];
13
+ export type FlowDirection = (typeof FLOW_DIRECTIONS)[number];
14
+ /** Label wrapping widths tried, in px; undefined is Mermaid's own default (the layout as today). */
15
+ export declare const WRAPPING_WIDTHS: readonly (number | undefined)[];
16
+ /** Candidates within this scale of the best one read the same; the shorter one wins. */
17
+ export declare const SCALE_TOLERANCE = 0.05;
18
+ /** Only turn a diagram whose best scale in its own direction is below this (text under ~10 px)… */
19
+ export declare const FLIP_BELOW = 0.75;
20
+ /** …and only when turning it raises the scale by at least this much. */
21
+ export declare const FLIP_GAIN = 0.15;
22
+ /** Below this scale the diagram is reported: no layout makes it readable, its content must change. */
23
+ export declare const SMALL_SCALE = 0.5;
24
+ export interface FlowchartHeader {
25
+ /** Index of the header line in `source.split("\n")`. */
26
+ line: number;
27
+ /** Direction as written; flowcharts without one are top-to-bottom. */
28
+ direction: FlowDirection;
29
+ }
30
+ /**
31
+ * The flowchart's header line and main direction, or undefined when the source is another kind
32
+ * of diagram. Blank lines and `%%` comments before the header are skipped.
33
+ */
34
+ export declare function flowchartHeader(source: string): FlowchartHeader | undefined;
35
+ /** The source with its main direction replaced; subgraph directions are left alone. */
36
+ export declare function withDirection(source: string, header: FlowchartHeader, direction: FlowDirection): string;
37
+ export declare function flippedDirection(direction: FlowDirection): FlowDirection;
38
+ /**
39
+ * The diagram sets its own Mermaid configuration (an `%%{init: …}%%` directive or `config:` in
40
+ * its front matter): it is drawn exactly as written.
41
+ */
42
+ export declare function authorConfigured(source: string): boolean;
43
+ /** Width and height of an SVG from its viewBox. */
44
+ export declare function measureSvg(svg: string): {
45
+ width: number;
46
+ height: number;
47
+ } | undefined;
48
+ export interface Fit {
49
+ /** Text column width in CSS px. */
50
+ width: number;
51
+ /** Largest printed height in CSS px; Infinity without pages (HTML). */
52
+ height: number;
53
+ }
54
+ /** How much the diagram is shrunk when printed: 1 is its natural size. */
55
+ export declare function printScale(size: {
56
+ width: number;
57
+ height: number;
58
+ }, fit: Fit): number;
59
+ export interface Candidate {
60
+ svg: string;
61
+ /** Label wrapping width in px; undefined is Mermaid's default. */
62
+ wrap: number | undefined;
63
+ flipped: boolean;
64
+ width: number;
65
+ height: number;
66
+ scale: number;
67
+ }
68
+ export declare function candidate(svg: string, wrap: number | undefined, flipped: boolean, fit: Fit): Candidate | undefined;
69
+ /** Whether a turned layout is worth trying, given the best layout in the written direction. */
70
+ export declare const flipWorthTrying: (best: Candidate) => boolean;
71
+ /**
72
+ * The chosen layout. `base` is the diagram as written (Mermaid's default wrapping, its own
73
+ * direction); the result is never smaller than it. A turned layout wins only when the written
74
+ * direction stays below FLIP_BELOW and turning gains at least FLIP_GAIN.
75
+ */
76
+ export declare function chooseCandidate(candidates: Candidate[], base: Candidate): Candidate;
77
+ /**
78
+ * Whether wider wrapping in this direction can still help: once the diagram is wider than the
79
+ * column and its scale went down, wider labels only make it wider.
80
+ */
81
+ export declare function wrapsExhausted(current: Candidate, previous: Candidate | undefined, fit: Fit): boolean;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Autofit for Mermaid flowcharts: pick, among several layouts of the same diagram, the one whose
3
+ * text prints largest once the diagram is scaled to the text column and the page height.
4
+ *
5
+ * Mermaid wraps label lines at `flowchart.wrappingWidth`, and a flowchart laid out along one axis
6
+ * can be far wider (or taller) than the page; the theme then shrinks the whole drawing, text
7
+ * included. Wider wrapping makes tall diagrams shorter; turning a long left-to-right chain
8
+ * top-to-bottom makes it fit the column. Neither has one value that suits every diagram, so each
9
+ * flowchart is rendered a few times and measured. Everything here is pure; the rendering loop is
10
+ * in render/mermaid.ts.
11
+ */
12
+ export const FLOW_DIRECTIONS = ["TB", "TD", "BT", "LR", "RL"];
13
+ /** Label wrapping widths tried, in px; undefined is Mermaid's own default (the layout as today). */
14
+ export const WRAPPING_WIDTHS = [
15
+ undefined,
16
+ 200,
17
+ 300,
18
+ 400,
19
+ 600,
20
+ 900,
21
+ ];
22
+ /** Candidates within this scale of the best one read the same; the shorter one wins. */
23
+ export const SCALE_TOLERANCE = 0.05;
24
+ /** Only turn a diagram whose best scale in its own direction is below this (text under ~10 px)… */
25
+ export const FLIP_BELOW = 0.75;
26
+ /** …and only when turning it raises the scale by at least this much. */
27
+ export const FLIP_GAIN = 0.15;
28
+ /** Below this scale the diagram is reported: no layout makes it readable, its content must change. */
29
+ export const SMALL_SCALE = 0.5;
30
+ /** The axis-swapped direction: horizontal flowcharts go top-to-bottom, vertical ones left-to-right. */
31
+ const FLIPPED = {
32
+ LR: "TB",
33
+ RL: "TB",
34
+ TB: "LR",
35
+ TD: "LR",
36
+ BT: "LR",
37
+ };
38
+ const HEADER = /^(\s*(?:flowchart|graph))(?:[ \t]+(TB|TD|BT|LR|RL))?(?=\s|;|$)/i;
39
+ /** Lines of the diagram after Mermaid's optional front matter (`---` … `---`). */
40
+ function bodyStart(lines) {
41
+ if (lines[0]?.trim() !== "---")
42
+ return 0;
43
+ const end = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
44
+ return end === -1 ? 0 : end + 1;
45
+ }
46
+ /**
47
+ * The flowchart's header line and main direction, or undefined when the source is another kind
48
+ * of diagram. Blank lines and `%%` comments before the header are skipped.
49
+ */
50
+ export function flowchartHeader(source) {
51
+ const lines = source.split("\n");
52
+ for (let i = bodyStart(lines); i < lines.length; i++) {
53
+ const text = lines[i].trim();
54
+ if (text === "" || text.startsWith("%%"))
55
+ continue;
56
+ const match = HEADER.exec(lines[i]);
57
+ if (!match)
58
+ return undefined;
59
+ const direction = (match[2]?.toUpperCase() ?? "TB");
60
+ return { line: i, direction };
61
+ }
62
+ return undefined;
63
+ }
64
+ /** The source with its main direction replaced; subgraph directions are left alone. */
65
+ export function withDirection(source, header, direction) {
66
+ const lines = source.split("\n");
67
+ lines[header.line] = lines[header.line].replace(HEADER, (_m, keyword) => `${keyword} ${direction}`);
68
+ return lines.join("\n");
69
+ }
70
+ export function flippedDirection(direction) {
71
+ return FLIPPED[direction];
72
+ }
73
+ /**
74
+ * The diagram sets its own Mermaid configuration (an `%%{init: …}%%` directive or `config:` in
75
+ * its front matter): it is drawn exactly as written.
76
+ */
77
+ export function authorConfigured(source) {
78
+ if (/%%\{\s*(?:init|initialize)\b/i.test(source))
79
+ return true;
80
+ const lines = source.split("\n");
81
+ const start = bodyStart(lines);
82
+ return start > 0 && lines.slice(1, start - 1).some((line) => /^\s*config\s*:/.test(line));
83
+ }
84
+ /** Width and height of an SVG from its viewBox. */
85
+ export function measureSvg(svg) {
86
+ const match = /viewBox="\s*[-\d.e]+[\s,]+[-\d.e]+[\s,]+([\d.e]+)[\s,]+([\d.e]+)\s*"/.exec(svg);
87
+ if (!match)
88
+ return undefined;
89
+ const width = Number(match[1]);
90
+ const height = Number(match[2]);
91
+ return width > 0 && height > 0 ? { width, height } : undefined;
92
+ }
93
+ /** How much the diagram is shrunk when printed: 1 is its natural size. */
94
+ export function printScale(size, fit) {
95
+ return Math.min(1, fit.width / size.width, fit.height / size.height);
96
+ }
97
+ export function candidate(svg, wrap, flipped, fit) {
98
+ const size = measureSvg(svg);
99
+ return size && { svg, wrap, flipped, ...size, scale: printScale(size, fit) };
100
+ }
101
+ const printedHeight = (c) => Math.round(c.height * c.scale);
102
+ /**
103
+ * Best of one direction's candidates: those within SCALE_TOLERANCE of the top scale and never
104
+ * below the layout as written (`base`), then the shortest printed, then the largest scale.
105
+ */
106
+ function bestOf(candidates, base) {
107
+ const top = Math.max(...candidates.map((c) => c.scale));
108
+ return candidates
109
+ .filter((c) => c.scale >= top - SCALE_TOLERANCE && c.scale >= base.scale - 1e-9)
110
+ .sort((a, b) => printedHeight(a) - printedHeight(b) || b.scale - a.scale)[0];
111
+ }
112
+ /** Whether a turned layout is worth trying, given the best layout in the written direction. */
113
+ export const flipWorthTrying = (best) => best.scale < FLIP_BELOW - 1e-9;
114
+ /**
115
+ * The chosen layout. `base` is the diagram as written (Mermaid's default wrapping, its own
116
+ * direction); the result is never smaller than it. A turned layout wins only when the written
117
+ * direction stays below FLIP_BELOW and turning gains at least FLIP_GAIN.
118
+ */
119
+ export function chooseCandidate(candidates, base) {
120
+ const written = bestOf(candidates.filter((c) => !c.flipped), base);
121
+ const best = written ?? base;
122
+ const turned = candidates.filter((c) => c.flipped);
123
+ if (turned.length === 0 || !flipWorthTrying(best))
124
+ return best;
125
+ const flipped = bestOf(turned, base);
126
+ return flipped && flipped.scale >= best.scale + FLIP_GAIN - 1e-9 ? flipped : best;
127
+ }
128
+ /**
129
+ * Whether wider wrapping in this direction can still help: once the diagram is wider than the
130
+ * column and its scale went down, wider labels only make it wider.
131
+ */
132
+ export function wrapsExhausted(current, previous, fit) {
133
+ return previous !== undefined && current.width > fit.width && current.scale < previous.scale;
134
+ }
135
+ //# sourceMappingURL=mermaid-fit.js.map
@@ -1,22 +1,56 @@
1
1
  import type { Diagnostic } from "../diagnostics.js";
2
2
  import type { MermaidBlock } from "../markdown/env.js";
3
- import type { MermaidTheme } from "../options.js";
3
+ import type { MermaidFlip, MermaidTheme, TextFont } from "../options.js";
4
4
  import type { BrowserSession } from "./browser.js";
5
5
  export { MERMAID_THEMES, type MermaidTheme } from "../options.js";
6
- type DiagramResult = {
6
+ /** How autofit laid out a flowchart (see render/mermaid-fit.ts). */
7
+ export interface DiagramLayout {
8
+ /** Label wrapping width in px; undefined is Mermaid's default. */
9
+ wrap: number | undefined;
10
+ /** Main direction turned to the other axis, e.g. "LR→TB"; undefined when kept. */
11
+ turned?: string;
12
+ /** Printed scale and height (CSS px) as written and as chosen. */
13
+ baseScale: number;
14
+ scale: number;
15
+ baseHeight: number;
16
+ height: number;
17
+ /** Layouts rendered to choose this one. */
18
+ tried: number;
19
+ }
20
+ export type DiagramResult = {
7
21
  svg: string;
22
+ /** Printed scale (1 = natural size), when the SVG could be measured. */
23
+ scale?: number;
24
+ /** Set for flowcharts considered by autofit. */
25
+ layout?: DiagramLayout;
8
26
  } | {
9
27
  error: string;
10
28
  };
29
+ export interface MermaidRenderOptions {
30
+ theme?: MermaidTheme;
31
+ /** Width of the text column, so diagrams are laid out at their printed size. */
32
+ widthPx?: number;
33
+ /** Text size of the document in CSS pixels, so diagram labels match the body text. */
34
+ fontPx?: number;
35
+ /** Tallest a diagram may print (the page's text height), in CSS px; Infinity for HTML. */
36
+ pageHeightPx?: number;
37
+ /** Search label wrapping widths (and, with `flip`, the other axis) for each flowchart. */
38
+ autofit?: boolean;
39
+ flip?: MermaidFlip;
40
+ /** Text font of the document, used for diagram labels too. */
41
+ font?: TextFont;
42
+ }
43
+ /**
44
+ * Room kept for a heading printed together with the diagram below it (see keep-with-heading in
45
+ * theme.css, where the same allowance is subtracted), in multiples of the text size.
46
+ */
47
+ export declare const HEADING_ALLOWANCE_EM = 5;
11
48
  /**
12
49
  * Render Mermaid diagrams to SVG in a blank page that holds no user content: only the diagram
13
- * sources are passed in, as data. Mermaid runs with `securityLevel: "strict"`.
50
+ * sources are passed in, as data. Mermaid runs with `securityLevel: "strict"`. With `autofit`,
51
+ * flowcharts are rendered in several layouts and the one whose text prints largest is kept.
14
52
  */
15
- export declare function renderMermaid(session: BrowserSession, blocks: MermaidBlock[], theme?: MermaidTheme,
16
- /** Width of the text column, so diagrams are laid out at their printed size. */
17
- widthPx?: number,
18
- /** Text size of the document in CSS pixels, so diagram labels match the body text. */
19
- fontPx?: number): Promise<DiagramResult[]>;
53
+ export declare function renderMermaid(session: BrowserSession, blocks: MermaidBlock[], options?: MermaidRenderOptions): Promise<DiagramResult[]>;
20
54
  /**
21
55
  * Replace the Mermaid placeholders in `html` with rendered SVGs, or with a visible error box.
22
56
  * Returns the new HTML and one error diagnostic per failed diagram.
@@ -1,58 +1,129 @@
1
1
  import { MERMAID_JS, readTextAsset } from "../assets.js";
2
2
  import { mermaidPlaceholder } from "../markdown/mermaid.js";
3
- import { fontFaceCss } from "./fonts.js";
3
+ import { FONT_FAMILIES, fontFaceCss, fontStack } from "./fonts.js";
4
4
  import { escapeHtml } from "../html.js";
5
+ import { authorConfigured, candidate, chooseCandidate, flipWorthTrying, flippedDirection, flowchartHeader, withDirection, WRAPPING_WIDTHS, wrapsExhausted, } from "./mermaid-fit.js";
5
6
  export { MERMAID_THEMES } from "../options.js";
6
7
  /** Width of the text column on A4 with 20 mm margins. */
7
8
  const DEFAULT_WIDTH_PX = 642;
8
9
  /** 10.5pt, the built-in themes' text size (Mermaid's own default is 16px). */
9
10
  const DEFAULT_FONT_PX = 14;
11
+ /**
12
+ * Room kept for a heading printed together with the diagram below it (see keep-with-heading in
13
+ * theme.css, where the same allowance is subtracted), in multiples of the text size.
14
+ */
15
+ export const HEADING_ALLOWANCE_EM = 5;
10
16
  let mermaidJs;
17
+ function mermaidConfig(theme, font, fontPx, wrap) {
18
+ const fontFamily = fontStack(font);
19
+ return {
20
+ startOnLoad: false,
21
+ securityLevel: "strict",
22
+ theme,
23
+ fontFamily,
24
+ // Sequence diagrams take their text sizes from here, the others from themeVariables.
25
+ fontSize: fontPx,
26
+ themeVariables: { fontFamily, fontSize: `${fontPx}px` },
27
+ htmlLabels: true,
28
+ // Actors only at the top of sequence diagrams: repeating them below costs height.
29
+ sequence: { mirrorActors: false },
30
+ ...(wrap === undefined ? {} : { flowchart: { wrappingWidth: wrap } }),
31
+ };
32
+ }
11
33
  /**
12
34
  * Render Mermaid diagrams to SVG in a blank page that holds no user content: only the diagram
13
- * sources are passed in, as data. Mermaid runs with `securityLevel: "strict"`.
35
+ * sources are passed in, as data. Mermaid runs with `securityLevel: "strict"`. With `autofit`,
36
+ * flowcharts are rendered in several layouts and the one whose text prints largest is kept.
14
37
  */
15
- export async function renderMermaid(session, blocks, theme = "neutral",
16
- /** Width of the text column, so diagrams are laid out at their printed size. */
17
- widthPx = DEFAULT_WIDTH_PX,
18
- /** Text size of the document in CSS pixels, so diagram labels match the body text. */
19
- fontPx = DEFAULT_FONT_PX) {
38
+ export async function renderMermaid(session, blocks, options = {}) {
39
+ const { theme = "neutral", widthPx = DEFAULT_WIDTH_PX, fontPx = DEFAULT_FONT_PX, pageHeightPx = Infinity, autofit = true, flip = "auto", font = "manrope", } = options;
40
+ const family = FONT_FAMILIES[font];
20
41
  mermaidJs ??= readTextAsset(MERMAID_JS);
21
42
  const page = await session.newPage();
22
43
  try {
23
- await page.setContent(`<!doctype html><html><head><meta charset="utf-8"><style>${fontFaceCss()}
24
- body { margin: 0; width: ${widthPx}px; font-family: "Inter", sans-serif; }</style>
44
+ await page.setContent(`<!doctype html><html><head><meta charset="utf-8"><style>${fontFaceCss(font)}
45
+ body { margin: 0; width: ${widthPx}px; font-family: ${fontStack(font)}; }</style>
25
46
  </head><body></body></html>`);
26
47
  await page.evaluate(mermaidJs);
27
- return await page.evaluate(async (sources, theme, fontPx) => {
48
+ await page.evaluate(async (family) => {
28
49
  // Runs in the browser. The project is compiled without DOM types, hence PageGlobals.
29
- const { document, mermaid } = globalThis;
50
+ const { document } = globalThis;
30
51
  // Load the font weights Mermaid uses before it measures any text.
31
- await Promise.all(["400", "600", "700"].map((w) => document.fonts.load(`${w} 16px Inter`)));
32
- mermaid.initialize({
33
- startOnLoad: false,
34
- securityLevel: "strict",
35
- theme,
36
- fontFamily: "Inter, sans-serif",
37
- // Sequence diagrams take their text sizes from here, the others from themeVariables.
38
- fontSize: fontPx,
39
- themeVariables: { fontFamily: "Inter, sans-serif", fontSize: `${fontPx}px` },
40
- htmlLabels: true,
41
- // Actors only at the top of sequence diagrams: repeating them below costs height.
42
- sequence: { mirrorActors: false },
43
- });
44
- const results = [];
45
- for (const [i, source] of sources.entries()) {
46
- try {
47
- const { svg } = await mermaid.render(`mermaid-${i + 1}`, source);
48
- results.push({ svg });
49
- }
50
- catch (error) {
51
- results.push({ error: String(error?.message ?? error) });
52
+ await Promise.all(["400", "600", "700"].map((w) => document.fonts.load(`${w} 16px "${family}"`)));
53
+ }, family);
54
+ const render = (source, id, wrap) => page.evaluate(async (source, id, config) => {
55
+ const { mermaid } = globalThis;
56
+ // Each render sets the whole configuration, so one layout never leaks into the next.
57
+ mermaid.initialize(config);
58
+ try {
59
+ return { svg: (await mermaid.render(id, source)).svg };
60
+ }
61
+ catch (error) {
62
+ return { error: String(error?.message ?? error) };
63
+ }
64
+ }, source, id, mermaidConfig(theme, font, fontPx, wrap));
65
+ const results = [];
66
+ for (const [i, block] of blocks.entries()) {
67
+ const id = `mermaid-${i + 1}`;
68
+ const fit = {
69
+ width: widthPx,
70
+ height: block.afterHeading ? pageHeightPx - HEADING_ALLOWANCE_EM * fontPx : pageHeightPx,
71
+ };
72
+ const written = await render(block.source, id, undefined);
73
+ if ("error" in written) {
74
+ results.push(written);
75
+ continue;
76
+ }
77
+ const base = candidate(written.svg, undefined, false, fit);
78
+ const header = flowchartHeader(block.source);
79
+ if (!autofit || !base || !header || authorConfigured(block.source)) {
80
+ results.push({ svg: written.svg, scale: base?.scale });
81
+ continue;
82
+ }
83
+ let tried = 1;
84
+ const candidates = [base];
85
+ const tryWraps = async (source, flipped) => {
86
+ let previous = flipped ? undefined : base;
87
+ for (const wrap of WRAPPING_WIDTHS) {
88
+ if (!flipped && wrap === undefined)
89
+ continue; // that is `base`
90
+ const rendered = await render(source, `${id}-${tried}`, wrap);
91
+ tried++;
92
+ if ("error" in rendered)
93
+ continue;
94
+ const next = candidate(rendered.svg, wrap, flipped, fit);
95
+ if (!next)
96
+ continue;
97
+ candidates.push(next);
98
+ if (wrapsExhausted(next, previous, fit))
99
+ break;
100
+ previous = next;
52
101
  }
102
+ };
103
+ await tryWraps(block.source, false);
104
+ let chosen = chooseCandidate(candidates, base);
105
+ const turnedTo = flippedDirection(header.direction);
106
+ if (flip === "auto" && flipWorthTrying(chosen)) {
107
+ await tryWraps(withDirection(block.source, header, turnedTo), true);
108
+ chosen = chooseCandidate(candidates, base);
53
109
  }
54
- return results;
55
- }, blocks.map((b) => b.source), theme, fontPx);
110
+ // Candidate ids ("mermaid-2-3") stay unique in the document, and its styles are scoped
111
+ // to that id, so the chosen SVG is used as rendered.
112
+ results.push({
113
+ svg: chosen.svg,
114
+ scale: chosen.scale,
115
+ layout: {
116
+ wrap: chosen.wrap,
117
+ turned: chosen.flipped ? `${header.direction}→${turnedTo}` : undefined,
118
+ baseScale: base.scale,
119
+ scale: chosen.scale,
120
+ baseHeight: Math.round(base.height * base.scale),
121
+ height: Math.round(chosen.height * chosen.scale),
122
+ tried,
123
+ },
124
+ });
125
+ }
126
+ return results;
56
127
  }
57
128
  finally {
58
129
  await page.close();
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml } from "../html.js";
2
+ import type { TextFont } from "../options.js";
2
3
  export { escapeHtml };
3
4
  /** Stylesheets of a built-in theme ("light" or "dark"). */
4
5
  export declare function builtinThemeCss(theme: "light" | "dark"): string[];
@@ -17,6 +18,8 @@ export interface DocumentParts {
17
18
  showTitle?: boolean;
18
19
  /** Theme stylesheet contents. Defaults to the built-in light theme. */
19
20
  themeCss?: string[];
21
+ /** Text font to embed. Defaults to Manrope. */
22
+ font?: TextFont;
20
23
  /** Layout stylesheets (density, font size, page height), applied after the theme. */
21
24
  layoutCss?: string[];
22
25
  /** Extra stylesheet contents, applied after the theme and layout. */