@imontene/mdrender 1.0.1 → 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 +33 -0
- package/README.md +88 -29
- package/dist/assets/density-compact.css +74 -0
- package/dist/assets/theme.css +19 -0
- package/dist/assets.js +5 -1
- package/dist/cli.js +48 -3
- package/dist/config.d.ts +7 -0
- package/dist/config.js +11 -2
- package/dist/convert.d.ts +7 -1
- package/dist/convert.js +57 -16
- package/dist/doctor.d.ts +30 -0
- package/dist/doctor.js +278 -0
- package/dist/inputs.js +15 -2
- package/dist/markdown/code-blocks.d.ts +9 -0
- package/dist/markdown/code-blocks.js +21 -0
- package/dist/markdown/index.js +3 -0
- package/dist/options.d.ts +44 -8
- package/dist/options.js +107 -5
- package/dist/render/browser.d.ts +4 -1
- package/dist/render/browser.js +8 -4
- package/dist/render/mermaid.d.ts +3 -1
- package/dist/render/mermaid.js +12 -4
- package/dist/render/page.d.ts +5 -0
- package/dist/render/page.js +6 -0
- package/dist/render/template.d.ts +5 -1
- package/dist/render/template.js +12 -2
- package/dist/write.d.ts +9 -0
- package/dist/write.js +41 -0
- package/package.json +1 -1
package/dist/render/browser.js
CHANGED
|
@@ -93,8 +93,9 @@ export class BrowserSession {
|
|
|
93
93
|
* rendered by then (math by KaTeX, diagrams by Mermaid), and any <script> in the Markdown
|
|
94
94
|
* must not run.
|
|
95
95
|
*/
|
|
96
|
+
/** Returns the PDF bytes (the caller writes them) and the URLs that were blocked. */
|
|
96
97
|
async printPdf(html, options) {
|
|
97
|
-
const margin = options.margin ?? { top: "
|
|
98
|
+
const margin = options.margin ?? { top: "10mm", right: "10mm", bottom: "10mm", left: "10mm" };
|
|
98
99
|
const headerFooter = options.headerTemplate !== undefined || options.footerTemplate !== undefined;
|
|
99
100
|
const page = await this.browser.newPage();
|
|
100
101
|
page.setDefaultTimeout(this.timeout);
|
|
@@ -104,8 +105,7 @@ export class BrowserSession {
|
|
|
104
105
|
await page.setContent(html, { waitUntil: "load" });
|
|
105
106
|
// Make sure every embedded font has loaded before printing.
|
|
106
107
|
await page.evaluate("document.fonts.ready.then(() => undefined)");
|
|
107
|
-
await page.pdf({
|
|
108
|
-
path: options.output,
|
|
108
|
+
const pdf = await page.pdf({
|
|
109
109
|
format: options.format ?? "A4",
|
|
110
110
|
landscape: options.landscape ?? false,
|
|
111
111
|
margin,
|
|
@@ -118,7 +118,7 @@ export class BrowserSession {
|
|
|
118
118
|
tagged: true,
|
|
119
119
|
timeout: this.timeout,
|
|
120
120
|
});
|
|
121
|
-
return { blocked: [...new Set(blocked)] };
|
|
121
|
+
return { pdf, blocked: [...new Set(blocked)] };
|
|
122
122
|
}
|
|
123
123
|
catch (error) {
|
|
124
124
|
throw new RenderError(`failed to render PDF: ${error.message}`);
|
|
@@ -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
|
}
|
package/dist/render/mermaid.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/render/mermaid.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/render/page.d.ts
CHANGED
|
@@ -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;
|
package/dist/render/page.js
CHANGED
|
@@ -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
|
-
/**
|
|
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. */
|
package/dist/render/template.js
CHANGED
|
@@ -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 = [
|
|
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/dist/write.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a file all at once: the content goes to a hidden temporary file in the same folder,
|
|
3
|
+
* which then replaces the target. An interrupted or failed conversion never leaves a truncated
|
|
4
|
+
* file and keeps the previous version.
|
|
5
|
+
*
|
|
6
|
+
* On Windows a file open in another program (a PDF viewer) cannot be replaced; short-lived locks
|
|
7
|
+
* (antivirus, indexing) are retried before reporting it.
|
|
8
|
+
*/
|
|
9
|
+
export declare function writeFileAtomic(file: string, data: string | Uint8Array): Promise<void>;
|
package/dist/write.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { RenderError } from "./errors.js";
|
|
5
|
+
const BUSY = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
6
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
/**
|
|
8
|
+
* Write a file all at once: the content goes to a hidden temporary file in the same folder,
|
|
9
|
+
* which then replaces the target. An interrupted or failed conversion never leaves a truncated
|
|
10
|
+
* file and keeps the previous version.
|
|
11
|
+
*
|
|
12
|
+
* On Windows a file open in another program (a PDF viewer) cannot be replaced; short-lived locks
|
|
13
|
+
* (antivirus, indexing) are retried before reporting it.
|
|
14
|
+
*/
|
|
15
|
+
export async function writeFileAtomic(file, data) {
|
|
16
|
+
const dir = path.dirname(file);
|
|
17
|
+
await mkdir(dir, { recursive: true });
|
|
18
|
+
const temp = path.join(dir, `.${path.basename(file)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
19
|
+
try {
|
|
20
|
+
await writeFile(temp, data);
|
|
21
|
+
for (let attempt = 1;; attempt++) {
|
|
22
|
+
try {
|
|
23
|
+
await rename(temp, file);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
const code = error.code ?? "";
|
|
28
|
+
if (!BUSY.has(code) || attempt >= 5)
|
|
29
|
+
throw error;
|
|
30
|
+
await sleep(100 * attempt);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
await rm(temp, { force: true });
|
|
36
|
+
const code = error.code ?? "";
|
|
37
|
+
const hint = BUSY.has(code) ? " (is it open in another program, such as a PDF viewer?)" : "";
|
|
38
|
+
throw new RenderError(`cannot write ${file}: ${error.message}${hint}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=write.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imontene/mdrender",
|
|
3
|
-
"version": "1.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",
|