@threepenny/md2pdf 0.0.0-stage → 2.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Threepenny Labs LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,208 @@
1
- # Temporary Holding Version
1
+ # md2pdf
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Convert Markdown to beautifully styled PDFs. This is the rendering engine and macOS command-line tool behind **[md2pdf.me](https://md2pdf.me)**, the free online converter.
4
+
5
+ - **Just want a PDF?** Use [md2pdf.me](https://md2pdf.me): nothing to install, and your text never leaves your browser.
6
+ - **On a Mac and comfortable with a terminal?** Install the command-line tool below. It adds a Finder right-click action and a keyboard shortcut.
7
+ - **Building something?** Use the renderer and styles as a dependency, to make PDFs ([Using the renderer](#using-the-renderer)) or to show Markdown in your own pages ([Embedding in a web page](#embedding-in-a-web-page)).
8
+
9
+ Supports GitHub-flavored Markdown: tables, task lists, footnotes, emoji shortcodes, syntax-highlighted code, raw HTML, GitHub alerts and Obsidian callouts (`> [!NOTE]`, `> [!tip] Custom title`), Obsidian wikilinks and embeds (`[[Note|alias]]` prints as plain text, `![[image.png]]` as an image, and an embedded note as a placeholder naming it), YAML front matter (left out of the PDF; its `title:` names the document when there is no `#` heading), and page breaks (`<div class="page-break"></div>`). Six print styles are included: `github`, `classic`, `academic`, `modern`, `report` and `resume`.
10
+
11
+ # Command-line tool (macOS)
12
+
13
+ Markdown is rendered to HTML with [markdown-it](https://github.com/markdown-it/markdown-it) and printed to PDF by headless Chrome.
14
+
15
+ ## Install
16
+
17
+ Requires Node.js and Google Chrome (Chromium, Edge or Brave also work).
18
+
19
+ ```sh
20
+ ./install.sh
21
+ ```
22
+
23
+ This installs:
24
+
25
+ - `~/.local/bin/md2pdf` — the command-line tool
26
+ - **Convert to PDF** Quick Action — right-click any `.md` file in Finder → **Quick Actions → Convert to PDF**
27
+ - **⌃⌥⌘P** keyboard shortcut — select `.md` files in Finder and press it
28
+
29
+ Re-run `install.sh` if you move this folder or remove the Node version it was installed with. `./uninstall.sh` removes all of it.
30
+
31
+ To use a different shortcut, pass it when installing (`@` ⌘, `~` ⌥, `^` ⌃, `$` ⇧), or set it to empty to skip:
32
+
33
+ ```sh
34
+ MD2PDF_SHORTCUT='@$p' ./install.sh # ⇧⌘P
35
+ MD2PDF_SHORTCUT= ./install.sh # no shortcut
36
+ ```
37
+
38
+ You can also change it later in **System Settings → Keyboard → Keyboard Shortcuts → Services → Files and Folders → Convert to PDF**.
39
+
40
+ ## Usage
41
+
42
+ ```sh
43
+ md2pdf notes.md # → notes.pdf next to notes.md
44
+ md2pdf *.md # convert several at once
45
+ md2pdf notes.md -o ~/out.pdf # choose the output path
46
+ md2pdf notes.md -p A4 # page size (default: Letter)
47
+ md2pdf notes.md --open # open the PDF when done
48
+ md2pdf notes.md -s classic # use a specific style
49
+ md2pdf notes.md --css my.css # layer extra CSS on top of the style
50
+ md2pdf notes.md --html # also write the intermediate HTML (for tweaking CSS)
51
+ ```
52
+
53
+ From Finder, a single file opens in Preview when done; multiple files show a notification.
54
+
55
+ ## Styles
56
+
57
+ | Style | Look |
58
+ | --------- | ---- |
59
+ | `github` | GitHub's Markdown rendering: sans-serif, boxed tables, GitHub syntax colors |
60
+ | `classic` | Book-like: Iowan Old Style serif, justified text, small-caps headings, ruled tables, page numbers |
61
+ | `academic` | Journal article: Palatino, numbered sections (1, 1.1, 1.1.1), booktabs tables, journal-style footnotes |
62
+ | `modern` | Clean sans-serif (Avenir Next / Segoe UI), generous whitespace, one blue accent |
63
+ | `report` | Business or technical report: title block from the first heading, running header with the title, "Page N of M" footers, gridded tables |
64
+ | `resume` | Compact and one-page friendly: the first heading is your name, the paragraph after it your contact line, `##` headings are ruled section dividers |
65
+
66
+ Switch the default style — used by the Finder Quick Action, the keyboard shortcut and the `md2pdf` command:
67
+
68
+ ```sh
69
+ ./set-style.sh # list styles (* marks the default)
70
+ ./set-style.sh classic # make classic the default
71
+ ```
72
+
73
+ The choice is saved in `~/.config/md2pdf/config.json`. Use `md2pdf -s <style>` to override it for one conversion.
74
+
75
+ ### Adding a style
76
+
77
+ Styles are registered in the `STYLES` map in `bin/md2pdf.js`; each is a list of CSS files inlined into the page in order. A style has two files:
78
+
79
+ - `styles/<name>.css`, the on-screen look. The content is wrapped in `<article class="markdown-body">`, and every rule must be scoped to it: no `:root`, `html`, `body` or `@page` rules, so the style can be embedded in other pages.
80
+ - `styles/print/<name>.css`, the print-only rules: `@page` margins, running headers and page numbers, and `html, body` margins.
81
+
82
+ Register it in `STYLES` and add its page margin to `pageMargins` in `lib/styles.js`, and it's available to `-s` and `set-style.sh`. Use `md2pdf --html` to get the intermediate HTML for tweaking in a browser. For one-off tweaks, `--css` is enough.
83
+
84
+ The text of the document's first `#` heading (empty if there is none) is available to page margin boxes as `var(--document-title)`, e.g. `@top-left { content: var(--document-title); }`, as the `report` style does.
85
+
86
+ A style declares the [shared variables](#variables) in its first rule, `:where(.markdown-body) { … }`, with a comment saying what each one does there, and uses them for every size, gap, color and font they name. Spacing follows two patterns: `line-height: calc(1 + 0.55 * var(--md2pdf-leading))` and `margin: calc(1.8em * var(--md2pdf-space)) 0 …`. `npm test` checks that each variable is declared and changes the document. Page margins aren't included: they stay in the style's `@page` rule, in `styles/print/<name>.css`.
87
+
88
+ # Using the renderer
89
+
90
+ The Markdown renderer and the styles are on npm as `@threepenny/md2pdf`. They can also be installed straight from a GitHub release tag (`npm install github:jbrecht/md2pdf#<tag>`).
91
+
92
+ ```sh
93
+ npm install @threepenny/md2pdf highlight.js
94
+ ```
95
+
96
+ To make a printable page, pass `buildDocument` a style's on-screen and print stylesheets, in that order:
97
+
98
+ ```js
99
+ import hljs from 'highlight.js';
100
+ import { createRenderer, buildDocument, titleOf } from '@threepenny/md2pdf/render';
101
+
102
+ const md = createRenderer({ hljs });
103
+ const html = buildDocument({
104
+ bodyHtml: md.render(markdown),
105
+ title: titleOf(markdown, 'document'),
106
+ css: classicCss + printCss, // the text of styles/classic.css and styles/print/classic.css
107
+ pageSize: 'A4',
108
+ });
109
+ ```
110
+
111
+ | Export | What it is |
112
+ | ------ | ---------- |
113
+ | `@threepenny/md2pdf/render` | `createRenderer({ hljs, math?, html? })`, `buildDocument(...)`, `titleOf(markdown, fallback)` (the first `#` heading, else the front matter's `title:`, else the fallback), `escapeHtml(s)`, `cssString(s)`, `headingText(html)` |
114
+ | `@threepenny/md2pdf/styles` | `pageMargins`: each style's `@page` margin at default spacing, e.g. `pageMargins.classic` is `'1in 1.1in 0.9in'` |
115
+ | `@threepenny/md2pdf/styles/<name>.css` | Each style's on-screen look, scoped to `.markdown-body`. For `github`, load `github-markdown-css/github-markdown-light.css` first |
116
+ | `@threepenny/md2pdf/styles/print/<name>.css` | Each style's print rules: page margins, running headers and page numbers. Load it after the on-screen file, only in a page that holds nothing but the document |
117
+
118
+ `createRenderer` takes the highlight.js instance to use (so you can pass a smaller bundle such as `highlight.js/lib/common`) and, optionally, `math: { texmath, katex }` from `markdown-it-texmath` and `katex` to render `\( \)`, `\[ \]`, `$` and `$$` formulas; formulas also need KaTeX's stylesheet, `katex/dist/katex.min.css`. Raw HTML in the Markdown is passed through unless you pass `html: false`, which escapes it; page breaks (`<div class="page-break"></div>`) are raw HTML, so they then print as text.
119
+
120
+ ### Variables
121
+
122
+ Every style takes the same variables, set on the `.markdown-body` element. A `--css` file for the CLI can set them too, e.g. `.markdown-body { --md2pdf-leading: 0.8; --md2pdf-space: 0.75; }` to tighten a document. Each style's file lists its defaults.
123
+
124
+ | Variable | What it sets |
125
+ | -------- | ------------ |
126
+ | `--md2pdf-font-size` | The body text size; headings and smaller text scale with it. An absolute length (`pt`, `px` or `rem`), since `em` and `%` would compound. 11pt, or 10.5pt in `modern` and `report` and 10pt in `resume` |
127
+ | `--md2pdf-leading` | Line spacing: scales each line height's excess over 1. 1 as designed |
128
+ | `--md2pdf-space` | The vertical gaps between blocks. 1 as designed |
129
+ | `--md2pdf-background` | The document's background, white |
130
+ | `--md2pdf-text` | Body text |
131
+ | `--md2pdf-muted` | Secondary text, such as footnotes and captions |
132
+ | `--md2pdf-accent` | The accent color: links, headings, list markers or callouts, depending on the style |
133
+ | `--md2pdf-rule` | Rules, borders and table lines (every style but `github`) |
134
+ | `--md2pdf-surface` | A tinted background: inline code and code blocks in `classic` and `modern`, and callouts too in `classic`; inline code, quotes and table headings in `report`. The other styles don't use it |
135
+ | `--md2pdf-font` | The body font |
136
+ | `--md2pdf-heading-font` | The heading font: the body font, except in `report`, where it's Helvetica and also sets callout titles and tables |
137
+ | `--md2pdf-code-font` | The code font |
138
+
139
+ The styles are designed for light pages; a dark page isn't supported. Page headers and footers in a PDF keep the style's own fonts and colors.
140
+
141
+ ### Upgrading from 1.x
142
+
143
+ - The variables are renamed (`--leading` is now `--md2pdf-leading`; the body font, `--serif` or in `modern` and `resume` `--sans`, is `--md2pdf-font`; `report`'s `--sans` is `--md2pdf-heading-font`; and so on) and must be set on `.markdown-body`. A value set on `:root`, as the 1.x README suggested, no longer reaches the document.
144
+ - The print rules moved to `styles/print/<name>.css`. Pass `buildDocument` both files, or PDFs lose their margins, running headers and page numbers.
145
+ - The package is `@threepenny/md2pdf`, so imports change from `md2pdf/…` to `@threepenny/md2pdf/…`, unless you install it under its old name (`npm install md2pdf@npm:@threepenny/md2pdf`).
146
+ - The `github` style includes highlight.js's GitHub theme, so you no longer need to load `highlight.js/styles/github.css`.
147
+
148
+ ## Embedding in a web page
149
+
150
+ Used inside your own pages, md2pdf is a Markdown-to-HTML renderer rather than a PDF exporter: you get its callouts, wikilinks, math, code highlighting and styles, styled to match your site. A style's on-screen stylesheet only styles the element you render into, `class="markdown-body"`. It sets nothing on `:root`, `html` or `body` and has no `@page` rules, so the rest of your page looks and prints as before. Leave out the print stylesheet: it would take over your page's printing.
151
+
152
+ To see it working, run `npm run demo` in this repo and open http://localhost:3900/demo/. The [demo](demo/index.html) is one static page: an editor, a style picker, and controls that override the size, spacing and accent of its one document. It needs no build step. An import map loads the renderer's dependencies from a CDN instead, so it needs a network connection. It does need a web server, because browsers won't load JavaScript modules from a page opened as a file.
153
+
154
+ With a bundler such as Vite:
155
+
156
+ ```html
157
+ <!DOCTYPE html>
158
+ <html lang="en">
159
+ <head>
160
+ <meta charset="utf-8">
161
+ <title>Notes</title>
162
+ <link rel="stylesheet" href="/node_modules/@threepenny/md2pdf/styles/classic.css">
163
+ <style>
164
+ /* Your site's look: on .markdown-body for every document, or on one of them. */
165
+ .markdown-body { --md2pdf-font-size: 12pt; --md2pdf-font: Georgia, serif; }
166
+ #rendered-notes { --md2pdf-accent: #0b5394; --md2pdf-space: 0.75; }
167
+ </style>
168
+ </head>
169
+ <body>
170
+ <h1>My site</h1>
171
+ <article id="rendered-notes" class="markdown-body"></article>
172
+ <script type="module">
173
+ import hljs from 'highlight.js/lib/common';
174
+ import { createRenderer } from '@threepenny/md2pdf/render';
175
+
176
+ // html: false escapes raw HTML, for Markdown you didn't write yourself.
177
+ const md = createRenderer({ hljs, html: false });
178
+ const markdown = '# Notes\n\n> [!tip] Rendered by md2pdf\n> With **callouts**, `code` and [[wikilinks]].\n\n<b>Escaped</b>';
179
+ document.getElementById('rendered-notes').innerHTML = md.render(markdown);
180
+ </script>
181
+ </body>
182
+ </html>
183
+ ```
184
+
185
+ Set the [variables](#variables) on the `.markdown-body` element itself, with any selector or inline (`<article class="markdown-body" style="--md2pdf-font-size: 10pt">`). The style declares its defaults with no specificity, so your rule wins wherever it appears. A value set on `:root` or another element around the document doesn't reach it. One page can hold several documents in the same style, each with its own overrides, but not two different styles.
186
+
187
+ The document paints its own background, so on a page that isn't white, give it some padding. Your page's own CSS still applies inside the document, for whatever the style doesn't set: a global `strong { color: #c00 }` reddens bold text in the document too. A highlight.js theme on your page is the exception: the styles keep it out of the document's code blocks. Headings get ids from their text (`# Notes` becomes `id="notes"`) so `[[#Notes]]` links work, so give your own elements ids that a heading won't produce.
188
+
189
+ For Markdown you didn't write, render with `html: false` and also run the output through a sanitizer such as DOMPurify before inserting it. `html: false` escapes raw HTML, and markdown-it refuses `javascript:`, `vbscript:`, `file:` and non-image `data:` URLs in links and images. Neither is enough alone yet ([#32](https://github.com/jbrecht/md2pdf/issues/32)):
190
+
191
+ - If other code on your page uses markdown-it-task-lists with `labelAfter`, that plugin's shared settings let raw HTML through md2pdf's task-list items.
192
+ - Headings become element ids (`# config` gives `id="config"`), which can shadow globals or ids your own scripts rely on.
193
+ - Images load from any URL the author chooses. A Content Security Policy's `img-src` limits that.
194
+ - With math enabled, a formula can draw outside the document, so clip the element you render into (`overflow: hidden`).
195
+
196
+ # Development
197
+
198
+ ```sh
199
+ npm install
200
+ npm test # end-to-end tests against test/fixtures
201
+ npm run demo # the embedding demo, at http://localhost:3900/demo/
202
+ ```
203
+
204
+ Set `CHROME_PATH` to use a specific browser binary.
205
+
206
+ # License
207
+
208
+ [MIT](LICENSE) © 2026 Threepenny Labs LLC
package/bin/md2pdf.js ADDED
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ // md2pdf: convert Markdown files to PDF using headless Chrome.
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { homedir, tmpdir } from 'node:os';
7
+ import { basename, dirname, extname, join, resolve } from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { parseArgs } from 'node:util';
10
+ import { createRequire } from 'node:module';
11
+
12
+ import hljs from 'highlight.js';
13
+ import { buildDocument, createRenderer, titleOf } from '../lib/render.js';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
+
18
+ // Each style is a list of CSS files inlined into the page, in order: its on-screen styles, then its
19
+ // print rules. To add a style, add both files, register it here and add its page margin to lib/styles.js;
20
+ // set-style.sh picks it up.
21
+ const styleFiles = (name, ...before) => [...before, join(ROOT, 'styles', `${name}.css`), join(ROOT, 'styles', 'print', `${name}.css`)];
22
+ const STYLES = {
23
+ github: styleFiles('github', require.resolve('github-markdown-css/github-markdown-light.css')),
24
+ classic: styleFiles('classic'),
25
+ academic: styleFiles('academic'),
26
+ modern: styleFiles('modern'),
27
+ report: styleFiles('report'),
28
+ resume: styleFiles('resume'),
29
+ };
30
+ const FALLBACK_STYLE = 'github';
31
+
32
+ // Settings shared by the CLI, Finder Quick Action and keyboard shortcut.
33
+ const CONFIG_PATH =
34
+ process.env.MD2PDF_CONFIG ??
35
+ join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'md2pdf', 'config.json');
36
+
37
+ const CHROME_CANDIDATES = [
38
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
39
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
40
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
41
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
42
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
43
+ ];
44
+
45
+ const usage = (defaultStyle) => `Usage: md2pdf [options] <file.md>...
46
+ md2pdf --set-style <name>
47
+ md2pdf --list-styles
48
+
49
+ Converts each Markdown file to a PDF saved next to it.
50
+
51
+ Options:
52
+ -o, --output <file> Output path (only with a single input file)
53
+ -s, --style <name> Style to use (${Object.keys(STYLES).join(', ')}; default: ${defaultStyle})
54
+ --css <file> Extra CSS file applied after the style
55
+ -p, --page-size <size> Letter, A4, Legal, ... (default: Letter)
56
+ --open Open the PDF when done
57
+ --html Also write the intermediate .html (for debugging styles)
58
+ --set-style <name> Make <name> the default style (also used by the Finder Quick Action)
59
+ --list-styles List styles, marking the default
60
+ -h, --help Show this help
61
+
62
+ Environment:
63
+ CHROME_PATH Path to a Chrome/Chromium binary to use
64
+ MD2PDF_CONFIG Config file (default: ${CONFIG_PATH})`;
65
+
66
+ function readConfig() {
67
+ if (!existsSync(CONFIG_PATH)) return {};
68
+ try {
69
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
70
+ } catch (err) {
71
+ console.error(`md2pdf: ignoring unreadable config ${CONFIG_PATH}: ${err.message}`);
72
+ return {};
73
+ }
74
+ }
75
+
76
+ function writeConfig(config) {
77
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
78
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
79
+ }
80
+
81
+ function unknownStyle(name) {
82
+ console.error(`md2pdf: unknown style "${name}". Available: ${Object.keys(STYLES).join(', ')}`);
83
+ process.exit(2);
84
+ }
85
+
86
+ function findChrome() {
87
+ if (process.env.CHROME_PATH) return process.env.CHROME_PATH;
88
+ const found = CHROME_CANDIDATES.find((p) => existsSync(p));
89
+ if (!found) throw new Error('Could not find Chrome. Install Google Chrome or set CHROME_PATH.');
90
+ return found;
91
+ }
92
+
93
+ function buildHtml({ markdown, title, baseDir, style, extraCss, pageSize }) {
94
+ const css = [...STYLES[style], ...(extraCss ? [extraCss] : [])]
95
+ .map((f) => readFileSync(f, 'utf8'))
96
+ .join('\n');
97
+ return buildDocument({
98
+ bodyHtml: createRenderer({ hljs }).render(markdown),
99
+ title,
100
+ css,
101
+ pageSize,
102
+ // <base> makes relative image/link paths resolve against the Markdown file's folder.
103
+ head: `<base href="${pathToFileURL(baseDir + '/').href}">`,
104
+ });
105
+ }
106
+
107
+ function runChrome(chrome, htmlPath, pdfPath) {
108
+ // A throwaway profile keeps headless Chrome separate from any Chrome you have open.
109
+ const profile = mkdtempSync(join(tmpdir(), 'md2pdf-chrome-'));
110
+ const args = [
111
+ '--headless',
112
+ '--disable-gpu',
113
+ '--no-first-run',
114
+ '--no-default-browser-check',
115
+ '--allow-file-access-from-files',
116
+ `--user-data-dir=${profile}`,
117
+ '--no-pdf-header-footer',
118
+ '--virtual-time-budget=10000',
119
+ `--print-to-pdf=${pdfPath}`,
120
+ pathToFileURL(htmlPath).href,
121
+ ];
122
+ return new Promise((resolvePromise, reject) => {
123
+ const proc = spawn(chrome, args, { stdio: ['ignore', 'ignore', 'pipe'] });
124
+ let stderr = '';
125
+ proc.stderr.on('data', (d) => (stderr += d));
126
+ proc.on('error', reject);
127
+ proc.on('close', (code) => {
128
+ rmSync(profile, { recursive: true, force: true });
129
+ if (code === 0 && existsSync(pdfPath)) resolvePromise();
130
+ else reject(new Error(`Chrome failed (exit ${code}):\n${stderr.trim()}`));
131
+ });
132
+ });
133
+ }
134
+
135
+ async function convert(input, opts, chrome) {
136
+ const inputPath = resolve(input);
137
+ if (!existsSync(inputPath)) throw new Error(`No such file: ${input}`);
138
+ const markdown = readFileSync(inputPath, 'utf8');
139
+ const stem = basename(inputPath, extname(inputPath));
140
+ const pdfPath = resolve(opts.output ?? join(dirname(inputPath), `${stem}.pdf`));
141
+ const title = titleOf(markdown, stem);
142
+
143
+ const html = buildHtml({
144
+ markdown,
145
+ title,
146
+ baseDir: dirname(inputPath),
147
+ style: opts.style,
148
+ extraCss: opts.css && resolve(opts.css),
149
+ pageSize: opts['page-size'],
150
+ });
151
+
152
+ const workDir = mkdtempSync(join(tmpdir(), 'md2pdf-'));
153
+ try {
154
+ const htmlPath = join(workDir, `${stem}.html`);
155
+ writeFileSync(htmlPath, html);
156
+ if (opts.html) writeFileSync(pdfPath.replace(/\.pdf$/i, '') + '.html', html);
157
+ await runChrome(chrome, htmlPath, pdfPath);
158
+ } finally {
159
+ rmSync(workDir, { recursive: true, force: true });
160
+ }
161
+ return pdfPath;
162
+ }
163
+
164
+ async function main() {
165
+ let parsed;
166
+ try {
167
+ parsed = parseArgs({
168
+ allowPositionals: true,
169
+ options: {
170
+ output: { type: 'string', short: 'o' },
171
+ style: { type: 'string', short: 's' },
172
+ css: { type: 'string' },
173
+ 'page-size': { type: 'string', short: 'p', default: 'Letter' },
174
+ open: { type: 'boolean' },
175
+ html: { type: 'boolean' },
176
+ 'set-style': { type: 'string' },
177
+ 'list-styles': { type: 'boolean' },
178
+ help: { type: 'boolean', short: 'h' },
179
+ },
180
+ });
181
+ } catch (err) {
182
+ console.error(`md2pdf: ${err.message}\n\nRun md2pdf --help for usage.`);
183
+ process.exit(2);
184
+ }
185
+ const { values: opts, positionals: files } = parsed;
186
+ const config = readConfig();
187
+ const defaultStyle = STYLES[config.style] ? config.style : FALLBACK_STYLE;
188
+
189
+ if (opts['set-style'] !== undefined) {
190
+ if (!STYLES[opts['set-style']]) unknownStyle(opts['set-style']);
191
+ writeConfig({ ...config, style: opts['set-style'] });
192
+ console.log(`Default style is now "${opts['set-style']}".`);
193
+ process.exit(0);
194
+ }
195
+ if (opts['list-styles']) {
196
+ for (const name of Object.keys(STYLES)) console.log(`${name === defaultStyle ? '*' : ' '} ${name}`);
197
+ process.exit(0);
198
+ }
199
+ if (opts.help || files.length === 0) {
200
+ console.log(usage(defaultStyle));
201
+ process.exit(opts.help ? 0 : 2);
202
+ }
203
+ if (config.style && !STYLES[config.style]) {
204
+ console.error(`md2pdf: config style "${config.style}" no longer exists; using ${FALLBACK_STYLE}`);
205
+ }
206
+ opts.style ??= defaultStyle;
207
+ if (!STYLES[opts.style]) unknownStyle(opts.style);
208
+ if (opts.output && files.length > 1) {
209
+ console.error('md2pdf: --output can only be used with a single input file');
210
+ process.exit(2);
211
+ }
212
+
213
+ const chrome = findChrome();
214
+ let failed = 0;
215
+ for (const file of files) {
216
+ try {
217
+ const pdf = await convert(file, opts, chrome);
218
+ console.log(pdf);
219
+ if (opts.open) spawn('open', [pdf], { stdio: 'ignore', detached: true }).unref();
220
+ } catch (err) {
221
+ failed++;
222
+ console.error(`md2pdf: ${file}: ${err.message}`);
223
+ }
224
+ }
225
+ process.exit(failed ? 1 : 0);
226
+ }
227
+
228
+ main();
@@ -0,0 +1,73 @@
1
+ // GitHub alerts and Obsidian callouts: a blockquote whose first line is `[!type]` becomes a titled box,
2
+ // <div class="markdown-alert markdown-alert-{family}">, the markup github.com uses for alerts.
3
+ // Obsidian's optional title follows the marker; its fold markers ([!note]- and [!note]+) are ignored,
4
+ // since a PDF can't fold.
5
+
6
+ // Each type belongs to one of GitHub's five alert families, or to quote. Unknown types look like notes.
7
+ const FAMILIES = {
8
+ note: ['note', 'info', 'todo', 'abstract', 'summary', 'tldr'],
9
+ tip: ['tip', 'hint', 'success', 'check', 'done'],
10
+ important: ['important', 'example'],
11
+ warning: ['warning', 'attention', 'question', 'help', 'faq'],
12
+ caution: ['caution', 'danger', 'error', 'failure', 'fail', 'missing', 'bug'],
13
+ quote: ['quote', 'cite'],
14
+ };
15
+ const FAMILY_OF = Object.fromEntries(
16
+ Object.entries(FAMILIES).flatMap(([family, types]) => types.map((type) => [type, family])),
17
+ );
18
+
19
+ // Octicons 19 (MIT, github.com/primer/octicons): the icons GitHub shows on alerts, plus quote.
20
+ const ICONS = {
21
+ note: 'M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z',
22
+ tip: 'M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z',
23
+ important: 'M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z',
24
+ warning: 'M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z',
25
+ caution: 'M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z',
26
+ quote: 'M1.75 2.5h10.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Zm4 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2.5 7.75v6a.75.75 0 0 1-1.5 0v-6a.75.75 0 0 1 1.5 0Z',
27
+ };
28
+
29
+ // `[!type]`, an optional fold marker, then an optional title, on the blockquote's first line.
30
+ const MARKER = /^\[!([\w-]+)\][+-]?(?:[ \t]+(.*))?$/;
31
+
32
+ export default function callouts(md) {
33
+ // Runs before inline parsing, so the title can be given its own inline token and still get Markdown.
34
+ md.core.ruler.before('inline', 'callouts', (state) => {
35
+ const tokens = state.tokens;
36
+ for (let i = 0; i < tokens.length; i++) {
37
+ const open = tokens[i];
38
+ if (open.type !== 'blockquote_open' || tokens[i + 1]?.type !== 'paragraph_open') continue;
39
+ const inline = tokens[i + 2];
40
+ const [first, ...rest] = inline.content.split('\n');
41
+ const marker = first.match(MARKER);
42
+ if (!marker) continue;
43
+
44
+ const type = marker[1].toLowerCase();
45
+ const family = FAMILY_OF[type] ?? 'note';
46
+ const close = tokens.slice(i + 1).find((t) => t.type === 'blockquote_close' && t.level === open.level);
47
+ open.tag = close.tag = 'div';
48
+ open.attrSet('class', `markdown-alert markdown-alert-${family}`);
49
+ open.attrSet('data-callout', type);
50
+
51
+ const titleOpen = new state.Token('callout_title_open', 'p', 1);
52
+ titleOpen.meta = { family };
53
+ const title = new state.Token('inline', '', 0);
54
+ title.content = marker[2]?.trim() || type[0].toUpperCase() + type.slice(1);
55
+ title.children = [];
56
+ const titleClose = new state.Token('callout_title_close', 'p', -1);
57
+ for (const t of [titleOpen, title, titleClose]) {
58
+ t.block = true;
59
+ t.level = open.level + (t.type === 'inline' ? 2 : 1);
60
+ t.map = inline.map && [inline.map[0], inline.map[0] + 1];
61
+ }
62
+
63
+ // The rest of the first paragraph is the body; a callout with only a title has none.
64
+ inline.content = rest.join('\n');
65
+ if (inline.map) tokens[i + 1].map = inline.map = [inline.map[0] + 1, inline.map[1]];
66
+ tokens.splice(i + 1, inline.content ? 0 : 3, titleOpen, title, titleClose);
67
+ }
68
+ });
69
+
70
+ md.renderer.rules.callout_title_open = (tokens, idx) =>
71
+ `<p class="markdown-alert-title"><svg class="octicon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="${ICONS[tokens[idx].meta.family]}"></path></svg>`;
72
+ md.renderer.rules.callout_title_close = () => '</p>\n';
73
+ }
package/lib/render.js ADDED
@@ -0,0 +1,110 @@
1
+ // Markdown → HTML rendering shared by the CLI and the md2pdf.me website.
2
+
3
+ import MarkdownIt from 'markdown-it';
4
+ import anchor from 'markdown-it-anchor';
5
+ import footnote from 'markdown-it-footnote';
6
+ import taskLists from 'markdown-it-task-lists';
7
+ import { full as emoji } from 'markdown-it-emoji';
8
+ import callouts from './callouts.js';
9
+ import wikilinks from './wikilinks.js';
10
+
11
+ // YAML front matter: a `---` line at the very start of the document, closed by another `---` line.
12
+ const FRONT_MATTER = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/;
13
+
14
+ // Leaves front matter (Obsidian, Jekyll, Hugo) out of the output instead of rendering it as a rule and a heading.
15
+ function frontMatter(md) {
16
+ md.block.ruler.before('table', 'front_matter', (state, startLine, endLine, silent) => {
17
+ if (startLine !== 0) return false;
18
+ const block = state.src.match(FRONT_MATTER)?.[0];
19
+ if (!block) return false;
20
+ if (!silent) state.line = block.replace(/\n$/, '').split('\n').length;
21
+ return true;
22
+ });
23
+ }
24
+
25
+ // Heading ids, which [[#Heading]] links must match.
26
+ const slugify = (s) => s.trim().toLowerCase().replace(/[^\w\- ]+/g, '').replace(/\s/g, '-');
27
+
28
+ export const escapeHtml = (s) =>
29
+ s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
30
+
31
+ // `hljs` is passed in so the CLI can use every language while the website
32
+ // ships the smaller common-languages bundle. `math` ({ texmath, katex }) is optional:
33
+ // the website loads it on demand when a document contains formulas. `html: false` escapes raw HTML in
34
+ // the Markdown instead of passing it through; use it for Markdown you didn't write.
35
+ export function createRenderer({ hljs, math, html = true }) {
36
+ const md = new MarkdownIt({
37
+ html,
38
+ linkify: true,
39
+ typographer: true,
40
+ highlight(code, lang) {
41
+ code = code.replace(/\n$/, '');
42
+ const body =
43
+ lang && hljs.getLanguage(lang)
44
+ ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value
45
+ : escapeHtml(code);
46
+ return `<pre><code class="hljs${lang ? ` language-${escapeHtml(lang)}` : ''}">${body}</code></pre>`;
47
+ },
48
+ });
49
+ md.use(anchor, { slugify })
50
+ .use(footnote)
51
+ .use(taskLists, { label: true })
52
+ .use(emoji)
53
+ .use(callouts)
54
+ .use(wikilinks, { slugify })
55
+ .use(frontMatter);
56
+ if (math) {
57
+ md.use(math.texmath, {
58
+ engine: math.katex,
59
+ // ChatGPT writes \( \) and \[ \]; many others use $ and $$.
60
+ delimiters: ['brackets', 'dollars', 'beg_end'],
61
+ // Require a space before an opening $ so prices like "$5 and $10" stay text.
62
+ outerSpace: true,
63
+ katexOptions: { throwOnError: false, output: 'html' },
64
+ });
65
+ }
66
+ return md;
67
+ }
68
+
69
+ // The document title: the first level-one heading, else the front matter's `title:`, else the fallback.
70
+ export function titleOf(markdown, fallback) {
71
+ const [block, yaml = ''] = markdown.match(FRONT_MATTER) ?? [''];
72
+ const heading = markdown.slice(block.length).match(/^#\s+(.+)$/m)?.[1].trim();
73
+ const title = yaml.match(/^title:[ \t]*(.+?)[ \t]*$/m)?.[1].replace(/^(["'])(.*)\1$/, '$2').trim();
74
+ return heading || title || fallback;
75
+ }
76
+
77
+ // A quoted CSS string, safe inside a <style> element.
78
+ export const cssString = (s) =>
79
+ `"${s.replace(/[\\"]/g, '\\$&').replace(/[\0-\x1f\x7f<]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
80
+
81
+ // The text of the first <h1> in rendered HTML, or '' if there is none.
82
+ export function headingText(html) {
83
+ const inner = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/)?.[1] ?? '';
84
+ const text = inner.replace(/<[^>]*>/g, '');
85
+ return text.replace(/&(lt|gt|quot|amp);/g, (_, e) => ({ lt: '<', gt: '>', quot: '"', amp: '&' })[e]).trim();
86
+ }
87
+
88
+ // A complete printable HTML document. `css` is the style's on-screen stylesheet followed by its print
89
+ // stylesheet.
90
+ // Styles can show the first heading in a running header with `content: var(--document-title)`.
91
+ export function buildDocument({ bodyHtml, title, css, pageSize = 'Letter', head = '' }) {
92
+ return `<!DOCTYPE html>
93
+ <html lang="en">
94
+ <head>
95
+ <meta charset="utf-8">
96
+ <title>${escapeHtml(title)}</title>
97
+ ${head}
98
+ <style>
99
+ ${css}
100
+ @page { size: ${pageSize}; }
101
+ :root { --document-title: ${cssString(headingText(bodyHtml))}; }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <article class="markdown-body">
106
+ ${bodyHtml}
107
+ </article>
108
+ </body>
109
+ </html>`;
110
+ }
package/lib/styles.js ADDED
@@ -0,0 +1,11 @@
1
+ // Facts about each style that code needs without reading its CSS.
2
+
3
+ // Each style's page margin at default spacing: the `margin` of the `@page` rule in styles/print/<name>.css.
4
+ export const pageMargins = {
5
+ github: '0.75in 0.7in',
6
+ classic: '1in 1.1in 0.9in',
7
+ academic: '1in 1.15in',
8
+ modern: '0.9in 1in',
9
+ report: '1in 1in 0.9in',
10
+ resume: '0.6in 0.7in',
11
+ };