@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.
- package/CHANGELOG.md +29 -0
- package/README.md +102 -39
- package/dist/assets/theme-tech-magazine-dark.css +19 -0
- package/dist/assets/theme-tech-magazine.css +535 -0
- package/dist/assets.d.ts +19 -0
- package/dist/assets.js +48 -2
- package/dist/cli.js +2 -2
- package/dist/convert.js +36 -8
- package/dist/options.d.ts +18 -1
- package/dist/options.js +43 -1
- package/dist/render/fonts.d.ts +4 -0
- package/dist/render/fonts.js +12 -1
- package/dist/render/header-footer.d.ts +2 -0
- package/dist/render/header-footer.js +1 -1
- package/dist/render/magazine.d.ts +48 -0
- package/dist/render/magazine.js +211 -0
- package/dist/render/mermaid-style.d.ts +39 -0
- package/dist/render/mermaid-style.js +169 -0
- package/dist/render/mermaid.js +9 -2
- package/dist/render/page.d.ts +2 -0
- package/dist/render/page.js +5 -0
- package/dist/render/template.d.ts +3 -1
- package/dist/render/template.js +10 -3
- package/package.json +4 -2
- package/tech_magazine/AlphaZeroChess/cover01.png +0 -0
- package/tech_magazine/AlphaZeroChess/cover02.png +0 -0
- package/tech_magazine/AlphaZeroChess/cover03.png +0 -0
- package/tech_magazine/AlphaZeroChess/cover04.png +0 -0
- package/tech_magazine/AlphaZeroChess/magazine.json +9 -0
package/dist/convert.js
CHANGED
|
@@ -7,13 +7,14 @@ 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 { BUILTIN_THEMES, DEFAULT_FONT_SIZE, fontSizePx, isDarkTheme, mergeOptions, normalizeOptions, OptionsError, resolveMargins, } from "./options.js";
|
|
10
|
+
import { BUILTIN_THEMES, DEFAULT_FONT_SIZE, fontSizePx, isDarkTheme, isMagazineTheme, 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
14
|
import { SMALL_SCALE } from "./render/mermaid-fit.js";
|
|
15
15
|
import { headerFooterStyle, isAntigravityTheme, mermaidThemeVariables, } from "./render/antigravity.js";
|
|
16
16
|
import { fieldImage } from "./render/field.js";
|
|
17
|
+
import { magazinePage } from "./render/magazine.js";
|
|
17
18
|
import { FONT_FAMILIES, fontStack } from "./render/fonts.js";
|
|
18
19
|
import { WATERMARK_CSS, watermarkHtml } from "./render/watermark.js";
|
|
19
20
|
import { contentHeightPx, contentWidthPx, lengthToPx, textWidthPx } from "./render/page.js";
|
|
@@ -182,7 +183,7 @@ paged = false) {
|
|
|
182
183
|
lang: options.lang,
|
|
183
184
|
// Soft hyphens only where paragraphs are justified: in the PDF.
|
|
184
185
|
hyphenate: paged && options.justify ? options.lang : undefined,
|
|
185
|
-
figures: options.theme === "academic_basic",
|
|
186
|
+
figures: options.theme === "academic_basic" || isMagazineTheme(options.theme),
|
|
186
187
|
});
|
|
187
188
|
const diagnostics = [
|
|
188
189
|
...config.warnings.map((message) => ({ severity: "warning", message })),
|
|
@@ -239,6 +240,26 @@ paged = false) {
|
|
|
239
240
|
});
|
|
240
241
|
}
|
|
241
242
|
const watermark = paged ? watermarks(options, margin) : "";
|
|
243
|
+
const showTitle = options.title !== undefined;
|
|
244
|
+
const magazine = isMagazineTheme(options.theme)
|
|
245
|
+
? magazinePage({
|
|
246
|
+
options,
|
|
247
|
+
margin,
|
|
248
|
+
baseDir,
|
|
249
|
+
hasCover: showTitle,
|
|
250
|
+
values: {
|
|
251
|
+
title,
|
|
252
|
+
author: options.author === undefined ? undefined : plainAuthor(options.author),
|
|
253
|
+
date: options.date,
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
: undefined;
|
|
257
|
+
if (magazine) {
|
|
258
|
+
diagnostics.push(...magazine.diagnostics);
|
|
259
|
+
if (magazine.coverNumber !== undefined) {
|
|
260
|
+
notes.push({ message: `cover: picture ${magazine.coverNumber}` });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
242
263
|
const html = buildHtmlDocument({
|
|
243
264
|
title,
|
|
244
265
|
body,
|
|
@@ -251,7 +272,8 @@ paged = false) {
|
|
|
251
272
|
affiliations: options.affiliations,
|
|
252
273
|
abstract: options.abstract,
|
|
253
274
|
keywords: options.keywords,
|
|
254
|
-
showTitle
|
|
275
|
+
showTitle,
|
|
276
|
+
headerClass: magazine?.headerClass,
|
|
255
277
|
themeCss,
|
|
256
278
|
font: options.font,
|
|
257
279
|
beforeBody: watermark || undefined,
|
|
@@ -259,6 +281,7 @@ paged = false) {
|
|
|
259
281
|
...densityCss(options.density),
|
|
260
282
|
layoutCss(options, margin),
|
|
261
283
|
...(watermark ? [WATERMARK_CSS] : []),
|
|
284
|
+
...(magazine ? [magazine.css] : []),
|
|
262
285
|
],
|
|
263
286
|
extraCss,
|
|
264
287
|
});
|
|
@@ -281,17 +304,22 @@ export async function convertFile(convert) {
|
|
|
281
304
|
const margin = resolveMargins(options);
|
|
282
305
|
const author = options.author === undefined ? undefined : plainAuthor(options.author);
|
|
283
306
|
const values = { title: doc.title, author, date: options.date };
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
307
|
+
// The magazine themes draw margins, header and footer in their stylesheet (magazine.ts).
|
|
308
|
+
const magazine = isMagazineTheme(options.theme);
|
|
309
|
+
const footer = magazine
|
|
310
|
+
? undefined
|
|
311
|
+
: (options.footer ?? (options.pageNumbers ? DEFAULT_FOOTER : undefined));
|
|
312
|
+
const header = magazine ? undefined : options.header;
|
|
313
|
+
const hasHeaderFooter = header !== undefined || footer !== undefined;
|
|
314
|
+
doc.diagnostics.push(...headerFooterSpaceWarnings(margin, header !== undefined, footer !== undefined));
|
|
287
315
|
const pageLook = headerFooterStyle(options.theme);
|
|
288
316
|
const session = await browsers.get();
|
|
289
317
|
const { pdf, blocked } = await session.printPdf(doc.html, {
|
|
290
318
|
format: options.pageSize,
|
|
291
319
|
landscape: options.orientation === "landscape",
|
|
292
|
-
margin,
|
|
320
|
+
margin: magazine ? { top: "0mm", right: "0mm", bottom: "0mm", left: "0mm" } : margin,
|
|
293
321
|
headerTemplate: hasHeaderFooter
|
|
294
|
-
? headerFooterTemplate(
|
|
322
|
+
? headerFooterTemplate(header, values, margin, options.font, pageLook)
|
|
295
323
|
: undefined,
|
|
296
324
|
footerTemplate: hasHeaderFooter
|
|
297
325
|
? headerFooterTemplate(footer, values, margin, options.font, pageLook)
|
package/dist/options.d.ts
CHANGED
|
@@ -13,10 +13,17 @@ export declare const MERMAID_THEMES: readonly ["default", "neutral", "dark", "fo
|
|
|
13
13
|
export type MermaidTheme = (typeof MERMAID_THEMES)[number];
|
|
14
14
|
export declare const MERMAID_FLIPS: readonly ["auto", "never"];
|
|
15
15
|
export type MermaidFlip = (typeof MERMAID_FLIPS)[number];
|
|
16
|
-
export declare const BUILTIN_THEMES: readonly ["antigravity", "antigravity-dark", "light", "dark", "academic_basic"];
|
|
16
|
+
export declare const BUILTIN_THEMES: readonly ["antigravity", "antigravity-dark", "light", "dark", "academic_basic", "tech_magazine", "tech_magazine-dark"];
|
|
17
17
|
export type BuiltinTheme = (typeof BUILTIN_THEMES)[number];
|
|
18
18
|
/** Built-in themes with a dark page. */
|
|
19
19
|
export declare const isDarkTheme: (theme: string) => boolean;
|
|
20
|
+
/** The magazine themes: a full-bleed cover and page margins drawn by the stylesheet. */
|
|
21
|
+
export declare const isMagazineTheme: (theme: string) => boolean;
|
|
22
|
+
export declare const COVER_TEXT_POSITIONS: readonly ["top", "bottom"];
|
|
23
|
+
export type CoverTextPosition = (typeof COVER_TEXT_POSITIONS)[number];
|
|
24
|
+
/** Colour of the cover text: dark ink for light pictures, light ink for dark ones. */
|
|
25
|
+
export declare const COVER_INKS: readonly ["dark", "light"];
|
|
26
|
+
export type CoverInk = (typeof COVER_INKS)[number];
|
|
20
27
|
export declare const TEXT_FONTS: readonly ["manrope", "inter"];
|
|
21
28
|
export type TextFont = (typeof TEXT_FONTS)[number];
|
|
22
29
|
export declare const DENSITIES: readonly ["normal", "compact"];
|
|
@@ -79,6 +86,16 @@ export interface DocumentOptions {
|
|
|
79
86
|
/** Summary shown under the title block (paragraphs separated by blank lines). */
|
|
80
87
|
abstract?: string;
|
|
81
88
|
keywords?: string[];
|
|
89
|
+
/** Magazine themes: folder of cover pictures under tech_magazine/, and the masthead. */
|
|
90
|
+
magazineName?: string;
|
|
91
|
+
/** Magazine themes: number of the cover picture; a random one when unset. */
|
|
92
|
+
cover?: number;
|
|
93
|
+
/** Magazine themes: where the title sits on the cover. */
|
|
94
|
+
coverText?: CoverTextPosition;
|
|
95
|
+
/** Magazine themes: colour of the cover text; the magazine's own setting for a cover wins. */
|
|
96
|
+
coverInk?: CoverInk;
|
|
97
|
+
/** Magazine themes: accent colour (drop cap, small headings, rules), e.g. "#2f6bff". */
|
|
98
|
+
accent?: string;
|
|
82
99
|
}
|
|
83
100
|
export declare const DEFAULT_OPTIONS: DocumentOptions;
|
|
84
101
|
/**
|
package/dist/options.js
CHANGED
|
@@ -12,9 +12,16 @@ export const BUILTIN_THEMES = [
|
|
|
12
12
|
"light",
|
|
13
13
|
"dark",
|
|
14
14
|
"academic_basic",
|
|
15
|
+
"tech_magazine",
|
|
16
|
+
"tech_magazine-dark",
|
|
15
17
|
];
|
|
16
18
|
/** Built-in themes with a dark page. */
|
|
17
|
-
export const isDarkTheme = (theme) => theme === "dark" || theme === "antigravity-dark";
|
|
19
|
+
export const isDarkTheme = (theme) => theme === "dark" || theme === "antigravity-dark" || theme === "tech_magazine-dark";
|
|
20
|
+
/** The magazine themes: a full-bleed cover and page margins drawn by the stylesheet. */
|
|
21
|
+
export const isMagazineTheme = (theme) => theme.startsWith("tech_magazine");
|
|
22
|
+
export const COVER_TEXT_POSITIONS = ["top", "bottom"];
|
|
23
|
+
/** Colour of the cover text: dark ink for light pictures, light ink for dark ones. */
|
|
24
|
+
export const COVER_INKS = ["dark", "light"];
|
|
18
25
|
export const TEXT_FONTS = ["manrope", "inter"];
|
|
19
26
|
export const DENSITIES = ["normal", "compact"];
|
|
20
27
|
export const DEFAULT_OPTIONS = {
|
|
@@ -34,12 +41,21 @@ export const DEFAULT_OPTIONS = {
|
|
|
34
41
|
css: [],
|
|
35
42
|
lang: DEFAULT_LANG,
|
|
36
43
|
};
|
|
44
|
+
/** The magazine themes: one wide ragged column in Inter on generous margins. */
|
|
45
|
+
const MAGAZINE_DEFAULTS = {
|
|
46
|
+
margin: "20mm 24mm 22mm",
|
|
47
|
+
fontSize: "10.5pt",
|
|
48
|
+
font: "inter",
|
|
49
|
+
justify: false,
|
|
50
|
+
};
|
|
37
51
|
/**
|
|
38
52
|
* Defaults of a built-in theme, applied under every other layer: the academic theme sets its
|
|
39
53
|
* two columns in smaller type on wider margins, with Inter for titles, captions and labels.
|
|
40
54
|
*/
|
|
41
55
|
export const THEME_DEFAULTS = {
|
|
42
56
|
academic_basic: { margin: "18mm 8mm", fontSize: "9.5pt", font: "inter" },
|
|
57
|
+
tech_magazine: MAGAZINE_DEFAULTS,
|
|
58
|
+
"tech_magazine-dark": MAGAZINE_DEFAULTS,
|
|
43
59
|
};
|
|
44
60
|
export class OptionsError extends MdRenderError {
|
|
45
61
|
constructor(message) {
|
|
@@ -76,6 +92,27 @@ const oneOf = (choices) => (value, describe) => {
|
|
|
76
92
|
return match;
|
|
77
93
|
throw new OptionsError(`${describe} must be one of: ${choices.join(", ")}`);
|
|
78
94
|
};
|
|
95
|
+
/** A CSS colour: #rgb, #rrggbb (with or without alpha), rgb()/hsl()/oklch() or a colour name. */
|
|
96
|
+
const COLOR = /^(#[0-9a-f]{3,4}|#[0-9a-f]{6}|#[0-9a-f]{8}|(rgb|rgba|hsl|hsla|oklch|oklab)\([\d\s.,%/+-]+\)|[a-z]+)$/i;
|
|
97
|
+
const color = (value, describe, baseDir) => {
|
|
98
|
+
const raw = text(value, describe, baseDir).trim();
|
|
99
|
+
if (COLOR.test(raw))
|
|
100
|
+
return raw;
|
|
101
|
+
throw new OptionsError(`${describe} must be a CSS colour, e.g. "#2f6bff"`);
|
|
102
|
+
};
|
|
103
|
+
/** A folder name under tech_magazine/: no path separators, no "..". */
|
|
104
|
+
const folderName = (value, describe, baseDir) => {
|
|
105
|
+
const raw = text(value, describe, baseDir).trim();
|
|
106
|
+
if (raw && !/[\\/]/.test(raw) && raw !== "." && raw !== "..")
|
|
107
|
+
return raw;
|
|
108
|
+
throw new OptionsError(`${describe} must be the name of a folder inside tech_magazine/`);
|
|
109
|
+
};
|
|
110
|
+
const coverNumber = (value, describe) => {
|
|
111
|
+
const number = Number(value);
|
|
112
|
+
if (Number.isInteger(number) && number >= 0)
|
|
113
|
+
return number;
|
|
114
|
+
throw new OptionsError(`${describe} must be the number of a cover picture, e.g. 2 or "02"`);
|
|
115
|
+
};
|
|
79
116
|
/** Text, or a list of texts (`keywords: [a, b]`). */
|
|
80
117
|
const textList = (value, describe, baseDir) => {
|
|
81
118
|
const list = Array.isArray(value) ? value : [value];
|
|
@@ -169,6 +206,11 @@ const VALIDATORS = {
|
|
|
169
206
|
affiliations: textList,
|
|
170
207
|
abstract: text,
|
|
171
208
|
keywords: textList,
|
|
209
|
+
magazineName: folderName,
|
|
210
|
+
cover: coverNumber,
|
|
211
|
+
coverText: oneOf(COVER_TEXT_POSITIONS),
|
|
212
|
+
coverInk: oneOf(COVER_INKS),
|
|
213
|
+
accent: color,
|
|
172
214
|
};
|
|
173
215
|
/**
|
|
174
216
|
* Validate options from a config file, front matter or the command line.
|
package/dist/render/fonts.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type { TextFont } from "../options.js";
|
|
|
10
10
|
* Code: JetBrains Mono from Fontsource (split into unicode-range subsets).
|
|
11
11
|
* Serif (academic theme only): STIX Two Text from Fontsource, the text face of the STIX fonts
|
|
12
12
|
* made for scientific publishing.
|
|
13
|
+
* Magazine themes: Inter Light and ExtraBold besides the text weights, and Newsreader from
|
|
14
|
+
* Fontsource for the drop caps, pull quotes and standfirst.
|
|
13
15
|
*/
|
|
14
16
|
export declare const FONT_FAMILIES: Record<TextFont, string>;
|
|
15
17
|
/** CSS font-family list for the text: the chosen font, Inter for missing glyphs, the system's. */
|
|
@@ -24,6 +26,8 @@ export declare function inlineFontStylesheet(cssKey: string): string;
|
|
|
24
26
|
export declare function fontFaceCss(font?: TextFont): string;
|
|
25
27
|
/** @font-face rules for STIX Two Text, the academic theme's serif, with the files embedded. */
|
|
26
28
|
export declare function serifFontFaceCss(): string;
|
|
29
|
+
/** @font-face rules the magazine themes add to the text and code fonts, files embedded. */
|
|
30
|
+
export declare function magazineFontFaceCss(): string;
|
|
27
31
|
/** JetBrains Mono Regular alone, for page headers/footers set in the mono family. */
|
|
28
32
|
export declare function monoRegularFontFace(): string;
|
|
29
33
|
/** The text font's regular weight alone, for page headers/footers (rendered apart from the page). */
|
package/dist/render/fonts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { INTER_DIR, INTER_FACES, MANROPE_STYLESHEETS, MONO_STYLESHEETS, readAsset, SERIF_STYLESHEETS, readTextAsset, woff2References, } from "../assets.js";
|
|
1
|
+
import { INTER_DIR, INTER_FACES, INTER_MAGAZINE_FACES, MANROPE_STYLESHEETS, MONO_STYLESHEETS, NEWSREADER_STYLESHEETS, readAsset, SERIF_STYLESHEETS, readTextAsset, woff2References, } from "../assets.js";
|
|
2
2
|
/**
|
|
3
3
|
* Static (non-variable) font files are used on purpose: Chromium embeds variable fonts in PDFs
|
|
4
4
|
* as Type 3 fonts, while static fonts are embedded as proper TrueType fonts.
|
|
@@ -10,6 +10,8 @@ import { INTER_DIR, INTER_FACES, MANROPE_STYLESHEETS, MONO_STYLESHEETS, readAsse
|
|
|
10
10
|
* Code: JetBrains Mono from Fontsource (split into unicode-range subsets).
|
|
11
11
|
* Serif (academic theme only): STIX Two Text from Fontsource, the text face of the STIX fonts
|
|
12
12
|
* made for scientific publishing.
|
|
13
|
+
* Magazine themes: Inter Light and ExtraBold besides the text weights, and Newsreader from
|
|
14
|
+
* Fontsource for the drop caps, pull quotes and standfirst.
|
|
13
15
|
*/
|
|
14
16
|
export const FONT_FAMILIES = { manrope: "Manrope", inter: "Inter" };
|
|
15
17
|
/** CSS font-family list for the text: the chosen font, Inter for missing glyphs, the system's. */
|
|
@@ -72,6 +74,15 @@ export function serifFontFaceCss() {
|
|
|
72
74
|
serif ??= SERIF_STYLESHEETS.map(inlineFontStylesheet).join("\n");
|
|
73
75
|
return serif;
|
|
74
76
|
}
|
|
77
|
+
let magazine;
|
|
78
|
+
/** @font-face rules the magazine themes add to the text and code fonts, files embedded. */
|
|
79
|
+
export function magazineFontFaceCss() {
|
|
80
|
+
magazine ??= [
|
|
81
|
+
...INTER_MAGAZINE_FACES.map(interFace),
|
|
82
|
+
...NEWSREADER_STYLESHEETS.map(inlineFontStylesheet),
|
|
83
|
+
].join("\n");
|
|
84
|
+
return magazine;
|
|
85
|
+
}
|
|
75
86
|
let monoRegular;
|
|
76
87
|
/** JetBrains Mono Regular alone, for page headers/footers set in the mono family. */
|
|
77
88
|
export function monoRegularFontFace() {
|
|
@@ -13,6 +13,8 @@ export interface HeaderFooterValues {
|
|
|
13
13
|
author?: string;
|
|
14
14
|
date?: string;
|
|
15
15
|
}
|
|
16
|
+
/** Split on `|` (but not `\|`) into left, center and right columns. */
|
|
17
|
+
export declare function columns(spec: string): [string, string, string];
|
|
16
18
|
/**
|
|
17
19
|
* Chrome header/footer template. Chrome renders it apart from the page, so it carries its own
|
|
18
20
|
* styles and its own copy of the text font.
|
|
@@ -11,7 +11,7 @@ function renderText(text, values) {
|
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
13
|
/** Split on `|` (but not `\|`) into left, center and right columns. */
|
|
14
|
-
function columns(spec) {
|
|
14
|
+
export function columns(spec) {
|
|
15
15
|
const parts = spec.split(/(?<!\\)\|/).map((p) => p.replace(/\\\|/g, "|"));
|
|
16
16
|
if (parts.length === 1)
|
|
17
17
|
return ["", parts[0], ""];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Diagnostic } from "../diagnostics.js";
|
|
2
|
+
import type { DocumentOptions, Margins } from "../options.js";
|
|
3
|
+
/**
|
|
4
|
+
* The magazine themes (tech_magazine, tech_magazine-dark): a full-bleed cover picture on page 1
|
|
5
|
+
* and the page margins, header and footer drawn by the stylesheet.
|
|
6
|
+
*
|
|
7
|
+
* Cover pictures live in tech_magazine/<magazine-name>/, next to the document or shipped with
|
|
8
|
+
* mdrender; the number in a file name (cover02.png → 2) is the one `cover:` picks. An optional
|
|
9
|
+
* magazine.json in the same folder sets the magazine's accent and, per cover, the ink and the
|
|
10
|
+
* veil behind the text:
|
|
11
|
+
*
|
|
12
|
+
* { "accent": "#2f6bff", "covers": { "1": { "ink": "dark", "veil": "rgba(255,255,255,0.6)" } } }
|
|
13
|
+
*
|
|
14
|
+
* A cover's ink there wins over `cover-ink`: the picture decides which text colour reads on it.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MAGAZINE_DIR = "tech_magazine";
|
|
17
|
+
export declare const DEFAULT_MAGAZINE = "AlphaZeroChess";
|
|
18
|
+
/** Number in a file name: the last group of digits ("cover02.png" → 2). */
|
|
19
|
+
export declare function coverNumber(file: string): number | undefined;
|
|
20
|
+
/** A CSS string literal. */
|
|
21
|
+
export declare const cssString: (value: string) => string;
|
|
22
|
+
export interface PageValues {
|
|
23
|
+
title: string;
|
|
24
|
+
author?: string;
|
|
25
|
+
date?: string;
|
|
26
|
+
magazine: string;
|
|
27
|
+
}
|
|
28
|
+
export interface MagazinePage {
|
|
29
|
+
/** Stylesheet: cover picture, accent, masthead, page margins, header and footer. */
|
|
30
|
+
css: string;
|
|
31
|
+
/** Classes of the title block when it is the cover. */
|
|
32
|
+
headerClass?: string;
|
|
33
|
+
/** Number of the cover picture used, for --verbose. */
|
|
34
|
+
coverNumber?: number;
|
|
35
|
+
diagnostics: Diagnostic[];
|
|
36
|
+
}
|
|
37
|
+
export interface MagazineInput {
|
|
38
|
+
options: DocumentOptions;
|
|
39
|
+
margin: Margins;
|
|
40
|
+
baseDir: string;
|
|
41
|
+
/** The document has a title block, which becomes the cover. */
|
|
42
|
+
hasCover: boolean;
|
|
43
|
+
values: Omit<PageValues, "magazine">;
|
|
44
|
+
/** Picks a cover when none is asked for (Math.random; tests pass their own). */
|
|
45
|
+
random?: () => number;
|
|
46
|
+
}
|
|
47
|
+
/** The magazine theme's page: cover, masthead, margins, header and footer. */
|
|
48
|
+
export declare function magazinePage(input: MagazineInput): MagazinePage;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { listAssets, MAGAZINES_KEY, readAsset } from "../assets.js";
|
|
4
|
+
import { columns } from "./header-footer.js";
|
|
5
|
+
import { paperSizeMm } from "./page.js";
|
|
6
|
+
/**
|
|
7
|
+
* The magazine themes (tech_magazine, tech_magazine-dark): a full-bleed cover picture on page 1
|
|
8
|
+
* and the page margins, header and footer drawn by the stylesheet.
|
|
9
|
+
*
|
|
10
|
+
* Cover pictures live in tech_magazine/<magazine-name>/, next to the document or shipped with
|
|
11
|
+
* mdrender; the number in a file name (cover02.png → 2) is the one `cover:` picks. An optional
|
|
12
|
+
* magazine.json in the same folder sets the magazine's accent and, per cover, the ink and the
|
|
13
|
+
* veil behind the text:
|
|
14
|
+
*
|
|
15
|
+
* { "accent": "#2f6bff", "covers": { "1": { "ink": "dark", "veil": "rgba(255,255,255,0.6)" } } }
|
|
16
|
+
*
|
|
17
|
+
* A cover's ink there wins over `cover-ink`: the picture decides which text colour reads on it.
|
|
18
|
+
*/
|
|
19
|
+
export const MAGAZINE_DIR = "tech_magazine";
|
|
20
|
+
export const DEFAULT_MAGAZINE = "AlphaZeroChess";
|
|
21
|
+
const IMAGE_TYPES = {
|
|
22
|
+
".png": "image/png",
|
|
23
|
+
".jpg": "image/jpeg",
|
|
24
|
+
".jpeg": "image/jpeg",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
".avif": "image/avif",
|
|
27
|
+
".svg": "image/svg+xml",
|
|
28
|
+
};
|
|
29
|
+
/** Number in a file name: the last group of digits ("cover02.png" → 2). */
|
|
30
|
+
export function coverNumber(file) {
|
|
31
|
+
const match = /(\d+)(?!.*\d)/.exec(path.parse(file).name);
|
|
32
|
+
return match ? Number(match[1]) : undefined;
|
|
33
|
+
}
|
|
34
|
+
function isImage(file) {
|
|
35
|
+
return path.extname(file).toLowerCase() in IMAGE_TYPES;
|
|
36
|
+
}
|
|
37
|
+
function parseSettings(text, where, diagnostics) {
|
|
38
|
+
try {
|
|
39
|
+
const data = JSON.parse(text);
|
|
40
|
+
if (data && typeof data === "object" && !Array.isArray(data))
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// reported below
|
|
45
|
+
}
|
|
46
|
+
diagnostics.push({
|
|
47
|
+
severity: "warning",
|
|
48
|
+
message: `${where}/magazine.json is not valid; ignored`,
|
|
49
|
+
});
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
function toCovers(files) {
|
|
53
|
+
return files
|
|
54
|
+
.filter((file) => isImage(file.name))
|
|
55
|
+
.flatMap((file) => {
|
|
56
|
+
const number = coverNumber(file.name);
|
|
57
|
+
return number === undefined ? [] : [{ number, ...file }];
|
|
58
|
+
})
|
|
59
|
+
.sort((a, b) => a.number - b.number);
|
|
60
|
+
}
|
|
61
|
+
/** The magazine's folder next to the document, else the one shipped with mdrender. */
|
|
62
|
+
function findMagazine(name, baseDir, diagnostics) {
|
|
63
|
+
const local = path.join(baseDir, MAGAZINE_DIR, name);
|
|
64
|
+
if (existsSync(local) && statSync(local).isDirectory()) {
|
|
65
|
+
const where = path.join(MAGAZINE_DIR, name);
|
|
66
|
+
const entries = readdirSync(local).map((file) => ({
|
|
67
|
+
name: file,
|
|
68
|
+
read: () => readFileSync(path.join(local, file)),
|
|
69
|
+
}));
|
|
70
|
+
const manifest = path.join(local, "magazine.json");
|
|
71
|
+
const settings = existsSync(manifest)
|
|
72
|
+
? parseSettings(readFileSync(manifest, "utf8"), where, diagnostics)
|
|
73
|
+
: {};
|
|
74
|
+
return { where, covers: toCovers(entries), settings };
|
|
75
|
+
}
|
|
76
|
+
const folderKey = `${MAGAZINES_KEY}/${name}`;
|
|
77
|
+
const keys = listAssets(folderKey);
|
|
78
|
+
const entries = keys.map((key) => ({
|
|
79
|
+
name: path.posix.basename(key),
|
|
80
|
+
read: () => readAsset(key),
|
|
81
|
+
}));
|
|
82
|
+
const manifest = entries.find((entry) => entry.name === "magazine.json");
|
|
83
|
+
const settings = manifest
|
|
84
|
+
? parseSettings(manifest.read().toString("utf8"), `${MAGAZINE_DIR}/${name}`, diagnostics)
|
|
85
|
+
: {};
|
|
86
|
+
return { where: `${MAGAZINE_DIR}/${name}`, covers: toCovers(entries), settings };
|
|
87
|
+
}
|
|
88
|
+
function pickCover(folder, wanted, random, diagnostics) {
|
|
89
|
+
if (folder.covers.length === 0)
|
|
90
|
+
return undefined;
|
|
91
|
+
if (wanted !== undefined) {
|
|
92
|
+
const match = folder.covers.find((cover) => cover.number === wanted);
|
|
93
|
+
if (match)
|
|
94
|
+
return match;
|
|
95
|
+
const available = folder.covers.map((cover) => cover.number).join(", ");
|
|
96
|
+
diagnostics.push({
|
|
97
|
+
severity: "warning",
|
|
98
|
+
message: `cover ${wanted} not found in ${folder.where} (available: ${available}); using a random one`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return folder.covers[Math.floor(random() * folder.covers.length)];
|
|
102
|
+
}
|
|
103
|
+
/** A CSS string literal. */
|
|
104
|
+
export const cssString = (value) => `"${value.replace(/[\\"]/g, "\\$&").replace(/[\r\n]+/g, " ")}"`;
|
|
105
|
+
/** Default veil behind the cover text for each ink (theme-tech-magazine.css has the same). */
|
|
106
|
+
const VEILS = {
|
|
107
|
+
light: "rgba(0, 0, 0, 0.6)",
|
|
108
|
+
dark: "rgba(255, 255, 255, 0.6)",
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* The veil behind the cover text, fading from the edge the text sits at. Written out without
|
|
112
|
+
* var(): Chrome drops a declaration that has both var() and a value as large as a picture.
|
|
113
|
+
*/
|
|
114
|
+
const veil = (colour, position) => `linear-gradient(${position === "top" ? "to bottom" : "to top"}, ${colour} 0%, transparent 50%)`;
|
|
115
|
+
const COUNTERS = { page: "counter(page)", pages: "counter(pages)" };
|
|
116
|
+
/** A header/footer column ("{magazine} · {date}") as a CSS `content` value. */
|
|
117
|
+
function marginContent(text, values) {
|
|
118
|
+
const parts = [];
|
|
119
|
+
let literal = "";
|
|
120
|
+
for (const piece of text.trim().split(/(\{\w+\})/)) {
|
|
121
|
+
const name = /^\{(\w+)\}$/.exec(piece)?.[1];
|
|
122
|
+
const counter = name ? COUNTERS[name] : undefined;
|
|
123
|
+
if (counter) {
|
|
124
|
+
if (literal)
|
|
125
|
+
parts.push(cssString(literal));
|
|
126
|
+
parts.push(counter);
|
|
127
|
+
literal = "";
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const value = name ? values[name] : undefined;
|
|
131
|
+
literal += value ?? piece;
|
|
132
|
+
}
|
|
133
|
+
if (literal)
|
|
134
|
+
parts.push(cssString(literal));
|
|
135
|
+
return parts.length ? parts.join(" ") : undefined;
|
|
136
|
+
}
|
|
137
|
+
const SIDES = ["left", "center", "right"];
|
|
138
|
+
/** `@top-left { content: … }` and so on for a header or footer spec. */
|
|
139
|
+
function marginBoxes(spec, edge, values) {
|
|
140
|
+
if (!spec)
|
|
141
|
+
return "";
|
|
142
|
+
return columns(spec)
|
|
143
|
+
.map((column, i) => {
|
|
144
|
+
const content = marginContent(column, values);
|
|
145
|
+
return content ? ` @${edge}-${SIDES[i]} { content: ${content}; }\n` : "";
|
|
146
|
+
})
|
|
147
|
+
.join("");
|
|
148
|
+
}
|
|
149
|
+
/** The magazine theme's page: cover, masthead, margins, header and footer. */
|
|
150
|
+
export function magazinePage(input) {
|
|
151
|
+
const { options, margin, baseDir, hasCover } = input;
|
|
152
|
+
const diagnostics = [];
|
|
153
|
+
const magazine = options.magazineName ?? DEFAULT_MAGAZINE;
|
|
154
|
+
const values = { ...input.values, magazine };
|
|
155
|
+
const rules = [];
|
|
156
|
+
const root = [];
|
|
157
|
+
let headerClass;
|
|
158
|
+
let number;
|
|
159
|
+
const folder = findMagazine(magazine, baseDir, diagnostics);
|
|
160
|
+
const accent = options.accent ?? folder.settings.accent;
|
|
161
|
+
if (accent)
|
|
162
|
+
root.push(`--tm-accent: ${accent};`);
|
|
163
|
+
if (hasCover) {
|
|
164
|
+
const cover = pickCover(folder, options.cover, input.random ?? Math.random, diagnostics);
|
|
165
|
+
if (!cover) {
|
|
166
|
+
diagnostics.push({
|
|
167
|
+
severity: "warning",
|
|
168
|
+
message: `no cover pictures in ${folder.where}: the cover has no picture`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const settings = cover ? (folder.settings.covers?.[String(cover.number)] ?? {}) : {};
|
|
172
|
+
const ink = settings.ink ?? options.coverInk ?? "light";
|
|
173
|
+
const position = options.coverText ?? "top";
|
|
174
|
+
if (cover) {
|
|
175
|
+
const type = IMAGE_TYPES[path.extname(cover.name).toLowerCase()];
|
|
176
|
+
// Straight into the rule, not through a custom property: Chrome drops one this large.
|
|
177
|
+
const picture = `url("data:${type};base64,${cover.read().toString("base64")}")`;
|
|
178
|
+
const layers = `${veil(settings.veil ?? VEILS[ink], position)}, ${picture}`;
|
|
179
|
+
rules.push(`.doc-header.doc-header { background-image: ${layers}; }`);
|
|
180
|
+
number = cover.number;
|
|
181
|
+
}
|
|
182
|
+
headerClass = `cover-ink-${ink} cover-text-${position}`;
|
|
183
|
+
const [width, height] = paperSizeMm(options.pageSize, options.orientation === "landscape");
|
|
184
|
+
rules.push(`@media print { .doc-header { width: ${width}mm; height: ${height}mm; } }`, `.doc-header::before { content: ${cssString(magazine)}; }`, options.date ? `.doc-header::after { content: ${cssString(options.date)}; }` : "");
|
|
185
|
+
}
|
|
186
|
+
// Margins, header and footer: Chrome prints with no margins and the stylesheet draws them, so
|
|
187
|
+
// that the cover can bleed to the edges of page 1.
|
|
188
|
+
const footer = options.footer ??
|
|
189
|
+
[options.date ? "{magazine} · {date}" : "{magazine}", options.pageNumbers ? "{page}" : ""].join(" | | ");
|
|
190
|
+
const page = [
|
|
191
|
+
` margin: ${margin.top} ${margin.right} ${margin.bottom} ${margin.left};\n`,
|
|
192
|
+
marginBoxes(options.header, "top", values),
|
|
193
|
+
marginBoxes(footer, "bottom", values),
|
|
194
|
+
].join("");
|
|
195
|
+
rules.push(`@page {\n${page}}`);
|
|
196
|
+
if (hasCover) {
|
|
197
|
+
const none = ["top", "bottom"]
|
|
198
|
+
.flatMap((edge) => SIDES.map((side) => ` @${edge}-${side} { content: none; }\n`))
|
|
199
|
+
.join("");
|
|
200
|
+
rules.push(`@page :first {\n margin: 0;\n${none}}`);
|
|
201
|
+
}
|
|
202
|
+
if (root.length)
|
|
203
|
+
rules.unshift(`:root { ${root.join(" ")} }`);
|
|
204
|
+
return {
|
|
205
|
+
css: rules.filter(Boolean).join("\n"),
|
|
206
|
+
headerClass,
|
|
207
|
+
coverNumber: number,
|
|
208
|
+
diagnostics,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=magazine.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
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 declare const CORNER_RADIUS = 5;
|
|
17
|
+
/** Opacity of shape and subgraph fills (strokes and text stay opaque). */
|
|
18
|
+
export declare const FILL_OPACITY = 0.72;
|
|
19
|
+
export declare const MERMAID_THEME_CSS: string;
|
|
20
|
+
type Point = [number, number];
|
|
21
|
+
/**
|
|
22
|
+
* Path data for a closed polygon whose corners are eased with quadratic curves of `radius`
|
|
23
|
+
* (smaller where an edge is too short for it: at most a third of each adjacent edge).
|
|
24
|
+
*/
|
|
25
|
+
export declare function roundedPolygonPath(points: Point[], radius?: number): string;
|
|
26
|
+
/**
|
|
27
|
+
* The corners of a closed shape drawn only with straight lines written as cubic segments, as
|
|
28
|
+
* Mermaid draws its flag and other hand-drawn polygons: the fill as one subpath, the outline as
|
|
29
|
+
* each side drawn twice ("M a C … b M a C … b M b C … c …"). Undefined for any path with a curve,
|
|
30
|
+
* another command, a gap or an open end.
|
|
31
|
+
*/
|
|
32
|
+
export declare function straightPathCorners(d: string): Point[] | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Round the corners of node polygons: `<polygon class="label-container">` (decision, hexagon,
|
|
35
|
+
* parallelogram, trapezoid) and closed shapes drawn only with straight segments (the flag).
|
|
36
|
+
* Other polygons and paths (arrowheads, edges, curved shapes) are left alone.
|
|
37
|
+
*/
|
|
38
|
+
export declare function roundNodePolygons(svg: string, radius?: number): string;
|
|
39
|
+
export {};
|