@kobekeye/mdf 1.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
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 ADDED
@@ -0,0 +1,82 @@
1
+ # mdf
2
+
3
+ Convert Markdown to beautiful PDFs — free, open-source, and zero-config.
4
+
5
+ ## Features
6
+
7
+ - **Syntax highlighting** — fenced code blocks with language detection (via highlight.js)
8
+ - **Math formulas** — inline and block LaTeX via KaTeX (`$...$` and `$$...$$`)
9
+ - **Table of contents** — insert `[TOC]` anywhere in your document
10
+ - **Callout blocks** — `:::info`, `:::warning`, `:::danger`, `:::success`
11
+ - **Task lists** — `- [ ]` and `- [x]`
12
+ - **Tables, images, blockquotes**
13
+ - **Manual page breaks** — insert `==page==` on its own line
14
+ - **CJK support** — uses Inter + Noto Sans TC via Google Fonts (requires internet on first run)
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install -g mdf
20
+ ```
21
+
22
+ Or run without installing:
23
+
24
+ ```bash
25
+ npx mdf input.md
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ mdf input.md # outputs input.pdf
32
+ mdf input.md output.pdf # custom output name
33
+ ```
34
+
35
+ ## Syntax Guide
36
+
37
+ ### Table of Contents
38
+
39
+ ```markdown
40
+ [TOC]
41
+ ```
42
+
43
+ ### Math
44
+
45
+ ```markdown
46
+ Inline: $E = mc^2$
47
+
48
+ Block:
49
+ $$
50
+ \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
51
+ $$
52
+ ```
53
+
54
+ ### Callout Blocks
55
+
56
+ ```markdown
57
+ :::info Title
58
+ This is an info callout.
59
+ :::
60
+
61
+ :::warning
62
+ Watch out!
63
+ :::
64
+
65
+ :::danger Critical
66
+ This is dangerous.
67
+ :::
68
+
69
+ :::success
70
+ Operation completed.
71
+ :::
72
+ ```
73
+
74
+ ### Manual Page Break
75
+
76
+ ```markdown
77
+ ==page==
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT
package/index.js ADDED
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const puppeteer = require('puppeteer');
5
+ const { renderToHtml } = require('./renderer');
6
+
7
+ // parse command line arguments
8
+ const args = process.argv.slice(2);
9
+
10
+ if (args.length === 0) {
11
+ console.log(`
12
+ usage: node index.js <input.md> [output.pdf]
13
+
14
+ input.md required, the Markdown file to convert
15
+ output.pdf optional, the output PDF file name
16
+ (if omitted, it will automatically use the same name, e.g. input.pdf)
17
+
18
+ examples:
19
+ node index.js README.md
20
+ node index.js doc.md custom-name.pdf
21
+ `);
22
+ process.exit(0);
23
+ }
24
+
25
+ const inputFile = args[0];
26
+ const outputFile = args[1] || inputFile.replace(/\.md$/i, '.pdf');
27
+
28
+ // check if input file exists
29
+ if (!fs.existsSync(inputFile)) {
30
+ console.error(`\x1b[31mError: file not found: ${inputFile}\x1b[0m`);
31
+ process.exit(1);
32
+ }
33
+
34
+ async function convertToPdf() {
35
+ console.log(`converting: ${inputFile} → ${outputFile}`);
36
+
37
+ const fullHtml = renderToHtml(inputFile);
38
+
39
+ // write HTML to temp file (in the same directory as Markdown), let Puppeteer parse relative path of images correctly
40
+ const inputDir = path.dirname(path.resolve(inputFile));
41
+ const tempHtmlPath = path.join(inputDir, `.mdf-temp-${Date.now()}.html`);
42
+ fs.writeFileSync(tempHtmlPath, fullHtml, 'utf-8');
43
+
44
+ try {
45
+ const browser = await puppeteer.launch();
46
+ const page = await browser.newPage();
47
+
48
+ // use file:// URL to load, let relative path of images parse correctly
49
+ await page.goto(`file://${tempHtmlPath}`, { waitUntil: 'networkidle0' });
50
+ // wait for all fonts (including Google Fonts) to load completely, avoid CJK characters use synthetic bold
51
+ await page.evaluate(() => document.fonts.ready);
52
+
53
+ await page.pdf({
54
+ path: outputFile,
55
+ format: 'A4',
56
+ printBackground: true,
57
+ margin: { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' },
58
+
59
+ displayHeaderFooter: true,
60
+ headerTemplate: '<span></span>', // do not display header
61
+ footerTemplate: `
62
+ <div style="width: 100%; text-align: center; font-size: 14px; color: #888; font-family: 'Times New Roman', Times, serif; margin-bottom: 5mm;">
63
+ <span class="pageNumber"></span>
64
+ </div>`,
65
+ });
66
+
67
+ await browser.close();
68
+ console.log(`\x1b[32mDone! Output to: ${path.resolve(outputFile)}\x1b[0m`);
69
+ } finally {
70
+ // clean up temp HTML file
71
+ try { fs.unlinkSync(tempHtmlPath); } catch (_) { }
72
+ }
73
+ }
74
+
75
+ convertToPdf().catch((err) => {
76
+ console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
77
+ process.exit(1);
78
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@kobekeye/mdf",
3
+ "version": "1.0.0",
4
+ "description": "Convert Markdown to beautiful PDFs — free, open-source, and zero-config.",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "mdf": "./index.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": [
13
+ "markdown",
14
+ "pdf",
15
+ "converter",
16
+ "cli",
17
+ "katex",
18
+ "syntax-highlighting"
19
+ ],
20
+ "author": "kobekeye",
21
+ "license": "MIT",
22
+ "type": "commonjs",
23
+ "dependencies": {
24
+ "highlight.js": "^11.11.1",
25
+ "katex": "^0.16.33",
26
+ "markdown-it": "^14.1.1",
27
+ "markdown-it-anchor": "^9.2.0",
28
+ "markdown-it-container": "^4.0.0",
29
+ "markdown-it-task-lists": "^2.1.1",
30
+ "markdown-it-texmath": "^1.0.0",
31
+ "puppeteer": "^24.37.5"
32
+ }
33
+ }
package/renderer.js ADDED
@@ -0,0 +1,212 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const MarkdownIt = require('markdown-it');
4
+ const texmath = require('markdown-it-texmath');
5
+ const katex = require('katex');
6
+ const hljs = require('highlight.js');
7
+ const anchor = require('markdown-it-anchor');
8
+ const taskLists = require('markdown-it-task-lists');
9
+ const container = require('markdown-it-container');
10
+ const md = new MarkdownIt({
11
+ html: true,
12
+ highlight: function (str, lang) {
13
+ if (lang && hljs.getLanguage(lang)) {
14
+ try {
15
+ return '<pre class="hljs"><code>' +
16
+ hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
17
+ '</code></pre>';
18
+ } catch (_) { }
19
+ }
20
+ return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
21
+ }
22
+ });
23
+ md.use(texmath, {
24
+ engine: katex,
25
+ delimiters: 'dollars',
26
+ });
27
+ md.use(anchor, {
28
+ permalink: false,
29
+ slugify: (s) => encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-')),
30
+ });
31
+ md.use(taskLists, { enabled: true });
32
+ // --- Callout container blocks: :::info, :::warning, :::danger, :::success ---
33
+ // Color aliases: :::blue = :::info, :::orange = :::warning, etc.
34
+ const CALLOUT_TYPES = [
35
+ { names: ['info', 'blue'], cssClass: 'info' },
36
+ { names: ['warning', 'orange'], cssClass: 'warning' },
37
+ { names: ['danger', 'red'], cssClass: 'danger' },
38
+ { names: ['success', 'green'], cssClass: 'success' },
39
+ ];
40
+ for (const { names, cssClass } of CALLOUT_TYPES) {
41
+ for (const name of names) {
42
+ md.use(container, name, {
43
+ render(tokens, idx) {
44
+ const token = tokens[idx];
45
+ if (token.nesting === 1) {
46
+ // opening tag – extract optional title after :::type
47
+ const titleMatch = token.info.trim().match(new RegExp(`^${name}\\s+(.*)$`));
48
+ const title = titleMatch ? titleMatch[1].trim() : '';
49
+ const titleHtml = title
50
+ ? `<p class="callout-title">${md.utils.escapeHtml(title)}</p>\n`
51
+ : '';
52
+ return `<div class="callout callout-${cssClass}">\n${titleHtml}`;
53
+ }
54
+ return '</div>\n';
55
+ },
56
+ });
57
+ }
58
+ }
59
+ // --- Spoiler (collapsible) container: :::spoiler ---
60
+ md.use(container, 'spoiler', {
61
+ render(tokens, idx) {
62
+ const token = tokens[idx];
63
+ if (token.nesting === 1) {
64
+ const titleMatch = token.info.trim().match(/^spoiler\s+(.*)$/);
65
+ const summary = titleMatch ? titleMatch[1].trim() : 'Spoiler';
66
+ return `<details class="callout-spoiler">\n<summary>${md.utils.escapeHtml(summary)}</summary>\n`;
67
+ }
68
+ return '</details>\n';
69
+ },
70
+ });
71
+ // Unique HTML comment used as TOC marker (survives markdown-it rendering)
72
+ const TOC_MARKER = '<!--TOC_PLACEHOLDER-->';
73
+ // Regex to match [TOC] on its own line in raw Markdown
74
+ const TOC_MD_REGEX = /^\[TOC\]$/gmi;
75
+ /**
76
+ * Generate a table of contents HTML from rendered HTML headings
77
+ * @param {string} html - rendered HTML string
78
+ * @returns {string} TOC HTML string
79
+ */
80
+ function generateTOC(html) {
81
+ const headingRegex = /<h([1-6])\s+id="([^"]*)"[^>]*>(.*?)<\/h[1-6]>/gi;
82
+ const headings = [];
83
+ let match;
84
+ while ((match = headingRegex.exec(html)) !== null) {
85
+ headings.push({
86
+ level: parseInt(match[1]),
87
+ id: match[2],
88
+ text: match[3].replace(/<[^>]+>/g, ''), // strip inner HTML tags
89
+ });
90
+ }
91
+ if (headings.length === 0) return '';
92
+ // find the minimum heading level to use as base
93
+ const minLevel = Math.min(...headings.map(h => h.level));
94
+ let tocHtml = '<nav class="toc">\n<p class="toc-title">Contents</p>\n<ul>\n';
95
+ let currentLevel = minLevel;
96
+ let isFirst = true;
97
+ for (const heading of headings) {
98
+ const level = heading.level;
99
+ if (level > currentLevel) {
100
+ // go deeper: open nested <ul>
101
+ for (let i = currentLevel; i < level; i++) {
102
+ tocHtml += '<ul>\n';
103
+ }
104
+ } else if (level < currentLevel) {
105
+ // go up: close nested <ul>
106
+ for (let i = currentLevel; i > level; i--) {
107
+ tocHtml += '</ul>\n</li>\n';
108
+ }
109
+ } else if (!isFirst) {
110
+ // same level, close previous <li>
111
+ tocHtml += '</li>\n';
112
+ }
113
+ tocHtml += `<li><a href="#${heading.id}">${heading.text}</a>\n`;
114
+ currentLevel = level;
115
+ isFirst = false;
116
+ }
117
+ // close remaining open tags
118
+ for (let i = currentLevel; i >= minLevel; i--) {
119
+ tocHtml += '</li>\n</ul>\n';
120
+ }
121
+ tocHtml += '</nav>';
122
+ return tocHtml;
123
+ }
124
+ /**
125
+ * Replace [TOC] in raw Markdown with a unique marker before rendering
126
+ * @param {string} markdown - raw Markdown string
127
+ * @returns {string} Markdown with [TOC] replaced by marker
128
+ */
129
+ function preprocessTOC(markdown) {
130
+ return markdown.replace(TOC_MD_REGEX, TOC_MARKER);
131
+ }
132
+ /**
133
+ * Replace TOC marker in rendered HTML with the actual generated TOC
134
+ * @param {string} html - rendered HTML string
135
+ * @returns {string} HTML with TOC inserted
136
+ */
137
+ function processTOC(html) {
138
+ if (!html.includes(TOC_MARKER)) return html;
139
+ const toc = generateTOC(html);
140
+ return html.replace(TOC_MARKER, toc);
141
+ }
142
+ // CSS file paths
143
+ const themePath = path.join(__dirname, 'themes', 'default.css');
144
+ const hljsCssPath = path.join(__dirname, 'node_modules', 'highlight.js', 'styles', 'github-dark.css');
145
+ const texmathCssPath = path.join(__dirname, 'node_modules', 'markdown-it-texmath', 'css', 'texmath.css');
146
+ // KaTeX CSS is referenced as a local file:// URL so its bundled fonts resolve correctly
147
+ const katexCssUrl = `file://${path.join(__dirname, 'node_modules', 'katex', 'dist', 'katex.min.css')}`;
148
+ // CSS cache: read once, avoid I/O on every render
149
+ let cachedThemeCSS = null;
150
+ let cachedHljsCSS = null;
151
+ let cachedTexmathCSS = null;
152
+ function loadCSS() {
153
+ if (!cachedThemeCSS) {
154
+ cachedThemeCSS = fs.readFileSync(themePath, 'utf-8');
155
+ cachedHljsCSS = fs.readFileSync(hljsCssPath, 'utf-8');
156
+ cachedTexmathCSS = fs.readFileSync(texmathCssPath, 'utf-8');
157
+ }
158
+ return { themeCSS: cachedThemeCSS, hljsCSS: cachedHljsCSS, texmathCSS: cachedTexmathCSS };
159
+ }
160
+ /**
161
+ * render only Markdown body HTML (without <head>, CSS, etc.) for SSE real-time update, avoid full page reload
162
+ * @param {string} markdownFilePath - Markdown file path
163
+ * @returns {string} rendered HTML body content
164
+ */
165
+ function renderBodyHtml(markdownFilePath) {
166
+ let content = fs.readFileSync(markdownFilePath, 'utf-8');
167
+ content = content.replace(/^==page==$/gm, '\n<div class="page-break"></div>\n');
168
+ content = preprocessTOC(content);
169
+ let html = md.render(content);
170
+ html = processTOC(html);
171
+ return html;
172
+ }
173
+ /**
174
+ * render Markdown file to complete HTML string (including CSS)
175
+ * @param {string} markdownFilePath - Markdown file path
176
+ * @param {object} [options] - options
177
+ * @param {string} [options.extraHeadHtml] - extra HTML to inject into <head>
178
+ * @param {string} [options.extraBodyHtml] - extra HTML to inject at the end of <body>
179
+ * @param {string} [options.contentWrapperId] - if provided, wrap content in a div with this ID (for SSE real-time update)
180
+ * @returns {string} complete HTML string
181
+ */
182
+ function renderToHtml(markdownFilePath, options = {}) {
183
+ let markdownContent = fs.readFileSync(markdownFilePath, 'utf-8');
184
+ markdownContent = markdownContent.replace(/^==page==$/gm, '\n<div class="page-break"></div>\n');
185
+ markdownContent = preprocessTOC(markdownContent);
186
+ const { themeCSS, hljsCSS, texmathCSS } = loadCSS();
187
+ let bodyHtml = md.render(markdownContent);
188
+ bodyHtml = processTOC(bodyHtml);
189
+ // if contentWrapperId is provided, wrap content in a div for SSE real-time update
190
+ if (options.contentWrapperId) {
191
+ bodyHtml = `<div id="${options.contentWrapperId}">${bodyHtml}</div>`;
192
+ }
193
+ const extraHead = options.extraHeadHtml || '';
194
+ const extraBody = options.extraBodyHtml || '';
195
+ return `<!DOCTYPE html>
196
+ <html>
197
+ <head>
198
+ <meta charset="UTF-8">
199
+ <link rel="stylesheet" href="${katexCssUrl}">
200
+ <link rel="preconnect" href="https://fonts.googleapis.com">
201
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
202
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Noto+Sans+TC:wght@400;700&display=swap" rel="stylesheet">
203
+ <style>${texmathCSS} ${hljsCSS} ${themeCSS}</style>
204
+ ${extraHead}
205
+ </head>
206
+ <body>
207
+ ${bodyHtml}
208
+ ${extraBody}
209
+ </body>
210
+ </html>`;
211
+ }
212
+ module.exports = { renderToHtml, renderBodyHtml };
@@ -0,0 +1,370 @@
1
+ /* global */
2
+ *,
3
+ *::before,
4
+ *::after {
5
+ box-sizing: border-box;
6
+ }
7
+
8
+ body {
9
+ font-family: 'inter', 'noto sans tc', sans-serif;
10
+ font-size: 15px;
11
+ line-height: 1.75;
12
+ color: #1a1a2e;
13
+ background: #ffffff;
14
+ max-width: 780px;
15
+ margin: 0 auto;
16
+ padding: 0;
17
+ -webkit-font-smoothing: antialiased;
18
+ }
19
+
20
+ /* headings */
21
+ h1,
22
+ h2,
23
+ h3,
24
+ h4,
25
+ h5,
26
+ h6 {
27
+ font-weight: 700;
28
+ line-height: 1.3;
29
+ margin-top: 2em;
30
+ margin-bottom: 0.6em;
31
+ color: #0d0d1a;
32
+ }
33
+
34
+ h1 {
35
+ font-size: 2em;
36
+ padding-bottom: 0.3em;
37
+ border-bottom: 2px solid #e8eaf0;
38
+ margin-top: 0;
39
+ }
40
+
41
+ /* ensure the first element on every page has no top margin */
42
+ body> :first-child {
43
+ margin-top: 0;
44
+ }
45
+
46
+ .page-break+* {
47
+ margin-top: 0;
48
+ }
49
+
50
+ h2 {
51
+ font-size: 1.4em;
52
+ padding-bottom: 0.2em;
53
+ border-bottom: 1px solid #e8eaf0;
54
+ }
55
+
56
+ h3 {
57
+ font-size: 1.15em;
58
+ }
59
+
60
+ h4 {
61
+ font-size: 1em;
62
+ color: #444;
63
+ }
64
+
65
+ /* paragraphs and texts */
66
+ p {
67
+ margin: 0 0 1em;
68
+ }
69
+
70
+ a {
71
+ color: #3b82f6;
72
+ text-decoration: none;
73
+ border-bottom: 1px solid rgba(59, 130, 246, 0.3);
74
+ }
75
+
76
+ strong {
77
+ font-weight: 700;
78
+ color: #0d0d1a;
79
+ }
80
+
81
+ em {
82
+ font-style: italic;
83
+ color: #333;
84
+ }
85
+
86
+ /* code */
87
+ code {
88
+ font-family: 'jetbrains mono', 'menlo', monospace;
89
+ font-size: 0.85em;
90
+ background: #ebebeb;
91
+ color: #1a1a1a;
92
+ padding: 0.15em 0.4em;
93
+ border-radius: 4px;
94
+ }
95
+
96
+ /* code blocks */
97
+ pre {
98
+ font-family: 'jetbrains mono', 'menlo', monospace;
99
+ font-size: 0.82em;
100
+ line-height: 1.65;
101
+ background: #1e1e2e;
102
+ color: #cdd6f4;
103
+ padding: 20px 24px;
104
+ border-radius: 4px;
105
+ overflow-x: auto;
106
+ margin: 1.2em 0;
107
+ border: 1px solid #2a2a3e;
108
+ }
109
+
110
+ pre code {
111
+ background: none;
112
+ color: inherit;
113
+ padding: 0;
114
+ font-size: inherit;
115
+ border-radius: 0;
116
+ }
117
+
118
+ /* blockquotes */
119
+ blockquote {
120
+ margin: 1.2em 0;
121
+ padding: 12px 20px;
122
+ border-left: 4px solid #3b82f6;
123
+ background: #f0f6ff;
124
+ border-radius: 0 4px 4px 0;
125
+ color: #334155;
126
+ }
127
+
128
+ blockquote p {
129
+ margin: 0;
130
+ }
131
+
132
+ /* horizontal rules */
133
+ hr {
134
+ border: none;
135
+ border-top: 2px solid #e8eaf0;
136
+ margin: 2em 0;
137
+ }
138
+
139
+ /* lists */
140
+ ul,
141
+ ol {
142
+ padding-left: 1.6em;
143
+ margin: 0.5em 0 1em;
144
+ }
145
+
146
+ li {
147
+ margin: 0.3em 0;
148
+ }
149
+
150
+ /* task lists */
151
+ ul.contains-task-list {
152
+ list-style-type: none;
153
+ padding-left: 0.2em;
154
+ }
155
+
156
+ li.task-list-item {
157
+ list-style: none;
158
+ }
159
+
160
+ li.task-list-item input[type="checkbox"] {
161
+ margin-right: 6px;
162
+ cursor: pointer;
163
+ }
164
+
165
+ /* tables */
166
+ table {
167
+ width: 100%;
168
+ table-layout: fixed;
169
+ border-collapse: collapse;
170
+ margin: 1.2em 0;
171
+ font-size: 0.92em;
172
+ }
173
+
174
+ thead tr {
175
+ background: #f5f6ff;
176
+ border-bottom: 2px solid #d4d8f0;
177
+ }
178
+
179
+ th {
180
+ font-weight: 600;
181
+ text-align: left;
182
+ padding: 10px 14px;
183
+ color: #0d0d1a;
184
+ border: 1px solid #d4d8f0;
185
+ }
186
+
187
+ td {
188
+ padding: 9px 14px;
189
+ border: 1px solid #e8eaf0;
190
+ color: #334155;
191
+ }
192
+
193
+ tr:nth-child(even) td {
194
+ background: #fafafe;
195
+ }
196
+
197
+ /* images */
198
+ img {
199
+ max-width: 100%;
200
+ border-radius: 6px;
201
+ display: block;
202
+ margin: 1em auto;
203
+ }
204
+
205
+ /* fundamental automatic page breaks */
206
+ h1,
207
+ h2,
208
+ h3,
209
+ h4 {
210
+ break-after: avoid;
211
+ }
212
+
213
+ pre,
214
+ blockquote,
215
+ img,
216
+ table,
217
+ figure {
218
+ break-inside: avoid;
219
+ }
220
+
221
+ /* manual page breaks */
222
+ .page-break {
223
+ break-after: page;
224
+ }
225
+
226
+ /* table of contents */
227
+ .toc {
228
+ background: #f8f9fc;
229
+ border: 1px solid #e2e5f0;
230
+ border-radius: 6px;
231
+ padding: 20px 28px;
232
+ margin: 1.5em 0 2em;
233
+ }
234
+
235
+ .toc-title {
236
+ font-weight: 700;
237
+ font-size: 1.15em;
238
+ color: #0d0d1a;
239
+ margin: 0 0 0.6em;
240
+ padding-bottom: 0.4em;
241
+ border-bottom: 1px solid #e2e5f0;
242
+ }
243
+
244
+ .toc ul {
245
+ list-style: none;
246
+ padding-left: 0;
247
+ margin: 0;
248
+ }
249
+
250
+ .toc ul ul {
251
+ padding-left: 1.4em;
252
+ }
253
+
254
+ .toc li {
255
+ margin: 0.25em 0;
256
+ }
257
+
258
+ .toc a {
259
+ color: #3b5998;
260
+ border-bottom: none;
261
+ text-decoration: none;
262
+ font-size: 0.95em;
263
+ }
264
+
265
+ .toc a:hover {
266
+ color: #3b82f6;
267
+ text-decoration: underline;
268
+ }
269
+
270
+ /* ===== Callout container blocks ===== */
271
+ .callout {
272
+ margin: 1.2em 0;
273
+ padding: 14px 20px;
274
+ border-left: 4px solid;
275
+ border-radius: 0 6px 6px 0;
276
+ font-size: 0.95em;
277
+ line-height: 1.7;
278
+ break-inside: avoid;
279
+ }
280
+
281
+ .callout p {
282
+ margin: 0 0 0.5em;
283
+ }
284
+
285
+ .callout p:last-child {
286
+ margin-bottom: 0;
287
+ }
288
+
289
+ .callout strong,
290
+ .callout-spoiler strong {
291
+ color: inherit;
292
+ }
293
+
294
+ .callout-title {
295
+ font-weight: 700;
296
+ margin: 0 0 0.4em !important;
297
+ font-size: 1em;
298
+ }
299
+
300
+ /* info – blue */
301
+ .callout-info {
302
+ border-left-color: #3b82f6;
303
+ background: #eff6ff;
304
+ color: #1e3a5f;
305
+ }
306
+
307
+ .callout-info .callout-title {
308
+ color: #2563eb;
309
+ }
310
+
311
+ /* warning – amber */
312
+ .callout-warning {
313
+ border-left-color: #f59e0b;
314
+ background: #fffbeb;
315
+ color: #78350f;
316
+ }
317
+
318
+ .callout-warning .callout-title {
319
+ color: #d97706;
320
+ }
321
+
322
+ /* danger – red */
323
+ .callout-danger {
324
+ border-left-color: #ef4444;
325
+ background: #fef2f2;
326
+ color: #7f1d1d;
327
+ }
328
+
329
+ .callout-danger .callout-title {
330
+ color: #dc2626;
331
+ }
332
+
333
+ /* success – green */
334
+ .callout-success {
335
+ border-left-color: #22c55e;
336
+ background: #f0fdf4;
337
+ color: #14532d;
338
+ }
339
+
340
+ .callout-success .callout-title {
341
+ color: #16a34a;
342
+ }
343
+
344
+ /* spoiler – collapsible */
345
+ .callout-spoiler {
346
+ margin: 1.2em 0;
347
+ padding: 14px 20px;
348
+ border-left: 4px solid #94a3b8;
349
+ background: #f8fafc;
350
+ border-radius: 0 6px 6px 0;
351
+ break-inside: avoid;
352
+ }
353
+
354
+ .callout-spoiler summary {
355
+ font-weight: 700;
356
+ cursor: pointer;
357
+ color: #475569;
358
+ font-size: 0.95em;
359
+ user-select: none;
360
+ }
361
+
362
+ .callout-spoiler summary:hover {
363
+ color: #334155;
364
+ }
365
+
366
+ .callout-spoiler[open] summary {
367
+ margin-bottom: 0.6em;
368
+ padding-bottom: 0.4em;
369
+ border-bottom: 1px solid #e2e8f0;
370
+ }