@imontene/mdrender 2.8.1 → 2.10.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 +23 -0
- package/README.md +102 -44
- 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 +47 -13
- package/dist/markdown/hyphenate.js +7 -3
- 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/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";
|
|
@@ -85,16 +86,22 @@ export function headerFooterSpaceWarnings(margin, hasHeader, hasFooter) {
|
|
|
85
86
|
return warnings;
|
|
86
87
|
}
|
|
87
88
|
/**
|
|
88
|
-
* Justified
|
|
89
|
-
* (the same ones markdown/hyphenate.ts gives soft hyphens).
|
|
90
|
-
* block, the table of contents and diagram labels stay aligned
|
|
91
|
-
* HTML keeps ragged lines.
|
|
89
|
+
* Justified text in the PDF: the body's paragraphs, those directly inside a quote or an alert,
|
|
90
|
+
* and bulleted and numbered list items (the same ones markdown/hyphenate.ts gives soft hyphens).
|
|
91
|
+
* Tables, footnotes, the title block, the table of contents and diagram labels stay aligned
|
|
92
|
+
* left, as does code inside a list item. Print only: on screen the HTML keeps ragged lines.
|
|
92
93
|
*/
|
|
93
94
|
const JUSTIFY_CSS = `@media print {
|
|
94
95
|
.markdown-body > p,
|
|
95
|
-
.markdown-body > blockquote > p:not(.markdown-alert-title)
|
|
96
|
+
.markdown-body > blockquote > p:not(.markdown-alert-title),
|
|
97
|
+
.markdown-body > :is(ul, ol) li,
|
|
98
|
+
.markdown-body > blockquote > :is(ul, ol) li {
|
|
96
99
|
text-align: justify;
|
|
97
100
|
}
|
|
101
|
+
/* What list items may hold besides text keeps its own alignment. */
|
|
102
|
+
.markdown-body li :is(pre, table) {
|
|
103
|
+
text-align: start;
|
|
104
|
+
}
|
|
98
105
|
}`;
|
|
99
106
|
/** Font size, if set, and the page's text height (used to fit images and diagrams). */
|
|
100
107
|
function layoutCss(options, margin) {
|
|
@@ -176,7 +183,7 @@ paged = false) {
|
|
|
176
183
|
lang: options.lang,
|
|
177
184
|
// Soft hyphens only where paragraphs are justified: in the PDF.
|
|
178
185
|
hyphenate: paged && options.justify ? options.lang : undefined,
|
|
179
|
-
figures: options.theme === "academic_basic",
|
|
186
|
+
figures: options.theme === "academic_basic" || isMagazineTheme(options.theme),
|
|
180
187
|
});
|
|
181
188
|
const diagnostics = [
|
|
182
189
|
...config.warnings.map((message) => ({ severity: "warning", message })),
|
|
@@ -233,6 +240,26 @@ paged = false) {
|
|
|
233
240
|
});
|
|
234
241
|
}
|
|
235
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
|
+
}
|
|
236
263
|
const html = buildHtmlDocument({
|
|
237
264
|
title,
|
|
238
265
|
body,
|
|
@@ -245,7 +272,8 @@ paged = false) {
|
|
|
245
272
|
affiliations: options.affiliations,
|
|
246
273
|
abstract: options.abstract,
|
|
247
274
|
keywords: options.keywords,
|
|
248
|
-
showTitle
|
|
275
|
+
showTitle,
|
|
276
|
+
headerClass: magazine?.headerClass,
|
|
249
277
|
themeCss,
|
|
250
278
|
font: options.font,
|
|
251
279
|
beforeBody: watermark || undefined,
|
|
@@ -253,6 +281,7 @@ paged = false) {
|
|
|
253
281
|
...densityCss(options.density),
|
|
254
282
|
layoutCss(options, margin),
|
|
255
283
|
...(watermark ? [WATERMARK_CSS] : []),
|
|
284
|
+
...(magazine ? [magazine.css] : []),
|
|
256
285
|
],
|
|
257
286
|
extraCss,
|
|
258
287
|
});
|
|
@@ -275,17 +304,22 @@ export async function convertFile(convert) {
|
|
|
275
304
|
const margin = resolveMargins(options);
|
|
276
305
|
const author = options.author === undefined ? undefined : plainAuthor(options.author);
|
|
277
306
|
const values = { title: doc.title, author, date: options.date };
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
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));
|
|
281
315
|
const pageLook = headerFooterStyle(options.theme);
|
|
282
316
|
const session = await browsers.get();
|
|
283
317
|
const { pdf, blocked } = await session.printPdf(doc.html, {
|
|
284
318
|
format: options.pageSize,
|
|
285
319
|
landscape: options.orientation === "landscape",
|
|
286
|
-
margin,
|
|
320
|
+
margin: magazine ? { top: "0mm", right: "0mm", bottom: "0mm", left: "0mm" } : margin,
|
|
287
321
|
headerTemplate: hasHeaderFooter
|
|
288
|
-
? headerFooterTemplate(
|
|
322
|
+
? headerFooterTemplate(header, values, margin, options.font, pageLook)
|
|
289
323
|
: undefined,
|
|
290
324
|
footerTemplate: hasHeaderFooter
|
|
291
325
|
? headerFooterTemplate(footer, values, margin, options.font, pageLook)
|
|
@@ -42,12 +42,16 @@ export function hyphenatorFor(lang) {
|
|
|
42
42
|
return hyphenate;
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
* Whether a paragraph is justified
|
|
46
|
-
*
|
|
45
|
+
* Whether a paragraph is justified (the same paragraphs as the print stylesheet in convert.ts):
|
|
46
|
+
* top-level, directly inside a blockquote or alert, or in a bulleted or numbered list item
|
|
47
|
+
* (nested lists too). Footnotes and tables are not.
|
|
47
48
|
*/
|
|
48
49
|
function justified(containers) {
|
|
49
|
-
|
|
50
|
+
// Bulleted and numbered lists (nested too) are justified like the text around them.
|
|
51
|
+
const blocks = containers.filter((c) => !LIST_CONTAINERS.has(c));
|
|
52
|
+
return blocks.length === 0 || (blocks.length === 1 && blocks[0] === "blockquote_open");
|
|
50
53
|
}
|
|
54
|
+
const LIST_CONTAINERS = new Set(["bullet_list_open", "ordered_list_open", "list_item_open"]);
|
|
51
55
|
function hyphenateInline(inline, hyphenate) {
|
|
52
56
|
let inAutolink = 0;
|
|
53
57
|
for (const child of inline.children ?? []) {
|
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
|
package/dist/render/page.d.ts
CHANGED
|
@@ -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: {
|
package/dist/render/page.js
CHANGED
|
@@ -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
|
|
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. */
|