@imontene/mdrender 2.9.0 → 2.11.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.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The finish mdrender gives Mermaid diagrams, in every theme:
3
+ *
4
+ * - shapes with slightly rounded corners: rectangles (nodes, subgraphs, sequence actors and
5
+ * notes) through CSS `rx`/`ry`, and polygon nodes (decision, hexagon, parallelogram,
6
+ * trapezoid) redrawn as paths with eased corners, since an SVG polygon has no corner radius;
7
+ * - subgraph titles in bold;
8
+ * - shape and subgraph fills kept in their colours but partly transparent, so the page and its
9
+ * watermarks show through.
10
+ *
11
+ * The CSS goes to Mermaid as `themeCSS`, so it applies while Mermaid measures labels: a bold
12
+ * subgraph title gets the room it needs. Polygons are rounded after rendering, which leaves the
13
+ * diagram's size unchanged.
14
+ */
15
+ /** Corner radius of rectangles and polygon nodes, in CSS px. */
16
+ export const CORNER_RADIUS = 5;
17
+ /** Opacity of shape and subgraph fills (strokes and text stay opaque). */
18
+ export const FILL_OPACITY = 0.72;
19
+ const SHAPES = [
20
+ ".node rect",
21
+ ".node polygon",
22
+ ".node circle",
23
+ ".node ellipse",
24
+ ".node path.label-container",
25
+ // Hand-drawn shapes (flag, cylinder…) carry the class on their group: fill-opacity inherits.
26
+ ".node .outer-path",
27
+ ".cluster rect",
28
+ "rect.actor",
29
+ "rect.note",
30
+ ];
31
+ export const MERMAID_THEME_CSS = [
32
+ `.node rect, .cluster rect, rect.actor, rect.note { rx: ${CORNER_RADIUS}px; ry: ${CORNER_RADIUS}px; }`,
33
+ `${SHAPES.join(", ")} { fill-opacity: ${FILL_OPACITY}; }`,
34
+ ".cluster-label, .cluster-label * { font-weight: 700; }",
35
+ // Mermaid's "neo" look drops a shadow under shapes; under a see-through fill it would show
36
+ // through the whole shape and grey it, hiding the page behind instead of revealing it.
37
+ `${SHAPES.join(", ")}, .node .outer-path path { filter: none !important; }`,
38
+ ].join("\n");
39
+ function parsePoints(points) {
40
+ const numbers = points
41
+ .trim()
42
+ .split(/[\s,]+/)
43
+ .map(Number);
44
+ if (numbers.length < 6 || numbers.length % 2 !== 0 || numbers.some((n) => !Number.isFinite(n))) {
45
+ return undefined;
46
+ }
47
+ const result = [];
48
+ for (let i = 0; i < numbers.length; i += 2)
49
+ result.push([numbers[i], numbers[i + 1]]);
50
+ return result;
51
+ }
52
+ const round = (n) => Math.round(n * 100) / 100;
53
+ /**
54
+ * Path data for a closed polygon whose corners are eased with quadratic curves of `radius`
55
+ * (smaller where an edge is too short for it: at most a third of each adjacent edge).
56
+ */
57
+ export function roundedPolygonPath(points, radius = CORNER_RADIUS) {
58
+ const n = points.length;
59
+ const corners = points.map((p, i) => {
60
+ const prev = points[(i - 1 + n) % n];
61
+ const next = points[(i + 1) % n];
62
+ const toPrev = Math.hypot(prev[0] - p[0], prev[1] - p[1]);
63
+ const toNext = Math.hypot(next[0] - p[0], next[1] - p[1]);
64
+ const r = Math.min(radius, toPrev / 3, toNext / 3);
65
+ const along = (q, d) => d === 0 ? p : [p[0] + ((q[0] - p[0]) * r) / d, p[1] + ((q[1] - p[1]) * r) / d];
66
+ return { start: along(prev, toPrev), corner: p, end: along(next, toNext) };
67
+ });
68
+ const fmt = (p) => `${round(p[0])},${round(p[1])}`;
69
+ const parts = [`M${fmt(corners[0].end)}`];
70
+ for (let i = 1; i <= n; i++) {
71
+ const c = corners[i % n];
72
+ parts.push(`L${fmt(c.start)}`, `Q${fmt(c.corner)} ${fmt(c.end)}`);
73
+ }
74
+ return parts.join(" ") + " Z";
75
+ }
76
+ const near = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]) < 0.5;
77
+ /** Distance from q to the line through a and b. */
78
+ function offLine(a, b, q) {
79
+ const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
80
+ if (length === 0)
81
+ return Math.hypot(q[0] - a[0], q[1] - a[1]);
82
+ return Math.abs((b[0] - a[0]) * (a[1] - q[1]) - (a[0] - q[0]) * (b[1] - a[1])) / length;
83
+ }
84
+ /**
85
+ * The corners of a closed shape drawn only with straight lines written as cubic segments, as
86
+ * Mermaid draws its flag and other hand-drawn polygons: the fill as one subpath, the outline as
87
+ * each side drawn twice ("M a C … b M a C … b M b C … c …"). Undefined for any path with a curve,
88
+ * another command, a gap or an open end.
89
+ */
90
+ export function straightPathCorners(d) {
91
+ const tokens = d.match(/[a-z]|-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) ?? [];
92
+ const segments = [];
93
+ let current;
94
+ let i = 0;
95
+ const take = () => {
96
+ const x = Number(tokens[i]);
97
+ const y = Number(tokens[i + 1]);
98
+ i += 2;
99
+ return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : undefined;
100
+ };
101
+ while (i < tokens.length) {
102
+ const command = tokens[i++];
103
+ if (command === "M") {
104
+ current = take();
105
+ if (!current)
106
+ return undefined;
107
+ }
108
+ else if (command === "C" && current) {
109
+ // One or more segments after a single "C".
110
+ do {
111
+ const c1 = take();
112
+ const c2 = take();
113
+ const to = take();
114
+ if (!c1 || !c2 || !to)
115
+ return undefined;
116
+ if (offLine(current, to, c1) > 0.5 || offLine(current, to, c2) > 0.5)
117
+ return undefined;
118
+ segments.push([current, to]);
119
+ current = to;
120
+ } while (i < tokens.length && !/[a-z]/i.test(tokens[i]));
121
+ }
122
+ else if (command === "Z" || command === "z") {
123
+ continue;
124
+ }
125
+ else {
126
+ return undefined;
127
+ }
128
+ }
129
+ // Drop each side's repeated stroke, then the sides must chain into a closed shape.
130
+ const sides = segments.filter(([from, to], k) => k === 0 || !(near(from, segments[k - 1][0]) && near(to, segments[k - 1][1])));
131
+ if (sides.length < 3)
132
+ return undefined;
133
+ for (let k = 1; k < sides.length; k++)
134
+ if (!near(sides[k][0], sides[k - 1][1]))
135
+ return undefined;
136
+ if (!near(sides.at(-1)[1], sides[0][0]))
137
+ return undefined;
138
+ const corners = sides.map(([from]) => from);
139
+ // Drop points that do not turn (several segments along one side).
140
+ const turning = corners.filter((p, k) => {
141
+ const prev = corners[(k - 1 + corners.length) % corners.length];
142
+ const next = corners[(k + 1) % corners.length];
143
+ return offLine(prev, next, p) > 0.5;
144
+ });
145
+ return turning.length >= 3 ? turning : undefined;
146
+ }
147
+ const NODE_PATH = /<path\b([^>]*?)\sd="([^"]*)"/g;
148
+ const NODE_POLYGON = /<polygon\b([^>]*?)\spoints="([^"]*)"([^>]*?)(\/?)>(<\/polygon>)?/g;
149
+ /**
150
+ * Round the corners of node polygons: `<polygon class="label-container">` (decision, hexagon,
151
+ * parallelogram, trapezoid) and closed shapes drawn only with straight segments (the flag).
152
+ * Other polygons and paths (arrowheads, edges, curved shapes) are left alone.
153
+ */
154
+ export function roundNodePolygons(svg, radius = CORNER_RADIUS) {
155
+ return svg
156
+ .replace(NODE_POLYGON, (tag, before, points, after) => {
157
+ if (!/\bclass="[^"]*\blabel-container\b/.test(before + after))
158
+ return tag;
159
+ const parsed = parsePoints(points);
160
+ if (!parsed)
161
+ return tag;
162
+ return `<path${before} d="${roundedPolygonPath(parsed, radius)}"${after}></path>`;
163
+ })
164
+ .replace(NODE_PATH, (tag, before, d) => {
165
+ const corners = straightPathCorners(d);
166
+ return corners ? `<path${before} d="${roundedPolygonPath(corners, radius)}"` : tag;
167
+ });
168
+ }
169
+ //# sourceMappingURL=mermaid-style.js.map
@@ -1,6 +1,7 @@
1
1
  import { MERMAID_JS, readTextAsset } from "../assets.js";
