@imontene/mdrender 2.0.0 → 2.1.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,21 @@
1
1
  # Cambios
2
2
 
3
+ ## 2.1.0 — 2026-10-15
4
+
5
+ - `mdrender doctor`: revisa instalación (versión, Node.js, `PATH`), navegador (cuál y por qué,
6
+ arranque y versión, sandbox), una conversión real de prueba, el `mdrender.config.json`
7
+ aplicado (errores, opciones mal escritas, valores efectivos), la caché de `setup` y el proxy.
8
+ `--quick`, `--json`; código 1 si encuentra un problema.
9
+ - Mejor uso de la página:
10
+ - Los bloques de código largos se parten entre páginas (al menos 4 líneas a cada lado) en
11
+ vez de saltar enteros y dejar hasta media página en blanco; los de hasta 12 líneas siguen
12
+ juntos.
13
+ - `--density compact` (o `density: compact`): interlineado y espacios más ajustados.
14
+ - `--font-size <tamaño>` (o `fontSize`): tamaño del texto, por defecto 10.5pt.
15
+ - Mermaid usa el tamaño de letra del documento (antes 16 px) y los diagramas de secuencia no
16
+ repiten los actores al pie.
17
+ - Imágenes y diagramas más altos que la página se reducen para caber en una sola.
18
+
3
19
  ## 2.0.0 — 2026-10-13
4
20
 
5
21
  - Márgenes por lado: `--margin-top`, `--margin-right`, `--margin-bottom`, `--margin-left`
package/README.md CHANGED
@@ -89,14 +89,39 @@ mdrender docs/ -o pdf/ --watch
89
89
  # Vista previa en el navegador que se recarga sola al guardar
90
90
  mdrender preview documento.md
91
91
 
92
+ # Más contenido por página
93
+ mdrender documento.md --density compact --font-size 10pt
94
+
92
95
  # Tema oscuro, o un CSS propio que reemplaza el tema
93
96
  mdrender documento.md --theme dark
94
97
  mdrender documento.md --theme mi-tema.css
95
98
 
96
99
  # Descargar un navegador si no tienes Chrome, Edge ni Chromium
97
100
  mdrender setup
101
+
102
+ # Diagnóstico: instalación, navegador, configuración y una conversión de prueba
103
+ mdrender doctor
98
104
  ```
99
105
 
106
+ ### Si algo no funciona: `mdrender doctor`
107
+
108
+ Revisa la versión y el Node.js, si `mdrender` está en el `PATH`, qué navegador se usa (y por
109
+ qué), lo arranca y convierte un documento de prueba con una ecuación y un diagrama, e indica
110
+ qué `mdrender.config.json` se aplica, con sus opciones mal escritas y los valores efectivos.
111
+ Cada problema viene con su solución sugerida; termina con código 1 si encuentra alguno.
112
+
113
+ ```text
114
+ [ok] mdrender 2.0.0, npm package (/usr/lib/node_modules/@imontene/mdrender)
115
+ [ok] Node.js 22.23.3
116
+ [warn] PATH the mdrender command is not in any PATH folder
117
+ -> add its folder to PATH (for npm: the output of `npm prefix -g`…)
118
+ [ok] Browser C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe
119
+ [ok] Test conversion PDF with an equation and a Mermaid diagram in 1840 ms
120
+ ```
121
+
122
+ `--quick` omite el arranque del navegador y la conversión; `--json` produce el informe en JSON
123
+ para compartirlo.
124
+
100
125
  En Windows (PowerShell o CMD) los comandos son idénticos:
101
126
 
102
127
  ```powershell
@@ -109,35 +134,37 @@ equipo); `--port` fija el puerto y `--no-open` no abre el navegador.
109
134
 
110
135
  ### Opciones
111
136
 
112
- | Opción | Descripción | Por defecto |
113
- | ----------------------------- | ----------------------------------------------------------- | ------------------ |
114
- | `-o, --output <ruta>` | Archivo de salida, o carpeta con varios archivos | junto al `.md` |
115
- | `-f, --format <pdf\|html>` | Formato de salida | `pdf` |
116
- | `--page-size <tamaño>` | `Letter`, `A4`, `Legal` (también `A3`, `A5`, `Tabloid`) | `Letter` |
117
- | `--margin <valor>` | Las cuatro márgenes como en CSS: `10mm`, `15mm 20mm`… | `10mm` |
118
- | `--margin-top <medida>` | Margen superior, p. ej. `15mm` (prevalece sobre `--margin`) | `10mm` |
119
- | `--margin-right <medida>` | Margen derecho | `10mm` |
120
- | `--margin-bottom <medida>` | Margen inferior | `10mm` |
121
- | `--margin-left <medida>` | Margen izquierdo | `10mm` |
122
- | `--orientation <orientación>` | `portrait` (vertical) o `landscape` (horizontal) | `portrait` |
123
- | `--landscape` | Atajo de `--orientation landscape` | — |
124
- | `--toc` | Tabla de contenidos al inicio (o donde pongas `[[toc]]`) | — |
125
- | `--toc-depth <n>` | Nivel de título más profundo en la tabla de contenidos | `3` |
126
- | `--header <texto>` | Encabezado de página (ver abajo) | — |
127
- | `--footer <texto>` | Pie de página (ver abajo) | `{page} / {pages}` |
128
- | `--no-page-numbers` | Sin el pie con números de página | — |
129
- | `--theme <light\|dark\|css>` | `light`, `dark` o un `.css` que reemplaza el tema | `light` |
130
- | `--css <archivo.css>` | CSS adicional (se puede repetir) | — |
131
- | `--mermaid-theme <tema>` | `default`, `neutral`, `dark`, `forest`, `base` | `neutral` / `dark` |
132
- | `--lang <código>` | Idioma del documento; `en` para inglés (`pt`, `fr`, `de`…) | `es` |
133
- | `--title <texto>` | Título del documento (muestra un bloque de título) | primer `# título` |
134
- | `--config <archivo>` | Archivo de configuración | el más cercano |
135
- | `--no-config` | Ignorar `mdrender.config.json` | — |
136
- | `--browser <ruta>` | Ruta a Chrome/Edge/Chromium (o variable `MDRENDER_BROWSER`) | autodetección |
137
- | `--allow-remote` | Permitir que el PDF descargue imágenes/CSS de internet | bloqueado |
138
- | `-w, --watch` | Regenerar al guardar | — |
139
- | `-q, --quiet` | Mostrar solo advertencias y errores | — |
140
- | `-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
+ | `--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 | — |
141
168
 
