@imontene/mdrender 2.1.0 → 2.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Cambios
2
2
 
3
+ ## 2.2.0 — 2026-10-15
4
+
5
+ - **Autoajuste de diagramas de flujo Mermaid** (`flowchart`/`graph`): cada diagrama se dibuja
6
+ con varios anchos de ajuste de etiquetas y se elige el que imprime el texto más grande (a igual
7
+ legibilidad, el más bajo), nunca más pequeño que tal como está escrito. Si sigue por debajo del
8
+ 75 % de su tamaño, se prueba el otro eje (`LR` → `TB`, `TD` → `LR`) y se gira si gana al menos
9
+ 15 puntos. En el banco de pruebas, la escala media del texto pasa de 0,80 a 0,98 y ningún
10
+ diagrama queda por debajo de 0,6.
11
+ - `--mermaid-flip never` (`mermaidFlip`) conserva la dirección; `--no-mermaid-autofit`
12
+ (`mermaidAutofit: false`) lo desactiva. Se respetan los diagramas con `%%{init}%%` o
13
+ `config:` propios.
14
+ - `-v` informa la elección por diagrama; aviso si un diagrama se imprime por debajo del 50 %.
15
+ - Un título seguido de un diagrama o una imagen ya no queda solo al pie de una página: pasa con
16
+ ellos a la siguiente.
17
+ - `npm run bench:mermaid [-- archivo.md]` compara los diagramas de un documento con y sin
18
+ autoajuste.
19
+
3
20
  ## 2.1.0 — 2026-10-15
4
21
 
5
22
  - `mdrender doctor`: revisa instalación (versión, Node.js, `PATH`), navegador (cuál y por qué,
package/README.md CHANGED
@@ -134,37 +134,39 @@ equipo); `--port` fija el puerto y `--no-open` no abre el navegador.
134
134
 
135
135
  ### Opciones
136
136
 