2
2
  import { mermaidPlaceholder } from "../markdown/mermaid.js";
3
3
  import { FONT_FAMILIES, fontFaceCss, fontStack } from "./fonts.js";
4
+ import { MERMAID_THEME_CSS, roundNodePolygons } from "./mermaid-style.js";
4
5
  import { escapeHtml } from "../html.js";
5
6
  import { authorConfigured, candidate, chooseCandidate, flipWorthTrying, flippedDirection, flowchartHeader, withDirection, WRAPPING_WIDTHS, wrapsExhausted, } from "./mermaid-fit.js";
6
7
  export { MERMAID_THEMES } from "../options.js";
@@ -25,6 +26,8 @@ function mermaidConfig(theme, font, fontPx, wrap, themeVariables = {}) {
25
26
  fontSize: fontPx,
26
27
  themeVariables: { ...themeVariables, fontFamily, fontSize: `${fontPx}px` },
27
28
  htmlLabels: true,
29
+ // Rounded corners, bold subgraph titles, see-through fills (render/mermaid-style.ts).
30
+ themeCSS: MERMAID_THEME_CSS,
28
31
  // Actors only at the top of sequence diagrams: repeating them below costs height.
29
32
  sequence: { mirrorActors: false },
30
33
  ...(wrap === undefined ? {} : { flowchart: { wrappingWidth: wrap } }),
@@ -51,7 +54,8 @@ body { margin: 0; width: ${widthPx}px; font-family: ${fontStack(font)}; }</style
51
54
  // Load the font weights Mermaid uses before it measures any text.
52
55
  await Promise.all(["400", "600", "700"].map((w) => document.fonts.load(`${w} 16px "${family}"`)));
53
56
  }, family);