142
169
  **Página**: por defecto hoja **Letter vertical con márgenes de 10 mm**. Las medidas aceptan
143
170
  `mm`, `cm`, `in` o `px`. `--margin` fija las cuatro márgenes a la vez y `--margin-top`,
@@ -146,6 +173,16 @@ equipo); `--port` fija el puerto y `--no-open` no abre el navegador.
146
173
  El encabezado y el pie se dibujan dentro de las márgenes superior e inferior: con menos de
147
174
  10 mm pueden tocar el texto y mdrender lo advierte.
148
175
 
176
+ **Aprovechar la página**: `--density compact` reduce interlineado, espacios entre párrafos y
177
+ títulos, y `--font-size 10pt` achica el texto (los diagramas Mermaid siguen el mismo tamaño).
178
+ Juntos ahorran entre un 15 y un 25 % de páginas. Además, siempre:
179
+
180
+ - Los bloques de código largos continúan en la página siguiente (con al menos 4 líneas a cada
181
+ lado del corte) en lugar de saltar enteros y dejar un hueco; los de hasta 12 líneas no se
182
+ parten.
183
+ - Las imágenes y diagramas más altos que la página se reducen para caber en una sola.
184
+ - Los diagramas de secuencia no repiten los actores al pie.
185
+
149
186
  **Encabezado y pie**: texto con marcadores `{page}`, `{pages}`, `{title}`, `{author}` y
150
187
  `{date}`; `|` separa columnas izquierda, centro y derecha. Ejemplos:
151
188
  `--header "{title} | | {date}"`, `--footer "Confidencial | {page} de {pages}"`.
@@ -176,6 +213,8 @@ header: "{title} | | {date}"
176
213
  "pageSize": "Letter",
177
214
  "margin": "15mm",
178
215
  "marginLeft": "25mm",
216
+ "density": "compact",
217
+ "fontSize": "10pt",
179
218
  "css": "estilos/empresa.css"
180
219
  }