137
- | Opción | Descripción | Por defecto |
138
- | ----------------------------- | ------------------------------------------------------------ | ------------------ |
139
- | `-o, --output <ruta>` | Archivo de salida, o carpeta con varios archivos | junto al `.md` |
140
- | `-f, --format <pdf\|html>` | Formato de salida | `pdf` |
141
- | `--page-size <tamaño>` | `Letter`, `A4`, `Legal` (también `A3`, `A5`, `Tabloid`) | `Letter` |
142
- | `--margin <valor>` | Las cuatro márgenes como en CSS: `10mm`, `15mm 20mm`… | `10mm` |
143
- | `--margin-top <medida>` | Margen superior, p. ej. `15mm` (prevalece sobre `--margin`) | `10mm` |
144
- | `--margin-right <medida>` | Margen derecho | `10mm` |
145
- | `--margin-bottom <medida>` | Margen inferior | `10mm` |
146
- | `--margin-left <medida>` | Margen izquierdo | `10mm` |
147
- | `--orientation <orientación>` | `portrait` (vertical) o `landscape` (horizontal) | `portrait` |
148
- | `--landscape` | Atajo de `--orientation landscape` | — |
149
- | `--toc` | Tabla de contenidos al inicio (o donde pongas `[[toc]]`) | — |
150
- | `--toc-depth <n>` | Nivel de título más profundo en la tabla de contenidos | `3` |
151
- | `--header <texto>` | Encabezado de página (ver abajo) | — |
152
- | `--footer <texto>` | Pie de página (ver abajo) | `{page} / {pages}` |
153
- | `--no-page-numbers` | Sin el pie con números de página | — |
154
- | `--density <densidad>` | `normal` o `compact` (menos espacio entre líneas y bloques) | `normal` |
155
- | `--font-size <tamaño>` | Tamaño del texto en `pt` o `px`, de 6 a 24 pt; p. ej. `10pt` | `10.5pt` |
156
- | `--theme <light\|dark\|css>` | `light`, `dark` o un `.css` que reemplaza el tema | `light` |
157
- | `--css <archivo.css>` | CSS adicional (se puede repetir) | — |
158
- | `--mermaid-theme <tema>` | `default`, `neutral`, `dark`, `forest`, `base` | `neutral` / `dark` |
159
- | `--lang <código>` | Idioma del documento; `en` para inglés (`pt`, `fr`, `de`…) | `es` |
160
- | `--title <texto>` | Título del documento (muestra un bloque de título) | primer `# título` |
161
- | `--config <archivo>` | Archivo de configuración | el más cercano |
162
- | `--no-config` | Ignorar `mdrender.config.json` | — |
163
- | `--browser <ruta>` | Ruta a Chrome/Edge/Chromium (o variable `MDRENDER_BROWSER`) | autodetección |
164
- | `--allow-remote` | Permitir que el PDF descargue imágenes/CSS de internet | bloqueado |
165
- | `-w, --watch` | Regenerar al guardar | — |
166
- | `-q, --quiet` | Mostrar solo advertencias y errores | — |
167
- | `-v, --verbose` | Mostrar también configuración, navegador y tiempos | — |
137
+ | Opción | Descripción | Por defecto |
138
+ | ------------------------------ | -------------------------------------------------------------------- | ------------------ |
139
+ | `-o, --output <ruta>` | Archivo de salida, o carpeta con varios archivos | junto al `.md` |
140
+ | `-f, --format <pdf\|html>` | Formato de salida | `pdf` |
141
+ | `--page-size <tamaño>` | `Letter`, `A4`, `Legal` (también `A3`, `A5`, `Tabloid`) | `Letter` |
142
+ | `--margin <valor>` | Las cuatro márgenes como en CSS: `10mm`, `15mm 20mm`… | `10mm` |
143
+ | `--margin-top <medida>` | Margen superior, p. ej. `15mm` (prevalece sobre `--margin`) | `10mm` |
144
+ | `--margin-right <medida>` | Margen derecho | `10mm` |
145
+ | `--margin-bottom <medida>` | Margen inferior | `10mm` |
146
+ | `--margin-left <medida>` | Margen izquierdo | `10mm` |
147
+ | `--orientation <orientación>` | `portrait` (vertical) o `landscape` (horizontal) | `portrait` |
148
+ | `--landscape` | Atajo de `--orientation landscape` | — |
149
+ | `--toc` | Tabla de contenidos al inicio (o donde pongas `[[toc]]`) | — |
150
+ | `--toc-depth <n>` | Nivel de título más profundo en la tabla de contenidos | `3` |
151
+ | `--header <texto>` | Encabezado de página (ver abajo) | — |
152
+ | `--footer <texto>` | Pie de página (ver abajo) | `{page} / {pages}` |
153
+ | `--no-page-numbers` | Sin el pie con números de página | — |
154
+ | `--density <densidad>` | `normal` o `compact` (menos espacio entre líneas y bloques) | `normal` |
155
+ | `--font-size <tamaño>` | Tamaño del texto en `pt` o `px`, de 6 a 24 pt; p. ej. `10pt` | `10.5pt` |
156
+ | `--theme <light\|dark\|css>` | `light`, `dark` o un `.css` que reemplaza el tema | `light` |
157
+ | `--css <archivo.css>` | CSS adicional (se puede repetir) | — |
158
+ | `--mermaid-theme <tema>` | `default`, `neutral`, `dark`, `forest`, `base` | `neutral` / `dark` |
159
+ | `--mermaid-flip <auto\|never>` | Si el autoajuste puede girar un diagrama de flujo de eje (ver abajo) | `auto` |
160
+ | `--no-mermaid-autofit` | Dibujar los diagramas de flujo tal como están escritos | — |
161
+ | `--lang <código>` | Idioma del documento; `en` para inglés (`pt`, `fr`, `de`…) | `es` |
162
+ | `--title <texto>` | Título del documento (muestra un bloque de título) | primer `# título` |
163
+ | `--config <archivo>` | Archivo de configuración | el más cercano |
164
+ | `--no-config` | Ignorar `mdrender.config.json` | — |
165
+ | `--browser <ruta>` | Ruta a Chrome/Edge/Chromium (o variable `MDRENDER_BROWSER`) | autodetección |
166
+ | `--allow-remote` | Permitir que el PDF descargue imágenes/CSS de internet | bloqueado |
167
+ | `-w, --watch` | Regenerar al guardar | — |
168
+ | `-q, --quiet` | Mostrar solo advertencias y errores | — |
169
+ | `-v, --verbose` | Mostrar también configuración, navegador y tiempos | — |
168
170
 
169
171
  **Página**: por defecto hoja **Letter vertical con márgenes de 10 mm**. Las medidas aceptan
170
172
  `mm`, `cm`, `in` o `px`. `--margin` fija las cuatro márgenes a la vez y `--margin-top`,
@@ -237,6 +239,25 @@ fecha). Las rutas de `css` y `theme` son relativas al archivo donde aparecen.
237
239
  - Si una fórmula o diagrama tiene un error, el documento se genera igual con una caja roja en
238
240
  su lugar, se informa `error: archivo.md:LÍNEA: mensaje` y el comando termina con código `2`.
239
241
 