54
- const render = (source, id, wrap) => page.evaluate(async (source, id, config) => {
57
+ const render = (source, id, wrap) => page
58
+ .evaluate(async (source, id, config) => {
55
59
  const { mermaid } = globalThis;
56
60
  // Each render sets the whole configuration, so one layout never leaks into the next.
57
61
  mermaid.initialize(config);
@@ -61,7 +65,10 @@ body { margin: 0; width: ${widthPx}px; font-family: ${fontStack(font)}; }</style
61
65
  catch (error) {
62
66
  return { error: String(error?.message ?? error) };
63
67
  }
64
- }, source, id, mermaidConfig(theme, font, fontPx, wrap, options.themeVariables));
68
+ }, source, id, mermaidConfig(theme, font, fontPx, wrap, options.themeVariables))
69
+ .then((result) =>
70
+ // Rounding the polygons keeps each diagram's size, so autofit measures it the same.
71
+ "svg" in result ? { svg: roundNodePolygons(result.svg) } : result);
65
72
  const results = [];
66
73
  for (const [i, block] of blocks.entries()) {
67
74
  const id = `mermaid-${i + 1}`;
@@ -1,4 +1,6 @@
1
1
  import type { PageSize } from "../options.js";
2
+ /** Width and height of the paper in millimetres, as printed. */
3
+ export declare function paperSizeMm(pageSize: PageSize, landscape: boolean): [number, number];
2
4
  export declare function lengthToPx(length: string): number;
3
5
  /** Exact width of the text column in CSS pixels, as Chrome prints it. */
4
6
  export declare function textWidthPx(pageSize: PageSize, landscape: boolean, margin: {
@@ -7,6 +7,11 @@ const PAPER_MM = {
7
7
  Legal: [215.9, 355.6],
8
8
  Tabloid: [279.4, 431.8],
9
9
  };
10
+ /** Width and height of the paper in millimetres, as printed. */
11
+ export function paperSizeMm(pageSize, landscape) {
12
+ const [width, height] = PAPER_MM[pageSize];
13
+ return landscape ? [height, width] : [width, height];
14
+ }
10
15
  const PX_PER_UNIT = { px: 1, in: 96, cm: 96 / 2.54, mm: 96 / 25.4 };
11
16
  export function lengthToPx(length) {
12
17
  const match = /^(\d+(?:\.\d+)?)(mm|cm|in|px)$/.exec(length);
@@ -1,5 +1,5 @@
1
1
  import { escapeHtml } from "../html.js";
2
- import type { BuiltinTheme, TextFont } from "../options.js";
2
+ import { type BuiltinTheme, type TextFont } from "../options.js";
3
3
  export { escapeHtml };
4
4
  /** Stylesheets of a built-in theme, with the fonts it adds to the text and code ones. */
5
5
  export declare function builtinThemeCss(theme: BuiltinTheme): string[];
@@ -21,6 +21,8 @@ export interface DocumentParts {
21
21
  abstract?: string;
22
22
  keywords?: string[];
23
23
  showTitle?: boolean;
24
+ /** Extra classes of the title block (the magazine cover's ink and text position). */
25
+ headerClass?: string;
24
26
  /** Theme stylesheet contents. Defaults to the built-in light theme. */
25
27
  themeCss?: string[];
26
28
  /** Text font to embed. Defaults to Manrope. */
@@ -1,7 +1,8 @@
1
1
  import { readTextAsset } from "../assets.js";
2
2
  import { escapeHtml } from "../html.js";
3
3
  import { DEFAULT_LANG, strings } from "../i18n.js";
4
- import { fontFaceCss, serifFontFaceCss } from "./fonts.js";
4
+ import { fontFaceCss, magazineFontFaceCss, serifFontFaceCss } from "./fonts.js";
5
+ import { isMagazineTheme } from "../options.js";
5
6
  import { katexCss } from "./katex.js";
6
7
  import { version } from "../version.js";
7
8
  export { escapeHtml };
@@ -21,11 +22,17 @@ const THEME_FILES = {
21
22
  light: ["theme.css"],
22
23
  dark: ["theme.css", "theme-dark.css"],
23
24
  academic_basic: ["theme.css", "theme-academic-basic.css"],
25
+ tech_magazine: ["theme.css", "theme-tech-magazine.css"],
26
+ "tech_magazine-dark": ["theme.css", "theme-tech-magazine.css", "theme-tech-magazine-dark.css"],
24
27
  };
25
28
  /** Stylesheets of a built-in theme, with the fonts it adds to the text and code ones. */
26
29
  export function builtinThemeCss(theme) {
27
30
  const css = THEME_FILES[theme].map(asset);
28
- return theme === "academic_basic" ? [serifFontFaceCss(), ...css] : css;
31
+ if (theme === "academic_basic")
32
+ return [serifFontFaceCss(), ...css];
33
+ if (isMagazineTheme(theme))
34
+ return [magazineFontFaceCss(), ...css];
35
+ return css;
29
36
  }
30
37
  /** Stylesheet for a density other than "normal", applied after the theme. */
31
38
  export function densityCss(density) {
@@ -47,7 +54,7 @@ function titleBlock(parts) {
47
54
  const meta = [author ? withSuperscripts(author) : undefined, date ? escapeHtml(date) : undefined];
48
55
  const metaText = meta.filter(Boolean).join(" · ");
49
56
  return [
50
- `<header class="doc-header">`,
57
+ `<header class="doc-header${parts.headerClass ? ` ${escapeHtml(parts.headerClass)}` : ""}">`,
51
58
  kicker ? `<p class="doc-kicker">${escapeHtml(kicker)}</p>` : "",
52
59
  `<h1 class="doc-title">${escapeHtml(title)}</h1>`,
53
60
  subtitle ? `<p class="doc-subtitle">${escapeHtml(subtitle)}</p>` : "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imontene/mdrender",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "Command-line app for Windows and Linux that renders Markdown — with LaTeX equations and Mermaid diagrams — to PDF and HTML set in Manrope or Inter",
5
5
  "keywords": [
6
6
  "markdown",
@@ -40,7 +40,8 @@
40
40
  "!dist/**/*.map",
41
41
  "README.md",
42
42
  "LICENSE",
43
- "CHANGELOG.md"
43
+ "CHANGELOG.md",
44
+ "tech_magazine"
44
45
  ],
45
46
  "engines": {
46
47
  "node": ">=22.12"
@@ -67,6 +68,7 @@
67
68
  "dependencies": {
68
69
  "@fontsource/jetbrains-mono": "^5.3.0",
69
70
  "@fontsource/manrope": "^5.3.0",
71
+ "@fontsource/newsreader": "^5.3.0",
70
72
  "@fontsource/stix-two-text": "^5.3.0",
71
73
  "@puppeteer/browsers": "^3.2.3",
72
74
  "chokidar": "^5.0.0",
@@ -0,0 +1,9 @@
1
+ {
2
+ "accent": "#2f6bff",
3
+ "covers": {
4
+ "1": { "ink": "dark", "veil": "rgba(255, 255, 255, 0.6)" },
5
+ "2": { "ink": "light", "veil": "rgba(8, 14, 28, 0.85)" },
6
+ "3": { "ink": "dark", "veil": "rgba(246, 238, 228, 0.65)" },
7
+ "4": { "ink": "light", "veil": "rgba(20, 10, 4, 0.65)" }
8
+ }
9
+ }