181
220
  ```
@@ -0,0 +1,74 @@
1
+ /* mdrender compact density (--density compact): loaded after the theme, tightens vertical spacing. */
2
+
3
+ body {
4
+ line-height: 1.5;
5
+ }
6
+
7
+ p,
8
+ ul,
9
+ ol,
10
+ blockquote,
11
+ pre,
12
+ table,
13
+ dl {
14
+ margin-bottom: 0.8em;
15
+ }
16
+
17
+ h1,
18
+ h2,
19
+ h3,
20
+ h4,
21
+ h5,
22
+ h6 {
23
+ margin: 1.3em 0 0.5em;
24
+ }
25
+
26
+ h1 {
27
+ font-size: 1.9em;
28
+ }
29
+
30
+ h2 {
31
+ font-size: 1.4em;
32
+ }
33
+
34
+ h3 {
35
+ font-size: 1.18em;
36
+ }
37
+
38
+ li + li {
39
+ margin-top: 0.15em;
40
+ }
41
+
42
+ pre {
43
+ padding: 0.7em 0.9em;
44
+ }
45
+
46
+ th,
47
+ td {
48
+ padding: 0.35em 0.7em;
49
+ }
50
+
51
+ .math-display {
52
+ margin: 0.8em 0;
53
+ }
54
+
55
+ .mermaid-diagram {
56
+ margin: 0.9em 0;
57
+ }
58
+
59
+ .doc-header {
60
+ margin-bottom: 1.5em;
61
+ }
62
+
63
+ .toc {
64
+ margin-bottom: 1.5em;
65
+ padding: 0.8em 1.2em;
66
+ }
67
+
68
+ hr {
69
+ margin: 1.5em 0;
70
+ }
71
+
72
+ hr.footnotes-sep {
73
+ margin: 1.8em 0 0.8em;
74
+ }
@@ -243,6 +243,14 @@ pre {
243
243
  background: var(--color-subtle-bg);
244
244
  white-space: pre-wrap;
245
245
  overflow-wrap: anywhere;
246
+ /* Long listings may continue on the next page, keeping a few lines on each side. */
247
+ break-inside: auto;
248
+ orphans: 4;
249
+ widows: 4;
250
+ }
251
+
252
+ /* Short blocks (marked by mdrender) stay on one page. */
253
+ pre.keep-together {
246
254
  break-inside: avoid;
247
255
  }
248
256
 
@@ -335,6 +343,17 @@ div.math-error {
335
343
  height: auto;
336
344
  }
337
345
 
346
+ /*
347
+ * In the PDF, images and diagrams are scaled down to fit the page height (mdrender sets
348
+ * --page-content-height), so they are never cut across pages.
349
+ */
350
+ @media print {
351
+ img,
352
+ .mermaid-diagram svg {
353
+ max-height: calc(var(--page-content-height) - 6mm);
354
+ }
355
+ }
356
+
338
357
  /* Mermaid's HTML labels are <p>/<span> inside <foreignObject>: keep document styles out. */
339
358
  .mermaid-diagram foreignObject p {
340
359
  margin: 0;
package/dist/assets.js CHANGED
@@ -85,7 +85,11 @@ export const MONO_STYLESHEETS = [
85
85
  ];
86
86
  export const KATEX_CSS = "katex/dist/katex.min.css";
87
87
  export const MERMAID_JS = "mermaid/dist/mermaid.min.js";
88
- export const THEMES = ["mdrender/assets/theme.css", "mdrender/assets/theme-dark.css"];
88
+ export const THEMES = [
89
+ "mdrender/assets/theme.css",
90
+ "mdrender/assets/theme-dark.css",
91
+ "mdrender/assets/density-compact.css",
92
+ ];
89
93
  export const PACKAGE_JSON = "mdrender/package.json";
90
94
  /** Fonts referenced by a stylesheet as url(...woff2), as asset keys. */
91
95
  export function woff2References(cssKey, css) {
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Command, CommanderError, Option } from "commander";
2
2
  import { setup } from "./browser/setup.js";
3
+ import { formatReport, runDoctor } from "./doctor.js";
3
4
  import { MdRenderError } from "./errors.js";
4
5
  import { ExitCode } from "./exit-codes.js";
5
6
  import { expandInputs, planOutputs } from "./inputs.js";
@@ -24,6 +25,8 @@ const DOCUMENT_FLAGS = [
24
25
  "header",
25
26
  "footer",
26
27
  "pageNumbers",
28
+ "density",
29
+ "fontSize",
27
30
  "css",
28
31
  "theme",
29
32
  "mermaidTheme",
@@ -52,6 +55,8 @@ function addDocumentOptions(command) {
52
55
  .option("--header <text>", 'page header, e.g. "{title} | | {date}"')
53
56
  .option("--footer <text>", 'page footer (default: "{page} / {pages}")')
54
57
  .option("--no-page-numbers", "no default page-number footer")
58
+ .option("--density <density>", "normal or compact (tighter spacing) (default: normal)")
59
+ .option("--font-size <size>", "text size, e.g. 10pt or 11pt (default: 10.5pt)")
55
60
  .option("--theme <theme>", "light, dark, or a .css file replacing the theme (default: light)")
56
61
  .option("--css <file>", "extra stylesheet (repeatable)", collect)
57
62
  .option("--mermaid-theme <theme>", `Mermaid theme: ${MERMAID_THEMES.join(", ")} (default: neutral; dark with --theme dark)`)
@@ -80,6 +85,7 @@ function buildRenderProgram(io) {
80
85
  Commands:
81
86
  mdrender preview <file> live HTML preview in the browser (see: mdrender preview --help)
82
87
  mdrender setup download a headless Chrome if no browser is installed
88
+ mdrender doctor check the installation, browser and configuration
83
89
 
84
90
  Header/footer placeholders: {page} {pages} {title} {author} {date}; "|" separates
85
91
  left | center | right columns.
@@ -143,6 +149,32 @@ async function runSetup(argv, io) {
143
149
  return parsed;
144
150
  return setup({ io, force: Boolean(program.opts().force) });
145
151
  }
152
+ function buildDoctorProgram(io) {
153
+ const program = new Command()
154
+ .name("mdrender doctor")
155
+ .description("Check the installation, browser and configuration, and try a real conversion.\n" +
156
+ "Exits with 1 when a problem is found.")
157
+ .option("--browser <path>", "check this browser instead of the detected one")
158
+ .option("--config <file>", "check this config file (default: nearest mdrender.config.json)")
159
+ .option("--no-config", "ignore mdrender.config.json files")
160
+ .option("--quick", "skip starting the browser and the test conversion")
161
+ .option("--json", "print the report as JSON");
162
+ return configure(program, io);
163
+ }
164
+ async function runDoctorCommand(argv, io) {
165
+ const program = buildDoctorProgram(io);
166
+ const parsed = await parse(program, argv);
167
+ if (parsed !== undefined)
168
+ return parsed;
169
+ const options = program.opts();
170
+ const report = await runDoctor({
171
+ browser: options.browser,
172
+ config: options.config,
173
+ render: !options.quick,
174
+ });
175
+ io.out(options.json ? `${JSON.stringify(report, null, 2)}\n` : formatReport(report));
176
+ return report.ok ? ExitCode.Ok : ExitCode.Usage;
177
+ }
146
178
  async function runPreview(argv, io, signal) {
147
179
  const program = buildPreviewProgram(io);
148
180
  const parsed = await parse(program, argv);
@@ -192,6 +224,8 @@ export async function main(argv, io = defaultIo, { signal = new AbortController(
192
224
  return await runPreview(argv.slice(1), io, signal);
193
225
  if (argv[0] === "setup")
194
226
  return await runSetup(argv.slice(1), io);
227
+ if (argv[0] === "doctor")
228
+ return await runDoctorCommand(argv.slice(1), io);
195
229
  return await runRender(argv, io, signal);
196
230
  }
197
231
  catch (error) {
package/dist/convert.js CHANGED
@@ -7,12 +7,12 @@ import { extractFrontMatter } from "./frontmatter.js";
7
7
  import { strings } from "./i18n.js";
8
8
  import { renderMarkdown } from "./markdown/index.js";
9
9
  import { buildToc, tocPlaceholder } from "./markdown/toc.js";
10
- import { mergeOptions, normalizeOptions, OptionsError, resolveMargins, } from "./options.js";
10
+ import { DEFAULT_FONT_SIZE, fontSizePx, mergeOptions, normalizeOptions, OptionsError, resolveMargins, } from "./options.js";
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 { contentWidthPx, lengthToPx } from "./render/page.js";
15
- import { buildHtmlDocument, builtinThemeCss } from "./render/template.js";
14
+ import { contentHeightPx, contentWidthPx, lengthToPx } from "./render/page.js";
15
+ import { buildHtmlDocument, builtinThemeCss, densityCss } from "./render/template.js";
16
16
  import { writeFileAtomic } from "./write.js";
17
17
  /** Starts one browser on first use and keeps it for later conversions. */
18
18
  export class BrowserPool {
@@ -78,6 +78,14 @@ export function headerFooterSpaceWarnings(margin, hasHeader, hasFooter) {
78
78
  }
79
79
  return warnings;
80
80
  }
81
+ /** Font size, if set, and the page's text height (used to fit images and diagrams). */
82
+ function layoutCss(options, margin) {
83
+ const height = contentHeightPx(options.pageSize, options.orientation === "landscape", margin);
84
+ const rules = [`:root { --page-content-height: ${height}px; }`];
85
+ if (options.fontSize)
86
+ rules.push(`html { font-size: ${options.fontSize}; }`);
87
+ return rules.join("\n");
88
+ }
81
89
  /** Mermaid theme: explicit choice, else dark diagrams for the dark page theme. */
82
90
  export function effectiveMermaidTheme(options) {
83
91
  return options.mermaidTheme ?? (options.theme === "dark" ? "dark" : "neutral");
@@ -121,7 +129,8 @@ export async function renderDocument(inputPath, settings, browsers) {
121
129
  const session = await browsers.get();
122
130
  const width = contentWidthPx(options.pageSize, options.orientation === "landscape", margin);
123
131
  const theme = effectiveMermaidTheme(options);
124
- const results = await renderMermaid(session, md.mermaid, theme, width);
132
+ const fontPx = fontSizePx(options.fontSize ?? DEFAULT_FONT_SIZE);
133
+ const results = await renderMermaid(session, md.mermaid, theme, width, fontPx);
125
134
  const filled = fillMermaid(body, md.nonce, md.mermaid, results);
126
135
  body = filled.html;
127
136
  diagnostics.push(...filled.diagnostics);
@@ -136,6 +145,7 @@ export async function renderDocument(inputPath, settings, browsers) {
136
145
  date: options.date,
137
146
  showTitle: options.title !== undefined,
138
147
  themeCss,
148
+ layoutCss: [...densityCss(options.density), layoutCss(options, margin)],
139
149
  extraCss,
140
150
  });
141
151
  diagnostics.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
@@ -0,0 +1,30 @@
1
+ export type CheckStatus = "ok" | "warn" | "fail" | "info";
2
+ export interface Check {
3
+ name: string;
4
+ status: CheckStatus;
5
+ detail: string;
6
+ /** What to do about a warning or failure. */
7
+ hint?: string;
8
+ }
9
+ export interface DoctorOptions {
10
+ /** Explicit browser executable (as with --browser). */
11
+ browser?: string;
12
+ /** Config file to check; undefined: nearest to `cwd`; false: none. */
13
+ config?: string | false;
14
+ /** Start the browser and convert a sample document (the slow part). Default: true. */
15
+ render?: boolean;
16
+ cwd?: string;
17
+ env?: Record<string, string | undefined>;
18
+ platform?: NodeJS.Platform;
19
+ }
20
+ export interface DoctorReport {
21
+ version: string;
22
+ checks: Check[];
23
+ ok: boolean;
24
+ }
25
+ /** Where `mdrender` resolves on the PATH, like `which`/`where`. */
26
+ export declare function findOnPath(env: Record<string, string | undefined>, platform?: NodeJS.Platform): string | undefined;
27
+ /** Inspect the installation, browser and configuration, and try a real conversion. */
28
+ export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
29
+ /** Plain-text report; ASCII markers so it reads well in any Windows console. */
30
+ export declare function formatReport(report: DoctorReport): string;
package/dist/doctor.js ADDED
@@ -0,0 +1,278 @@
1
+ import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import sea from "node:sea";
5
+ import { assetPath, PACKAGE_JSON } from "./assets.js";
6
+ import { cacheDir, installedBrowser } from "./browser/cache.js";
7
+ import { findBrowser } from "./browser/detect.js";
8
+ import { findConfigFile, readConfig } from "./config.js";
9
+ import { BrowserPool, convertFile } from "./convert.js";
10
+ import { DEFAULT_FONT_SIZE, mergeOptions, resolveMargins } from "./options.js";
11
+ import { version } from "./version.js";
12
+ const MIN_NODE = [22, 12];
13
+ function runningAsExecutable() {
14
+ try {
15
+ return sea.isSea();
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function isFile(file) {
22
+ try {
23
+ return statSync(file).isFile();
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /** Where `mdrender` resolves on the PATH, like `which`/`where`. */
30
+ export function findOnPath(env, platform = process.platform) {
31
+ const pathValue = Object.entries(env).find(([key]) => key.toUpperCase() === "PATH")?.[1] ?? "";
32
+ const separator = platform === "win32" ? ";" : ":";
33
+ const names = platform === "win32"
34
+ ? ["mdrender.exe", "mdrender.cmd", "mdrender.ps1", "mdrender"]
35
+ : ["mdrender"];
36
+ const join = platform === "win32" ? path.win32.join : path.posix.join;
37
+ for (const dir of pathValue.split(separator).filter(Boolean)) {
38
+ for (const name of names) {
39
+ const candidate = join(dir, name);
40
+ if (isFile(candidate))
41
+ return candidate;
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+ function installationChecks(env, platform) {
47
+ const checks = [];
48
+ const executable = runningAsExecutable();
49
+ checks.push({
50
+ name: "mdrender",
51
+ status: "ok",
52
+ detail: executable
53
+ ? `${version}, standalone executable (${process.execPath})`
54
+ : `${version}, npm package (${path.dirname(assetPath(PACKAGE_JSON))})`,
55
+ });
56
+ if (!executable) {
57
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
58
+ const [needMajor, needMinor] = MIN_NODE;
59
+ const supported = major > needMajor || (major === needMajor && minor >= needMinor);
60
+ checks.push({
61
+ name: "Node.js",
62
+ status: supported ? "ok" : "fail",
63
+ detail: `${process.versions.node} (${process.execPath})`,
64
+ hint: supported ? undefined : `mdrender needs Node.js ${needMajor}.${needMinor} or newer`,
65
+ });
66
+ }
67
+ checks.push({
68
+ name: "System",
69
+ status: "info",
70
+ detail: `${platform} ${process.arch}, ${os.release()}`,
71
+ });
72
+ const onPath = findOnPath(env, platform);
73
+ checks.push(onPath
74
+ ? { name: "PATH", status: "ok", detail: `mdrender found at ${onPath}` }
75
+ : {
76
+ name: "PATH",
77
+ status: "warn",
78
+ detail: "the mdrender command is not in any PATH folder",
79
+ hint: "you can only run it by its full path or with npx; add its folder to PATH " +
80
+ "(for npm: the output of `npm prefix -g`, plus /bin on Linux)",
81
+ });
82
+ return checks;
83
+ }
84
+ function browserSource(found, explicit, env, downloaded) {
85
+ if (explicit)
86
+ return "from --browser";
87
+ if (env.MDRENDER_BROWSER)
88
+ return "from MDRENDER_BROWSER";
89
+ if (downloaded && path.resolve(found) === path.resolve(downloaded)) {
90
+ return "downloaded by mdrender setup";
91
+ }
92
+ return "installed on this system";
93
+ }
94
+ function configChecks(options, cwd) {
95
+ if (options.config === false) {
96
+ return [{ name: "Config", status: "info", detail: "config files disabled (--no-config)" }];
97
+ }
98
+ const file = options.config ? path.resolve(cwd, options.config) : findConfigFile(cwd);
99
+ if (!file) {
100
+ const defaults = mergeOptions();
101
+ return [
102
+ {
103
+ name: "Config",
104
+ status: "info",
105
+ detail: `no mdrender.config.json in ${cwd} or its parent folders; defaults apply`,
106
+ },
107
+ settingsCheck(defaults),
108
+ ];
109
+ }
110
+ if (!existsSync(file)) {
111
+ return [{ name: "Config", status: "fail", detail: `not found: ${file}` }];
112
+ }
113
+ try {
114
+ const config = readConfig(file);
115
+ const checks = [
116
+ config.warnings.length
117
+ ? {
118
+ name: "Config",
119
+ status: "warn",
120
+ detail: `${file}\n${config.warnings.map((w) => ` - ${w}`).join("\n")}`,
121
+ hint: "fix or remove the options above; they are ignored",
122
+ }
123
+ : { name: "Config", status: "ok", detail: file },
124
+ ];
125
+ checks.push(settingsCheck(mergeOptions(config.options)));
126
+ return checks;
127
+ }
128
+ catch (error) {
129
+ return [{ name: "Config", status: "fail", detail: error.message }];
130
+ }
131
+ }
132
+ function settingsCheck(options) {
133
+ const m = resolveMargins(options);
134
+ return {
135
+ name: "Settings",
136
+ status: "info",
137
+ detail: `${options.pageSize} ${options.orientation}, margins ${m.top} ${m.right} ${m.bottom} ` +
138
+ `${m.left} (top right bottom left), ${options.density} density, ` +
139
+ `${options.fontSize ?? DEFAULT_FONT_SIZE} text, language ${options.lang}, theme ` +
140
+ path.basename(options.theme),
141
+ };
142
+ }
143
+ const SAMPLE = `# mdrender doctor
144
+
145
+ Equation: $e^{i\\pi} + 1 = 0$
146
+
147
+ \`\`\`mermaid
148
+ flowchart LR
149
+ A[Markdown] --> B[PDF]
150
+ \`\`\`
151
+ `;
152
+ async function browserChecks(options, env, platform) {
153
+ const checks = [];
154
+ const dir = cacheDir(env, platform);
155
+ const downloaded = installedBrowser(dir);
156
+ checks.push({
157
+ name: "Setup cache",
158
+ status: "info",
159
+ detail: downloaded ? `${dir} (contains ${downloaded})` : `${dir} (no downloaded browser)`,
160
+ });
161
+ let executable;
162
+ try {
163
+ executable = findBrowser(options.browser, {
164
+ env,
165
+ platform,
166
+ downloaded: () => downloaded,
167
+ });
168
+ }
169
+ catch (error) {
170
+ checks.push({
171
+ name: "Browser",
172
+ status: "fail",
173
+ detail: error.message.split("\n")[0],
174
+ hint: "run `mdrender setup` to download one, install Chrome/Edge/Chromium, or point to " +
175
+ "one with --browser <path> or MDRENDER_BROWSER",
176
+ });
177
+ return checks;
178
+ }
179
+ checks.push({
180
+ name: "Browser",
181
+ status: "ok",
182
+ detail: `${executable} (${browserSource(executable, options.browser, env, downloaded)})`,
183
+ });
184
+ if (options.render === false)
185
+ return checks;
186
+ const notes = [];
187
+ const pool = new BrowserPool(executable, (message) => notes.push(message));
188
+ const work = mkdtempSync(path.join(os.tmpdir(), "mdrender-doctor-"));
189
+ try {
190
+ let started = Date.now();
191
+ const session = await pool.get();
192
+ const browserVersion = await session.version();
193
+ const unsandboxed = notes.some((note) => note.includes("sandbox"));
194
+ checks.push({
195
+ name: "Browser start",
196
+ status: unsandboxed ? "warn" : "ok",
197
+ detail: `${browserVersion} in ${Date.now() - started} ms`,
198
+ hint: unsandboxed
199
+ ? "started without Chrome's sandbox (blocked by the system, e.g. Ubuntu 23.10+); " +
200
+ "conversions still work. A browser installed from a package avoids it"
201
+ : undefined,
202
+ });
203
+ const input = path.join(work, "doctor.md");
204
+ writeFileSync(input, SAMPLE);
205
+ started = Date.now();
206
+ const result = await convertFile({
207
+ input,
208
+ output: path.join(work, "doctor.pdf"),
209
+ format: "pdf",
210
+ config: false,
211
+ browsers: pool,
212
+ });
213
+ const problems = result.diagnostics.map((d) => d.message);
214
+ checks.push({
215
+ name: "Test conversion",
216
+ status: problems.length ? "fail" : "ok",
217
+ detail: problems.length
218
+ ? problems.join("; ")
219
+ : `PDF with an equation and a Mermaid diagram in ${Date.now() - started} ms`,
220
+ });
221
+ }
222
+ catch (error) {
223
+ checks.push({
224
+ name: "Browser start",
225
+ status: "fail",
226
+ detail: error.message.split("\n")[0],
227
+ hint: "try another browser with --browser <path>, or `mdrender setup --force`",
228
+ });
229
+ }
230
+ finally {
231
+ await pool.close();
232
+ rmSync(work, { recursive: true, force: true });
233
+ }
234
+ return checks;
235
+ }
236
+ function networkChecks(env) {
237
+ const proxy = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"].find((k) => env[k]);
238
+ return proxy
239
+ ? [{ name: "Proxy", status: "info", detail: `${proxy} is set (used by mdrender setup)` }]
240
+ : [];
241
+ }
242
+ /** Inspect the installation, browser and configuration, and try a real conversion. */
243
+ export async function runDoctor(options = {}) {
244
+ const env = options.env ?? process.env;
245
+ const platform = options.platform ?? process.platform;
246
+ const cwd = options.cwd ?? process.cwd();
247
+ const checks = [
248
+ ...installationChecks(env, platform),
249
+ ...(await browserChecks(options, env, platform)),
250
+ ...configChecks(options, cwd),
251
+ ...networkChecks(env),
252
+ ];
253
+ return { version, checks, ok: !checks.some((check) => check.status === "fail") };
254
+ }
255
+ const LABEL = {
256
+ ok: "[ok] ",
257
+ warn: "[warn]",
258
+ fail: "[fail]",
259
+ info: "[info]",
260
+ };
261
+ /** Plain-text report; ASCII markers so it reads well in any Windows console. */
262
+ export function formatReport(report) {
263
+ const width = Math.max(...report.checks.map((check) => check.name.length));
264
+ const lines = report.checks.map((check) => {
265
+ const [first, ...rest] = check.detail.split("\n");
266
+ const head = `${LABEL[check.status]} ${check.name.padEnd(width)} ${first}`;
267
+ const more = rest.map((line) => `${" ".repeat(width + 9)}${line}`);
268
+ const hint = check.hint ? [`${" ".repeat(width + 9)}-> ${check.hint}`] : [];
269
+ return [head, ...more, ...hint].join("\n");
270
+ });
271
+ const failures = report.checks.filter((c) => c.status === "fail").length;
272
+ const warnings = report.checks.filter((c) => c.status === "warn").length;
273
+ const summary = report.ok
274
+ ? `All good${warnings ? ` (${warnings} warning${warnings > 1 ? "s" : ""})` : ""}.`
275
+ : `${failures} problem${failures > 1 ? "s" : ""} found.`;
276
+ return `mdrender doctor\n\n${lines.join("\n")}\n\n${summary}\n`;
277
+ }
278
+ //# sourceMappingURL=doctor.js.map
@@ -0,0 +1,9 @@
1
+ import type { MarkdownIt } from "markdown-it";
2
+ /**
3
+ * Code blocks up to this many lines are kept on one page; longer ones may break across pages
4
+ * (the theme keeps at least a few lines on each side), so a long listing does not push itself
5
+ * to the next page and leave a large gap behind.
6
+ */
7
+ export declare const SHORT_CODE_LINES = 12;
8
+ /** Mark short fenced and indented code blocks with `keep-together` (see SHORT_CODE_LINES). */
9
+ export declare function codeBlocks(md: MarkdownIt): void;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Code blocks up to this many lines are kept on one page; longer ones may break across pages
3
+ * (the theme keeps at least a few lines on each side), so a long listing does not push itself
4
+ * to the next page and leave a large gap behind.
5
+ */
6
+ export const SHORT_CODE_LINES = 12;
7
+ const KEEP_CLASS = "keep-together";
8
+ function markShort(html, content) {
9
+ const lines = content.replace(/\n$/, "").split("\n").length;
10
+ if (lines > SHORT_CODE_LINES || !html.startsWith("<pre"))
11
+ return html;
12
+ return html.replace(/^<pre(?: class="([^"]*)")?/, (_m, classes) => classes ? `<pre class="${classes} ${KEEP_CLASS}"` : `<pre class="${KEEP_CLASS}"`);
13
+ }
14
+ /** Mark short fenced and indented code blocks with `keep-together` (see SHORT_CODE_LINES). */
15
+ export function codeBlocks(md) {
16
+ for (const rule of ["fence", "code_block"]) {
17
+ const render = md.renderer.rules[rule];
18
+ md.renderer.rules[rule] = (tokens, idx, options, env, self) => markShort(render(tokens, idx, options, env, self), tokens[idx].content);
19
+ }
20
+ }
21
+ //# sourceMappingURL=code-blocks.js.map
@@ -4,6 +4,7 @@ import anchor from "markdown-it-anchor";
4
4
  import footnote from "markdown-it-footnote";