242
+ **Autoajuste de diagramas de flujo**: en el PDF, un diagrama más ancho que la columna o más alto
243
+ que la página se reduce entero, y su texto puede quedar diminuto. Por eso mdrender dibuja cada
244
+ `flowchart`/`graph` con varios anchos de ajuste de las etiquetas y se queda con el que imprime el
245
+ texto más grande; a igual legibilidad, el que ocupa menos alto. Nunca lo imprime más pequeño que
246
+ tal como está escrito.
247
+
248
+ - Si el diagrama sigue por debajo del 75 % de su tamaño, prueba además el otro eje (`LR` → `TB`,
249
+ o `TD` → `LR`) y lo gira si gana al menos 15 puntos. Una cadena horizontal de 8 cajas, por
250
+ ejemplo, pasa de imprimirse al 37 % a su tamaño real. `--mermaid-flip never` (o
251
+ `mermaidFlip: never`) conserva siempre la dirección escrita.
252
+ - No se tocan los diagramas con configuración propia (`%%{init: …}%%` o `config:` en su front
253
+ matter) ni los de otros tipos (secuencia, torta, clases…).
254
+ - `-v` informa la elección de cada diagrama; si alguno queda por debajo del 50 %, mdrender avisa:
255
+ conviene partirlo o acortar sus etiquetas.
256
+ - `--no-mermaid-autofit` (o `mermaidAutofit: false`) lo desactiva.
257
+
258
+ Un título seguido de un diagrama o una imagen pasa con ellos a la página siguiente cuando no
259
+ caben, en lugar de quedar solo al pie de la anterior.
260
+
240
261
  ## Más Markdown
241
262
 
242
263
  - **Alertas** estilo GitHub: `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`,
@@ -352,6 +352,21 @@ div.math-error {
352
352
  .mermaid-diagram svg {
353
353
  max-height: calc(var(--page-content-height) - 6mm);
354
354
  }
355
+
356
+ /* Next to its heading: leave room for it (HEADING_ALLOWANCE_EM in render/mermaid.ts). */
357
+ .keep-with-heading img,
358
+ .keep-with-heading .mermaid-diagram svg {
359
+ max-height: calc(var(--page-content-height) - 6mm - 5rem);
360
+ }
361
+ }
362
+
363
+ /* A heading and the diagram or image right after it go to the same page. */
364
+ .keep-with-heading {
365
+ break-inside: avoid;
366
+ }
367
+
368
+ .markdown-body > .keep-with-heading:first-child > :first-child {
369
+ margin-top: 0;
355
370
  }
356
371
 
357
372
  /* Mermaid's HTML labels are <p>/<span> inside <foreignObject>: keep document styles out. */
package/dist/cli.js CHANGED
@@ -30,6 +30,8 @@ const DOCUMENT_FLAGS = [
30
30
  "css",
31
31
  "theme",
32
32
  "mermaidTheme",
33
+ "mermaidAutofit",
34
+ "mermaidFlip",
33
35
  "lang",
34
36
  "title",
35
37
  ];
@@ -60,6 +62,8 @@ function addDocumentOptions(command) {
60
62
  .option("--theme <theme>", "light, dark, or a .css file replacing the theme (default: light)")
61
63
  .option("--css <file>", "extra stylesheet (repeatable)", collect)
62
64
  .option("--mermaid-theme <theme>", `Mermaid theme: ${MERMAID_THEMES.join(", ")} (default: neutral; dark with --theme dark)`)
65
+ .option("--no-mermaid-autofit", "draw flowcharts exactly as written (no layout search)")
66
+ .option("--mermaid-flip <mode>", "auto: turn a flowchart to the other axis if its text prints too small; never (default: auto)")
63
67
  .option("--lang <tag>", "document language, e.g. es or en (default: es)")
64
68
  .option("--title <text>", "document title (shown as a title block)")
65
69
  .option("--config <file>", "config file (default: nearest mdrender.config.json)")
@@ -111,7 +115,11 @@ function cliOverrides(program) {
111
115
  if (program.getOptionValueSource(key) === "cli")
112
116
  raw[key] = program.getOptionValue(key);
113
117
  }
114
- return normalizeOptions(raw, "command line", process.cwd(), (key) => key === "pageNumbers" ? "--no-page-numbers" : toFlag(key));
118
+ return normalizeOptions(raw, "command line", process.cwd(), (key) => key === "pageNumbers"
119
+ ? "--no-page-numbers"
120
+ : key === "mermaidAutofit"
121
+ ? "--no-mermaid-autofit"
122
+ : toFlag(key));
115
123
  }