5
5
  import { DEFAULT_LANG } from "../i18n.js";
6
6
  import { alerts } from "./alerts.js";
7
+ import { codeBlocks } from "./code-blocks.js";
7
8
  import { inlineChildLines } from "./env.js";
8
9
  import { highlightCode } from "./highlight.js";
9
10
  import { inlineLocalImages } from "./images.js";
@@ -25,6 +26,8 @@ export function createMarkdown() {
25
26
  md.use(tocMarker);
26
27
  md.use(inlineChildLines);
27
28
  md.use(inlineLocalImages);
29
+ // Before math and mermaid, which hand their non-code fences on to this renderer.
30
+ md.use(codeBlocks);
28
31
  md.use(math);
29
32
  md.use(mermaid);
30
33
  return md;
package/dist/options.d.ts CHANGED
@@ -12,6 +12,8 @@ export type Margins = {
12
12
  export declare const MERMAID_THEMES: readonly ["default", "neutral", "dark", "forest", "base"];
13
13
  export type MermaidTheme = (typeof MERMAID_THEMES)[number];
14
14
  export declare const BUILTIN_THEMES: readonly ["light", "dark"];
15
+ export declare const DENSITIES: readonly ["normal", "compact"];
16
+ export type Density = (typeof DENSITIES)[number];
15
17
  /** Rendering options, merged from defaults < config file < front matter < command line. */
16
18
  export interface DocumentOptions {
17
19
  pageSize: PageSize;
@@ -33,6 +35,10 @@ export interface DocumentOptions {
33
35
  footer?: string;
34
36
  /** Mermaid theme; when unset, "neutral" for light pages and "dark" for the dark theme. */
35
37
  mermaidTheme?: MermaidTheme;
38
+ /** Vertical spacing: "compact" tightens line height, paragraph and heading spacing. */
39
+ density: Density;
40
+ /** Base font size of the text, e.g. "10pt"; when unset, the theme's (10.5pt). */
41
+ fontSize?: string;
36
42
  /** "light", "dark", or the absolute path of a stylesheet that replaces the built-in theme. */
37
43
  theme: string;
38
44
  /** Extra stylesheets (absolute paths), applied after the built-in theme. */
@@ -50,6 +56,10 @@ export declare class OptionsError extends MdRenderError {
50
56
  }
51
57
  /** Parse a CSS margin shorthand into the four sides, or throw. */
52
58
  export declare function parseMargin(margin: string): Margins;
59
+ /** Text size of the built-in themes (theme.css), when --font-size is not given. */
60
+ export declare const DEFAULT_FONT_SIZE = "10.5pt";
61
+ /** A font size in CSS pixels ("10.5pt" → 14). */
62
+ export declare function fontSizePx(size: string): number;
53
63
  /**
54
64
  * Validate options from a config file, front matter or the command line.
55
65
  *
package/dist/options.js CHANGED
@@ -6,6 +6,7 @@ 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
8
  export const BUILTIN_THEMES = ["light", "dark"];
9
+ export const DENSITIES = ["normal", "compact"];
9
10
  export const DEFAULT_OPTIONS = {
10
11
  pageSize: "Letter",
11
12
  margin: "10mm",
@@ -13,6 +14,7 @@ export const DEFAULT_OPTIONS = {
13
14
  toc: false,
14
15
  tocDepth: 3,
15
16
  pageNumbers: true,
17
+ density: "normal",
16
18
  theme: "light",
17
19
  css: [],
18
20
  lang: DEFAULT_LANG,
@@ -59,6 +61,27 @@ const length = (value, describe) => {
59
61
  return trimmed;
60
62
  throw new OptionsError(`${describe} must be a length in mm, cm, in or px, e.g. "10mm"`);
61
63
  };
64
+ /** Text size of the built-in themes (theme.css), when --font-size is not given. */
65
+ export const DEFAULT_FONT_SIZE = "10.5pt";
66
+ /** Font sizes accepted by --font-size, in points or pixels. */
67
+ const FONT_SIZE = /^(\d+(?:\.\d+)?)(pt|px)$/;
68
+ const FONT_SIZE_PT = { min: 6, max: 24 };
69
+ /** A font size in CSS pixels ("10.5pt" → 14). */
70
+ export function fontSizePx(size) {
71
+ const match = FONT_SIZE.exec(size);
72
+ if (!match)
73
+ throw new Error(`invalid font size: ${size}`);
74
+ return match[2] === "pt" ? (Number(match[1]) * 4) / 3 : Number(match[1]);
75
+ }
76
+ const fontSize = (value, describe) => {
77
+ const size = text(value, describe, "").trim();
78
+ if (FONT_SIZE.test(size)) {
79
+ const pt = (fontSizePx(size) * 3) / 4;
80
+ if (pt >= FONT_SIZE_PT.min && pt <= FONT_SIZE_PT.max)
81
+ return size;
82
+ }
83
+ throw new OptionsError(`${describe} must be a size in pt or px from ${FONT_SIZE_PT.min}pt to ${FONT_SIZE_PT.max}pt, e.g. "10pt"`);
84
+ };
62
85
  const VALIDATORS = {
63
86
  pageSize: oneOf(PAGE_SIZES),
64
87
  margin: (value, describe) => {
@@ -87,6 +110,8 @@ const VALIDATORS = {
87
110
  header: text,
88
111
  footer: text,
89
112
  mermaidTheme: oneOf(MERMAID_THEMES),
113
+ density: oneOf(DENSITIES),
114
+ fontSize,
90
115
  theme: (value, describe, baseDir) => {
91
116
  const theme = text(value, describe, baseDir);
92
117
  const builtin = BUILTIN_THEMES.find((t) => t === theme.toLowerCase());
@@ -45,5 +45,7 @@ export declare class BrowserSession {
45
45
  pdf: Uint8Array;
46
46
  blocked: string[];
47
47
  }>;
48
+ /** Browser name and version, e.g. "HeadlessChrome/141.0.7390.37". */
49
+ version(): Promise<string>;
48
50
  close(): Promise<void>;
49
51
  }
@@ -127,6 +127,10 @@ export class BrowserSession {
127
127
  await page.close();
128
128
  }
129
129
  }
130
+ /** Browser name and version, e.g. "HeadlessChrome/141.0.7390.37". */
131
+ version() {
132
+ return this.browser.version();
133
+ }
130
134
  async close() {
131
135
  await this.browser.close();
132
136
  }
@@ -14,7 +14,9 @@ type DiagramResult = {
14
14
  */
15
15
  export declare function renderMermaid(session: BrowserSession, blocks: MermaidBlock[], theme?: MermaidTheme,
16
16
  /** Width of the text column, so diagrams are laid out at their printed size. */
17
- widthPx?: number): Promise<DiagramResult[]>;
17
+ widthPx?: number,
18
+ /** Text size of the document in CSS pixels, so diagram labels match the body text. */
19
+ fontPx?: number): Promise<DiagramResult[]>;
18
20
  /**
19
21
  * Replace the Mermaid placeholders in `html` with rendered SVGs, or with a visible error box.
20
22
  * Returns the new HTML and one error diagnostic per failed diagram.
@@ -5,6 +5,8 @@ import { escapeHtml } from "../html.js";
5
5
  export { MERMAID_THEMES } from "../options.js";
6
6
  /** Width of the text column on A4 with 20 mm margins. */
7
7
  const DEFAULT_WIDTH_PX = 642;
8
+ /** 10.5pt, the built-in themes' text size (Mermaid's own default is 16px). */
9
+ const DEFAULT_FONT_PX = 14;
8
10
  let mermaidJs;
9
11
  /**
10
12
  * Render Mermaid diagrams to SVG in a blank page that holds no user content: only the diagram
@@ -12,7 +14,9 @@ let mermaidJs;
12
14
  */
13
15
  export async function renderMermaid(session, blocks, theme = "neutral",
14
16
  /** Width of the text column, so diagrams are laid out at their printed size. */
15
- widthPx = DEFAULT_WIDTH_PX) {
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) {
16
20
  mermaidJs ??= readTextAsset(MERMAID_JS);
17
21
  const page = await session.newPage();
18
22
  try {
@@ -20,7 +24,7 @@ widthPx = DEFAULT_WIDTH_PX) {
20
24
  body { margin: 0; width: ${widthPx}px; font-family: "Inter", sans-serif; }</style>
21
25
  </head><body></body></html>`);
22
26
  await page.evaluate(mermaidJs);
23
- return await page.evaluate(async (sources, theme) => {
27
+ return await page.evaluate(async (sources, theme, fontPx) => {
24
28
  // Runs in the browser. The project is compiled without DOM types, hence PageGlobals.
25
29
  const { document, mermaid } = globalThis;
26
30
  // Load the font weights Mermaid uses before it measures any text.
@@ -30,8 +34,12 @@ body { margin: 0; width: ${widthPx}px; font-family: "Inter", sans-serif; }</styl
30
34
  securityLevel: "strict",
31
35
  theme,
32
36
  fontFamily: "Inter, sans-serif",
33
- themeVariables: { 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` },
34
40
  htmlLabels: true,
41
+ // Actors only at the top of sequence diagrams: repeating them below costs height.
42
+ sequence: { mirrorActors: false },
35
43
  });
36
44
  const results = [];
37
45
  for (const [i, source] of sources.entries()) {
@@ -44,7 +52,7 @@ body { margin: 0; width: ${widthPx}px; font-family: "Inter", sans-serif; }</styl
44
52
  }
45
53
  }
46
54
  return results;
47
- }, blocks.map((b) => b.source), theme);
55
+ }, blocks.map((b) => b.source), theme, fontPx);
48
56
  }
49
57
  finally {
50
58
  await page.close();
@@ -5,3 +5,8 @@ export declare function contentWidthPx(pageSize: PageSize, landscape: boolean, m
5
5
  left: string;
6
6
  right: string;
7
7
  }): number;
8
+ /** Height of the text area in CSS pixels: the paper minus the top and bottom margins. */
9
+ export declare function contentHeightPx(pageSize: PageSize, landscape: boolean, margin: {
10
+ top: string;
11
+ bottom: string;
12
+ }): number;
@@ -20,4 +20,10 @@ export function contentWidthPx(pageSize, landscape, margin) {
20
20
  const paperPx = ((landscape ? height : width) * 96) / 25.4;
21
21
  return Math.round(paperPx - lengthToPx(margin.left) - lengthToPx(margin.right));
22
22
  }
23
+ /** Height of the text area in CSS pixels: the paper minus the top and bottom margins. */
24
+ export function contentHeightPx(pageSize, landscape, margin) {
25
+ const [width, height] = PAPER_MM[pageSize];
26
+ const paperPx = ((landscape ? width : height) * 96) / 25.4;
27
+ return Math.round(paperPx - lengthToPx(margin.top) - lengthToPx(margin.bottom));
28
+ }
23
29
  //# sourceMappingURL=page.js.map