116
124
  function settings(program, options) {
117
125
  return {
package/dist/convert.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { Diagnostic } from "./diagnostics.js";
2
2
  import { type DocumentOptions, type Margins, type MermaidTheme } from "./options.js";
3
3
  import type { OutputFormat } from "./paths.js";
4
4
  import { BrowserSession } from "./render/browser.js";
5
+ import { type DiagramLayout } from "./render/mermaid.js";
5
6
  /** Settings shared by every file of a run (batch, watch or preview). */
6
7
  export interface RenderSettings {
7
8
  /** Explicit browser executable. */
@@ -30,6 +31,8 @@ export interface ConvertResult {
30
31
  output: string;
31
32
  /** Problems found while rendering; the output is still written, with errors shown inline. */
32
33
  diagnostics: Diagnostic[];
34
+ /** Details for --verbose, such as the layout chosen for each Mermaid flowchart. */
35
+ notes: Note[];
33
36
  /** The effective options after merging config file, front matter and command line. */
34
37
  options: DocumentOptions;
35
38
  /** Config file that was applied, if any. */
@@ -59,12 +62,20 @@ export declare class BrowserPool {
59
62
  */
60
63
  export declare const MIN_HEADER_FOOTER_MARGIN = "10mm";
61
64
  export declare function headerFooterSpaceWarnings(margin: Margins, hasHeader: boolean, hasFooter: boolean): Diagnostic[];
65
+ export interface Note {
66
+ line?: number;
67
+ message: string;
68
+ }
69
+ /** "Mermaid autofit: wrap 300, turned LR→TB, scale 0.33 → 1.00, height 81 → 903 px". */
70
+ export declare function describeLayout(layout: DiagramLayout): string;
62
71
  /** Mermaid theme: explicit choice, else dark diagrams for the dark page theme. */
63
72
  export declare function effectiveMermaidTheme(options: DocumentOptions): MermaidTheme;
64
73
  /**
65
74
  * Render a Markdown file to a complete HTML document. A browser is only started (through
66
75
  * `browsers`) when the document has Mermaid diagrams.
67
76
  */
68
- export declare function renderDocument(inputPath: string, settings: RenderSettings, browsers: BrowserPool): Promise<RenderedDocument>;
77
+ export declare function renderDocument(inputPath: string, settings: RenderSettings, browsers: BrowserPool,
78
+ /** Printed on pages (PDF): diagrams are fitted to the page height, not only the width. */
79
+ paged?: boolean): Promise<RenderedDocument>;
69
80
  /** Read a Markdown file and write it as a self-contained HTML document or a PDF. */
70
81
  export declare function convertFile(convert: ConvertOptions): Promise<ConvertResult>;
package/dist/convert.js CHANGED
@@ -11,6 +11,7 @@ import { DEFAULT_FONT_SIZE, fontSizePx, mergeOptions, normalizeOptions, OptionsE
11
11
  import { BrowserSession } from "./render/browser.js";
12
12
  import { DEFAULT_FOOTER, headerFooterTemplate } from "./render/header-footer.js";
13
13
  import { fillMermaid, renderMermaid } from "./render/mermaid.js";
14
+ import { SMALL_SCALE } from "./render/mermaid-fit.js";
14
15
  import { contentHeightPx, contentWidthPx, lengthToPx } from "./render/page.js";
15
16
  import { buildHtmlDocument, builtinThemeCss, densityCss } from "./render/template.js";
16
17
  import { writeFileAtomic } from "./write.js";
@@ -86,6 +87,19 @@ function layoutCss(options, margin) {
86
87
  rules.push(`html { font-size: ${options.fontSize}; }`);
87
88
  return rules.join("\n");
88
89
  }
90
+ const fixed2 = (n) => n.toFixed(2);
91
+ /** "Mermaid autofit: wrap 300, turned LR→TB, scale 0.33 → 1.00, height 81 → 903 px". */
92
+ export function describeLayout(layout) {
93
+ const changes = [
94
+ layout.wrap === undefined ? undefined : `wrap ${layout.wrap}`,
95
+ layout.turned ? `turned ${layout.turned}` : undefined,
96
+ ].filter(Boolean);
97
+ const scale = `scale ${fixed2(layout.baseScale)} → ${fixed2(layout.scale)}`;
98
+ const height = `height ${layout.baseHeight} → ${layout.height} px`;
99
+ return changes.length === 0
100
+ ? `Mermaid autofit: kept as written (scale ${fixed2(layout.scale)}, ${layout.tried} layouts tried)`
101
+ : `Mermaid autofit: ${changes.join(", ")}, ${scale}, ${height} (${layout.tried} layouts tried)`;
102
+ }
89
103
  /** Mermaid theme: explicit choice, else dark diagrams for the dark page theme. */
90
104
  export function effectiveMermaidTheme(options) {
91
105
  return options.mermaidTheme ?? (options.theme === "dark" ? "dark" : "neutral");
@@ -94,7 +108,9 @@ export function effectiveMermaidTheme(options) {
94
108
  * Render a Markdown file to a complete HTML document. A browser is only started (through
95
109
  * `browsers`) when the document has Mermaid diagrams.
96
110
  */
97
- export async function renderDocument(inputPath, settings, browsers) {
111
+ export async function renderDocument(inputPath, settings, browsers,
112
+ /** Printed on pages (PDF): diagrams are fitted to the page height, not only the width. */
113
+ paged = false) {
98
114
  const input = path.resolve(inputPath);
99
115
  const baseDir = path.dirname(input);
100
116
  const source = await readFile(input, "utf8");
@@ -114,6 +130,7 @@ export async function renderDocument(inputPath, settings, browsers) {
114
130
  ...config.warnings.map((message) => ({ severity: "warning", message })),
115
131
  ...md.diagnostics,
116
132
  ];
133
+ const notes = [];
117
134
  const title = options.title ?? md.title ?? path.parse(input).name;
118
135
  // Table of contents: at the [[toc]] marker, or at the start with --toc.
119
136
  let body = md.html;
@@ -130,10 +147,36 @@ export async function renderDocument(inputPath, settings, browsers) {
130
147
  const width = contentWidthPx(options.pageSize, options.orientation === "landscape", margin);
131
148
  const theme = effectiveMermaidTheme(options);
132
149
  const fontPx = fontSizePx(options.fontSize ?? DEFAULT_FONT_SIZE);
133
- const results = await renderMermaid(session, md.mermaid, theme, width, fontPx);
150
+ const landscape = options.orientation === "landscape";
151
+ const results = await renderMermaid(session, md.mermaid, {
152
+ theme,
153
+ widthPx: width,
154
+ fontPx,
155
+ // Same limit as the print stylesheet: the page's text height minus 6 mm.
156
+ pageHeightPx: paged
157
+ ? contentHeightPx(options.pageSize, landscape, margin) - lengthToPx("6mm")
158
+ : Infinity,
159
+ autofit: options.mermaidAutofit,
160
+ flip: options.mermaidFlip,
161
+ });
134
162
  const filled = fillMermaid(body, md.nonce, md.mermaid, results);
135
163
  body = filled.html;
136
164
  diagnostics.push(...filled.diagnostics);
165
+ md.mermaid.forEach((block, i) => {
166
+ const result = results[i];
167
+ if (!result || "error" in result)
168
+ return;
169
+ if (result.layout)
170
+ notes.push({ line: block.line, message: describeLayout(result.layout) });
171
+ if (paged && result.scale !== undefined && result.scale < SMALL_SCALE) {
172
+ diagnostics.push({
173
+ severity: "warning",
174
+ line: block.line,
175
+ message: `Mermaid diagram printed at ${Math.round(result.scale * 100)}% of its size; ` +
176
+ "split it or shorten its labels to make it readable",
177
+ });
178
+ }
179
+ });
137
180
  }
138
181
  const html = buildHtmlDocument({
139
182
  title,
@@ -154,14 +197,14 @@ export async function renderDocument(inputPath, settings, browsers) {
154
197
  ...(options.theme.endsWith(".css") ? [options.theme] : []),
155
198
  ...options.css,
156
199
  ];
157
- return { html, title, diagnostics, options, configFile, dependencies };
200
+ return { html, title, diagnostics, notes, options, configFile, dependencies };
158
201
  }
159
202
  /** Read a Markdown file and write it as a self-contained HTML document or a PDF. */
160
203
  export async function convertFile(convert) {
161
204
  const output = path.resolve(convert.output);
162
205
  const browsers = convert.browsers ?? new BrowserPool(convert.browser);
163
206
  try {
164
- const doc = await renderDocument(convert.input, convert, browsers);
207
+ const doc = await renderDocument(convert.input, convert, browsers, convert.format === "pdf");
165
208
  const { options } = doc;
166
209
  if (convert.format === "pdf") {
167
210
  const margin = resolveMargins(options);
@@ -195,6 +238,7 @@ export async function convertFile(convert) {
195
238
  return {
196
239
  output,
197
240
  diagnostics: doc.diagnostics,
241
+ notes: doc.notes,
198
242
  options,
199
243
  configFile: doc.configFile,
200
244
  dependencies: doc.dependencies,
@@ -4,6 +4,8 @@ export interface MermaidBlock {
4
4
  source: string;
5
5
  /** 1-based source line of the opening fence. */
6
6
  line?: number;
7
+ /** Printed together with the heading just above it (see markdown/keep-with-heading.ts). */
8
+ afterHeading?: boolean;
7
9
  }
8
10
  /** Per-document state shared by the Markdown plugins during one render. */
9
11
  export interface RenderEnv extends Env {
@@ -8,6 +8,7 @@ import { codeBlocks } from "./code-blocks.js";
8
8
  import { inlineChildLines } from "./env.js";
9
9
  import { highlightCode } from "./highlight.js";
10
10
  import { inlineLocalImages } from "./images.js";
11
+ import { keepWithHeading } from "./keep-with-heading.js";
11
12
  import { math } from "./math.js";
12
13
  import { mermaid } from "./mermaid.js";
13
14
  import { taskLists } from "./task-lists.js";
@@ -24,6 +25,7 @@ export function createMarkdown() {
24
25
  md.use(taskLists);
25
26
  md.use(alerts);
26
27
  md.use(tocMarker);
28
+ md.use(keepWithHeading);
27
29
  md.use(inlineChildLines);
28
30
  md.use(inlineLocalImages);
29
31
  // Before math and mermaid, which hand their non-code fences on to this renderer.
@@ -0,0 +1,8 @@
1
+ import type { MarkdownIt } from "markdown-it";
2
+ export declare const KEEP_WITH_HEADING_CLASS = "keep-with-heading";
3
+ /**
4
+ * Wrap a top-level heading and the diagram or lone image right after it in a block that is not
5
+ * split across pages (`.keep-with-heading` in theme.css). Otherwise a figure that does not fit at
6
+ * the bottom of a page moves to the next one and leaves its heading alone at the bottom.
7
+ */
8
+ export declare function keepWithHeading(md: MarkdownIt): void;
@@ -0,0 +1,44 @@
1
+ export const KEEP_WITH_HEADING_CLASS = "keep-with-heading";
2
+ const isMermaidFence = (token) => token?.type === "fence" && token.info.trim().toLowerCase() === "mermaid";
3
+ /** A paragraph holding nothing but one image (`![alt](img.png)`). */
4
+ function imageParagraphEnd(tokens, i) {
5
+ if (tokens[i]?.type !== "paragraph_open" || tokens[i + 2]?.type !== "paragraph_close") {
6
+ return undefined;
7
+ }
8
+ const children = tokens[i + 1]?.children?.filter((child) => !(child.type === "text" && child.content.trim() === ""));
9
+ return children?.length === 1 && children[0].type === "image" ? i + 2 : undefined;
10
+ }
11
+ /**
12
+ * Wrap a top-level heading and the diagram or lone image right after it in a block that is not
13
+ * split across pages (`.keep-with-heading` in theme.css). Otherwise a figure that does not fit at
14
+ * the bottom of a page moves to the next one and leaves its heading alone at the bottom.
15
+ */
16
+ export function keepWithHeading(md) {
17
+ md.core.ruler.push("keep_with_heading", (state) => {
18
+ const tokens = state.tokens;
19
+ for (let i = 0; i < tokens.length; i++) {
20
+ const open = tokens[i];
21
+ if (open.type !== "heading_open" || open.level !== 0)
22
+ continue;
23
+ const next = i + 3; // heading_open, inline, heading_close
24
+ let end;
25
+ if (isMermaidFence(tokens[next])) {
26
+ end = next;
27
+ tokens[next].meta = { ...tokens[next].meta, afterHeading: true };
28
+ }
29
+ else {
30
+ end = imageParagraphEnd(tokens, next);
31
+ }
32
+ if (end === undefined)
33
+ continue;
34
+ const before = new state.Token("html_block", "", 0);
35
+ before.content = `<div class="${KEEP_WITH_HEADING_CLASS}">\n`;
36
+ const after = new state.Token("html_block", "", 0);
37
+ after.content = "</div>\n";
38
+ tokens.splice(end + 1, 0, after);
39
+ tokens.splice(i, 0, before);
40
+ i = end + 2;
41
+ }
42
+ });
43
+ }
44
+ //# sourceMappingURL=keep-with-heading.js.map
@@ -15,7 +15,9 @@ export function mermaid(md) {
15
15
  return fence(tokens, idx, options, rawEnv, self);
16
16
  }
17
17
  const env = rawEnv;
18
- const index = env.mermaid.push({ source: token.content, line: tokenLine(token) }) - 1;
18
+ const block = { source: token.content, line: tokenLine(token) };
19
+ const afterHeading = token.meta?.afterHeading;
20
+ const index = env.mermaid.push(afterHeading ? { ...block, afterHeading } : block) - 1;
19
21
  return `<figure class="mermaid-diagram">${mermaidPlaceholder(env.nonce, index)}</figure>\n`;
20
22
  };
21
23
  }
package/dist/options.d.ts CHANGED
@@ -11,6 +11,8 @@ export type Margins = {
11
11
  };
12
12
  export declare const MERMAID_THEMES: readonly ["default", "neutral", "dark", "forest", "base"];
13
13
  export type MermaidTheme = (typeof MERMAID_THEMES)[number];
14
+ export declare const MERMAID_FLIPS: readonly ["auto", "never"];
15
+ export type MermaidFlip = (typeof MERMAID_FLIPS)[number];
14
16
  export declare const BUILTIN_THEMES: readonly ["light", "dark"];
15
17
  export declare const DENSITIES: readonly ["normal", "compact"];
16
18
  export type Density = (typeof DENSITIES)[number];
@@ -35,6 +37,10 @@ export interface DocumentOptions {
35
37
  footer?: string;
36
38
  /** Mermaid theme; when unset, "neutral" for light pages and "dark" for the dark theme. */
37
39
  mermaidTheme?: MermaidTheme;
40
+ /** Try several layouts of each flowchart and keep the one whose text prints largest. */
41
+ mermaidAutofit: boolean;
42
+ /** Whether autofit may turn a flowchart to the other axis (LR ↔ TB) when it reads far better. */
43
+ mermaidFlip: MermaidFlip;
38
44
  /** Vertical spacing: "compact" tightens line height, paragraph and heading spacing. */
39
45
  density: Density;
40
46
  /** Base font size of the text, e.g. "10pt"; when unset, the theme's (10.5pt). */
package/dist/options.js CHANGED
@@ -5,6 +5,7 @@ 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"];
9
10
  export const DENSITIES = ["normal", "compact"];
10
11
  export const DEFAULT_OPTIONS = {
@@ -14,6 +15,8 @@ export const DEFAULT_OPTIONS = {
14
15
  toc: false,
15
16
  tocDepth: 3,
16
17
  pageNumbers: true,
18
+ mermaidAutofit: true,
19
+ mermaidFlip: "auto",
17
20
  density: "normal",
18
21
  theme: "light",
19
22
  css: [],
@@ -110,6 +113,8 @@ const VALIDATORS = {
110
113
  header: text,
111
114
  footer: text,
112
115
  mermaidTheme: oneOf(MERMAID_THEMES),
116
+ mermaidAutofit: bool,
117
+ mermaidFlip: oneOf(MERMAID_FLIPS),
113
118
  density: oneOf(DENSITIES),
114
119
  fontSize,
115
120
  theme: (value, describe, baseDir) => {
@@ -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,54 @@
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 } 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
+ }
41
+ /**
42
+ * Room kept for a heading printed together with the diagram below it (see keep-with-heading in
43
+ * theme.css, where the same allowance is subtracted), in multiples of the text size.
44
+ */
45
+ export declare const HEADING_ALLOWANCE_EM = 5;
11
46
  /**
12
47
  * 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"`.
48
+ * sources are passed in, as data. Mermaid runs with `securityLevel: "strict"`. With `autofit`,
49
+ * flowcharts are rendered in several layouts and the one whose text prints largest is kept.
14
50
  */
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[]>;
51
+ export declare function renderMermaid(session: BrowserSession, blocks: MermaidBlock[], options?: MermaidRenderOptions): Promise<DiagramResult[]>;
20
52
  /**
21
53
  * Replace the Mermaid placeholders in `html` with rendered SVGs, or with a visible error box.
22
54
  * Returns the new HTML and one error diagnostic per failed diagram.
@@ -2,21 +2,40 @@ import { MERMAID_JS, readTextAsset } from "../assets.js";
2
2
  import { mermaidPlaceholder } from "../markdown/mermaid.js";
3
3
  import { fontFaceCss } 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, fontPx, wrap) {
18
+ return {
19
+ startOnLoad: false,
20
+ securityLevel: "strict",
21
+ theme,
22
+ fontFamily: "Inter, sans-serif",
23
+ // Sequence diagrams take their text sizes from here, the others from themeVariables.
24
+ fontSize: fontPx,
25
+ themeVariables: { fontFamily: "Inter, sans-serif", fontSize: `${fontPx}px` },
26
+ htmlLabels: true,
27
+ // Actors only at the top of sequence diagrams: repeating them below costs height.
28
+ sequence: { mirrorActors: false },
29
+ ...(wrap === undefined ? {} : { flowchart: { wrappingWidth: wrap } }),
30
+ };
31
+ }
11
32
  /**
12
33
  * 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"`.
34
+ * sources are passed in, as data. Mermaid runs with `securityLevel: "strict"`. With `autofit`,
35
+ * flowcharts are rendered in several layouts and the one whose text prints largest is kept.
14
36
  */
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) {
37
+ export async function renderMermaid(session, blocks, options = {}) {
38
+ const { theme = "neutral", widthPx = DEFAULT_WIDTH_PX, fontPx = DEFAULT_FONT_PX, pageHeightPx = Infinity, autofit = true, flip = "auto", } = options;
20
39
  mermaidJs ??= readTextAsset(MERMAID_JS);
21
40
  const page = await session.newPage();
22
41
  try {
@@ -24,35 +43,85 @@ fontPx = DEFAULT_FONT_PX) {
24
43
  body { margin: 0; width: ${widthPx}px; font-family: "Inter", sans-serif; }</style>
25
44
  </head><body></body></html>`);
26
45
  await page.evaluate(mermaidJs);
27
- return await page.evaluate(async (sources, theme, fontPx) => {
46
+ await page.evaluate(async () => {
28
47
  // Runs in the browser. The project is compiled without DOM types, hence PageGlobals.
29
- const { document, mermaid } = globalThis;
48
+ const { document } = globalThis;
30
49
  // Load the font weights Mermaid uses before it measures any text.
31
50
  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) });
51
+ });
52
+ const render = (source, id, wrap) => page.evaluate(async (source, id, config) => {
53
+ const { mermaid } = globalThis;
54
+ // Each render sets the whole configuration, so one layout never leaks into the next.
55
+ mermaid.initialize(config);
56
+ try {
57
+ return { svg: (await mermaid.render(id, source)).svg };
58
+ }
59
+ catch (error) {
60
+ return { error: String(error?.message ?? error) };
61
+ }
62
+ }, source, id, mermaidConfig(theme, fontPx, wrap));
63
+ const results = [];
64
+ for (const [i, block] of blocks.entries()) {
65
+ const id = `mermaid-${i + 1}`;
66
+ const fit = {
67
+ width: widthPx,
68
+ height: block.afterHeading ? pageHeightPx - HEADING_ALLOWANCE_EM * fontPx : pageHeightPx,
69
+ };
70
+ const written = await render(block.source, id, undefined);
71
+ if ("error" in written) {
72
+ results.push(written);
73
+ continue;
74
+ }
75
+ const base = candidate(written.svg, undefined, false, fit);
76
+ const header = flowchartHeader(block.source);
77
+ if (!autofit || !base || !header || authorConfigured(block.source)) {
78
+ results.push({ svg: written.svg, scale: base?.scale });
79
+ continue;
80
+ }
81
+ let tried = 1;
82
+ const candidates = [base];
83
+ const tryWraps = async (source, flipped) => {
84
+ let previous = flipped ? undefined : base;
85
+ for (const wrap of WRAPPING_WIDTHS) {
86
+ if (!flipped && wrap === undefined)
87
+ continue; // that is `base`
88
+ const rendered = await render(source, `${id}-${tried}`, wrap);
89
+ tried++;
90
+ if ("error" in rendered)
91
+ continue;
92
+ const next = candidate(rendered.svg, wrap, flipped, fit);
93
+ if (!next)
94
+ continue;
95
+ candidates.push(next);
96
+ if (wrapsExhausted(next, previous, fit))
97
+ break;
98
+ previous = next;
52
99
  }
100
+ };
101
+ await tryWraps(block.source, false);
102
+ let chosen = chooseCandidate(candidates, base);
103
+ const turnedTo = flippedDirection(header.direction);
104
+ if (flip === "auto" && flipWorthTrying(chosen)) {
105
+ await tryWraps(withDirection(block.source, header, turnedTo), true);
106
+ chosen = chooseCandidate(candidates, base);
53
107
  }
54
- return results;
55
- }, blocks.map((b) => b.source), theme, fontPx);
108
+ // Candidate ids ("mermaid-2-3") stay unique in the document, and its styles are scoped
109
+ // to that id, so the chosen SVG is used as rendered.
110
+ results.push({
111
+ svg: chosen.svg,
112
+ scale: chosen.scale,
113
+ layout: {
114
+ wrap: chosen.wrap,
115
+ turned: chosen.flipped ? `${header.direction}→${turnedTo}` : undefined,
116
+ baseScale: base.scale,
117
+ scale: chosen.scale,
118
+ baseHeight: Math.round(base.height * base.scale),
119
+ height: Math.round(chosen.height * chosen.scale),
120
+ tried,
121
+ },
122
+ });
123
+ }
124
+ return results;
56
125
  }
57
126
  finally {
58
127
  await page.close();
package/dist/run.js CHANGED
@@ -26,6 +26,12 @@ export async function runJob(job, format, settings, browsers, io) {
26
26
  }
27
27
  if (io.verbose && result.configFile)
28
28
  io.err(`config: ${result.configFile}\n`);
29
+ if (io.verbose) {
30
+ for (const note of result.notes) {
31
+ const location = [displayPath(job.input), note.line].filter((p) => p !== undefined);
32
+ io.err(`${location.join(":")}: ${note.message}\n`);
33
+ }
34
+ }
29
35
  if (!io.quiet)
30
36
  io.out(`${result.output}\n`);
31
37
  if (io.verbose)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imontene/mdrender",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Command-line app for Windows and Linux that renders Markdown — with LaTeX equations and Mermaid diagrams — to PDF and HTML using the Inter typeface",
5
5
  "keywords": [
6
6
  "markdown",
@@ -60,7 +60,8 @@
60
60
  "check": "npm run format:check && npm run lint && npm run typecheck && npm test",
61
61
  "build:exe": "npm run build && node scripts/build-sea.mjs",
62
62
  "prepublishOnly": "npm run check && npm run build",
63
- "test:coverage": "vitest run --coverage"
63
+ "test:coverage": "vitest run --coverage",
64
+ "bench:mermaid": "tsx tests/bench/mermaid-autofit.ts"
64
65
  },
65
66
  "dependencies": {
66
67
  "@fontsource/jetbrains-mono": "^5.3.0",