@@ -2,6 +2,8 @@ import { escapeHtml } from "../html.js";
2
2
  export { escapeHtml };
3
3
  /** Stylesheets of a built-in theme ("light" or "dark"). */
4
4
  export declare function builtinThemeCss(theme: "light" | "dark"): string[];
5
+ /** Stylesheet for a density other than "normal", applied after the theme. */
6
+ export declare function densityCss(density: "normal" | "compact"): string[];
5
7
  export interface DocumentParts {
6
8
  title: string;
7
9
  body: string;
@@ -15,7 +17,9 @@ export interface DocumentParts {
15
17
  showTitle?: boolean;
16
18
  /** Theme stylesheet contents. Defaults to the built-in light theme. */
17
19
  themeCss?: string[];
18
- /** Extra stylesheet contents, applied after the theme. */
20
+ /** Layout stylesheets (density, font size, page height), applied after the theme. */
21
+ layoutCss?: string[];
22
+ /** Extra stylesheet contents, applied after the theme and layout. */
19
23
  extraCss?: string[];
20
24
  }
21
25
  /** Wrap a rendered Markdown fragment in a complete, self-contained HTML document. */
@@ -17,6 +17,10 @@ function asset(name) {
17
17
  export function builtinThemeCss(theme) {
18
18
  return theme === "dark" ? [asset("theme.css"), asset("theme-dark.css")] : [asset("theme.css")];
19
19
  }
20
+ /** Stylesheet for a density other than "normal", applied after the theme. */
21
+ export function densityCss(density) {
22
+ return density === "compact" ? [asset("density-compact.css")] : [];
23
+ }
20
24
  function titleBlock({ title, subtitle, author, date }) {
21
25
  const meta = [author, date].filter(Boolean).map((part) => escapeHtml(part));
22
26
  return [
@@ -31,9 +35,15 @@ function titleBlock({ title, subtitle, author, date }) {
31
35
  }
32
36
  /** Wrap a rendered Markdown fragment in a complete, self-contained HTML document. */
33
37
  export function buildHtmlDocument(parts) {
34
- const { title, body, math = false, lang, author, extraCss = [] } = parts;
38
+ const { title, body, math = false, lang, author, layoutCss = [], extraCss = [] } = parts;
35
39
  const themeCss = parts.themeCss ?? builtinThemeCss("light");
36
- const styles = [fontFaceCss(), ...(math ? [katexCss()] : []), ...themeCss, ...extraCss];
40
+ const styles = [
41
+ fontFaceCss(),
42
+ ...(math ? [katexCss()] : []),
43
+ ...themeCss,
44
+ ...layoutCss,
45
+ ...extraCss,
46
+ ];
37
47
  return `<!doctype html>
38
48
  <html${lang ? ` lang="${escapeHtml(lang)}"` : ""}>
39
49
  <head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imontene/mdrender",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",