mdv-live 0.5.14 → 0.5.15
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 +42 -0
- package/README.md +8 -0
- package/bin/mdv.js +27 -40
- package/package.json +3 -3
- package/src/api/pdf.js +10 -138
- package/src/services/pdf.js +206 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.15] - 2026-05-09
|
|
9
|
+
|
|
10
|
+
### Refactored
|
|
11
|
+
|
|
12
|
+
- **PDF 生成ロジックを `src/services/pdf.js` に集約**:
|
|
13
|
+
- サーバー HTTP route (`src/api/pdf.js`) と CLI (`bin/mdv.js convert`) の
|
|
14
|
+
両方が **同じ実装** を共有
|
|
15
|
+
- bug fix・security check (realpath/symlink)・hoist 対応・stdin pipe ハング
|
|
16
|
+
対応・workspace 汚染回避 (temp copy) が 1 箇所に集約。今後どちらの経路
|
|
17
|
+
でも同じ品質を保つ
|
|
18
|
+
- **CLI から `npx` を完全排除**:
|
|
19
|
+
- 旧: `execFileSync('npx', ['md-to-pdf', ...])` / `npx @marp-team/marp-cli`
|
|
20
|
+
- 新: `services/pdf.js` の `exportMarpPdf` / `exportMarkdownPdf` を直接呼ぶ
|
|
21
|
+
- 効果: registry 不通でも動く、バージョン揺れ防止、サーバー側と整合
|
|
22
|
+
|
|
23
|
+
### Changed (breaking-ish)
|
|
24
|
+
|
|
25
|
+
- **`md-to-pdf` を `optionalDependencies` に降格** (旧 `dependencies`):
|
|
26
|
+
- デフォルト `npm install mdv-live` で puppeteer/chromium DL がほぼ消滅
|
|
27
|
+
し、install 大幅軽量化 (Marp 使わない人は完全 skip)
|
|
28
|
+
- `@marp-team/marp-cli` も同じく optional (従来通り)
|
|
29
|
+
- PDF 機能 (Plain Markdown / Marp 両方) を使う場合は `--include=optional`
|
|
30
|
+
か通常 `npm install` (default で optional も入る)。CI で `--omit=optional`
|
|
31
|
+
してると `PDF_TOOL_UNAVAILABLE` になり 503 / exit 1 + 案内
|
|
32
|
+
- **既存ユーザー影響**: `npm install --omit=optional` していた人は要再 install
|
|
33
|
+
|
|
34
|
+
### Tests
|
|
35
|
+
|
|
36
|
+
- 247 → **249 件 (+2)**:
|
|
37
|
+
- `src/services/pdf.js` の import が throw しない
|
|
38
|
+
- `bin/mdv.js` が `npx` を呼んでいない (regression guard)
|
|
39
|
+
|
|
40
|
+
### Chore
|
|
41
|
+
|
|
42
|
+
- `tests/fixtures/html-preview/` `tests/fixtures/marp-notes/` を `.gitignore`
|
|
43
|
+
に追加 (test-html-preview.js が runtime に書き出す artifact)
|
|
44
|
+
- `CODEX.md` を git 管理に追加 (codex review の Project ルール)
|
|
45
|
+
|
|
46
|
+
### Docs
|
|
47
|
+
|
|
48
|
+
- README に **依存パッケージは optional 扱い** であることと install 方法を明記
|
|
49
|
+
|
|
8
50
|
## [0.5.14] - 2026-05-09
|
|
9
51
|
|
|
10
52
|
### Changed — Style PDF dispatch をリファイン
|
package/README.md
CHANGED
|
@@ -78,6 +78,14 @@ mdv -v
|
|
|
78
78
|
|
|
79
79
|
Markdown ファイルは CLI または Web UI から PDF に変換できます。
|
|
80
80
|
|
|
81
|
+
> **依存パッケージは optional 扱い** — `@marp-team/marp-cli` (Marp 用) と `md-to-pdf` (Plain Markdown 用) は `optionalDependencies`。デフォルト `npm install` でも入りますが、CI 等で `--omit=optional` 指定すると入りません。**PDF 機能を使うなら**:
|
|
82
|
+
> ```bash
|
|
83
|
+
> npm install -g mdv-live # 通常はこれで OK (optional も入る)
|
|
84
|
+
> # CI などで --omit=optional する場合:
|
|
85
|
+
> npm install --include=optional
|
|
86
|
+
> ```
|
|
87
|
+
> 不在のまま PDF 生成すると、サーバーは 503 + 案内、CLI は exit 1 + `npm install --include=optional` 提案を返します。
|
|
88
|
+
|
|
81
89
|
### CLI
|
|
82
90
|
|
|
83
91
|
```bash
|
package/bin/mdv.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Compatible with the original Python mdv-live CLI
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { execSync } from 'node:child_process';
|
|
9
9
|
import { readFileSync } from 'node:fs';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
11
|
import { createServer as createNetServer } from 'node:net';
|
|
@@ -17,6 +17,7 @@ import open from 'open';
|
|
|
17
17
|
|
|
18
18
|
import { createMdvServer } from '../src/server.js';
|
|
19
19
|
import { resolvePdfOptions, resolveStyle } from '../src/styles/index.js';
|
|
20
|
+
import { exportMarpPdf, exportMarkdownPdf } from '../src/services/pdf.js';
|
|
20
21
|
|
|
21
22
|
const DEFAULT_PORT = 8642;
|
|
22
23
|
const MARP_FRONTMATTER_PATTERN = /^---\s*\n[\s\S]*?marp:\s*true[\s\S]*?\n---/;
|
|
@@ -287,27 +288,41 @@ async function convertToPdf(inputPath, outputPath, styleArg, pdfOptionsPath) {
|
|
|
287
288
|
}
|
|
288
289
|
|
|
289
290
|
/**
|
|
290
|
-
*
|
|
291
|
+
* Format a PDF tool error for CLI output.
|
|
292
|
+
* @param {Error} err
|
|
293
|
+
* @returns {string} Error message including install hint when applicable.
|
|
294
|
+
*/
|
|
295
|
+
function formatPdfToolError(err) {
|
|
296
|
+
if (err.code === 'PDF_TOOL_UNAVAILABLE') {
|
|
297
|
+
return `Error: ${err.message}\n Run: npm install --include=optional`;
|
|
298
|
+
}
|
|
299
|
+
if (err.stderr) {
|
|
300
|
+
return `Error: PDF conversion failed\n${err.stderr}`;
|
|
301
|
+
}
|
|
302
|
+
return `Error: PDF conversion failed\n${err.message || err}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Convert Marp presentation to PDF using the shared service.
|
|
307
|
+
*
|
|
291
308
|
* @param {string} inputPath - Resolved input file path
|
|
292
309
|
* @param {string} outputPath - Resolved output file path
|
|
293
310
|
* @returns {Promise<number>} Exit code
|
|
294
311
|
*/
|
|
295
312
|
async function convertMarpToPdf(inputPath, outputPath) {
|
|
296
313
|
try {
|
|
297
|
-
|
|
298
|
-
encoding: 'utf-8',
|
|
299
|
-
stdio: 'inherit'
|
|
300
|
-
});
|
|
314
|
+
await exportMarpPdf(inputPath, outputPath);
|
|
301
315
|
console.log(`PDF saved: ${outputPath}`);
|
|
302
316
|
return 0;
|
|
303
|
-
} catch {
|
|
304
|
-
console.error(
|
|
317
|
+
} catch (err) {
|
|
318
|
+
console.error(formatPdfToolError(err));
|
|
305
319
|
return 1;
|
|
306
320
|
}
|
|
307
321
|
}
|
|
308
322
|
|
|
309
323
|
/**
|
|
310
|
-
* Convert regular markdown to PDF using
|
|
324
|
+
* Convert regular markdown to PDF using the shared service.
|
|
325
|
+
*
|
|
311
326
|
* @param {string} inputPath - Resolved input file path
|
|
312
327
|
* @param {string} outputPath - Resolved output file path
|
|
313
328
|
* @param {import('../src/styles/index.js').StyleConfig} styleConfig - Style preset
|
|
@@ -315,40 +330,12 @@ async function convertMarpToPdf(inputPath, outputPath) {
|
|
|
315
330
|
*/
|
|
316
331
|
async function convertMarkdownToPdf(inputPath, outputPath, styleConfig) {
|
|
317
332
|
console.log('Converting as document (A4 portrait)...');
|
|
318
|
-
|
|
319
333
|
try {
|
|
320
|
-
|
|
321
|
-
const stylesheetPaths = styleConfig.stylesheets ?? (styleConfig.stylesheet ? [styleConfig.stylesheet] : []);
|
|
322
|
-
|
|
323
|
-
for (const stylesheetPath of stylesheetPaths) {
|
|
324
|
-
args.push('--stylesheet', stylesheetPath);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (styleConfig.highlightStyle) {
|
|
328
|
-
args.push('--highlight-style', styleConfig.highlightStyle);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (styleConfig.css) {
|
|
332
|
-
args.push('--css', styleConfig.css);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
execFileSync('npx', args, {
|
|
336
|
-
encoding: 'utf-8',
|
|
337
|
-
stdio: 'inherit',
|
|
338
|
-
cwd: path.dirname(inputPath),
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
// md-to-pdf outputs to same directory with .pdf extension
|
|
342
|
-
const generatedPdf = inputPath.replace(/\.(md|markdown)$/i, '.pdf');
|
|
343
|
-
if (generatedPdf !== outputPath) {
|
|
344
|
-
await fs.rename(generatedPdf, outputPath);
|
|
345
|
-
}
|
|
346
|
-
|
|
334
|
+
await exportMarkdownPdf(inputPath, outputPath, { styleConfig });
|
|
347
335
|
console.log(`PDF saved: ${outputPath}`);
|
|
348
336
|
return 0;
|
|
349
|
-
} catch {
|
|
350
|
-
console.error(
|
|
351
|
-
console.error('Make sure md-to-pdf is available (npx md-to-pdf)');
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error(formatPdfToolError(err));
|
|
352
339
|
return 1;
|
|
353
340
|
}
|
|
354
341
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mdv-live",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.15",
|
|
4
4
|
"description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -51,13 +51,13 @@
|
|
|
51
51
|
"highlight.js": "^11.10.0",
|
|
52
52
|
"markdown-it": "^14.1.0",
|
|
53
53
|
"markdown-it-task-lists": "^2.1.1",
|
|
54
|
-
"md-to-pdf": "^5.2.5",
|
|
55
54
|
"mime-types": "^2.1.35",
|
|
56
55
|
"multer": "^1.4.5-lts.1",
|
|
57
56
|
"open": "^10.1.0",
|
|
58
57
|
"ws": "^8.18.0"
|
|
59
58
|
},
|
|
60
59
|
"optionalDependencies": {
|
|
61
|
-
"@marp-team/marp-cli": "^4.0.3"
|
|
60
|
+
"@marp-team/marp-cli": "^4.0.3",
|
|
61
|
+
"md-to-pdf": "^5.2.5"
|
|
62
62
|
}
|
|
63
63
|
}
|
package/src/api/pdf.js
CHANGED
|
@@ -1,94 +1,13 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
1
|
import fs from 'fs/promises';
|
|
3
2
|
import path from 'path';
|
|
4
3
|
import os from 'os';
|
|
5
|
-
import { createRequire } from 'module';
|
|
6
4
|
import { isMarp } from '../rendering/markdown.js';
|
|
7
5
|
import { validatePath, validatePathReal } from '../utils/path.js';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
const require = createRequire(import.meta.url);
|
|
11
|
-
const highlightStylesheet = path.resolve(path.dirname(require.resolve('highlight.js')), '..', 'styles', 'atom-one-dark.css');
|
|
12
|
-
const PDF_EXPORT_TIMEOUT_MS = 180000;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Lazily resolve a package's bin script via require.resolve.
|
|
16
|
-
*
|
|
17
|
-
* 0.5.10〜0.5.12 の hoist 罠 + optionalDependencies 欠如対応:
|
|
18
|
-
* - npm hoisting で実体パスが top-level / nested いずれにもなる
|
|
19
|
-
* - optionalDep が欠ける環境 (--omit=optional) では import 時に throw すると
|
|
20
|
-
* サーバー起動が壊れる → request 時に lazy で解決し、欠ければ 503
|
|
21
|
-
*
|
|
22
|
-
* @param {string} pkgName - npm package name (e.g. '@marp-team/marp-cli')
|
|
23
|
-
* @param {string} binName - bin entry key (matches package.json bin)
|
|
24
|
-
* @returns {string} Absolute path to the bin script
|
|
25
|
-
* @throws {Error} `code === 'PDF_TOOL_UNAVAILABLE'` if package missing
|
|
26
|
-
*/
|
|
27
|
-
function resolvePkgBin(pkgName, binName) {
|
|
28
|
-
let pkgPath;
|
|
29
|
-
try {
|
|
30
|
-
pkgPath = require.resolve(`${pkgName}/package.json`);
|
|
31
|
-
} catch (err) {
|
|
32
|
-
const e = new Error(`${pkgName} is not installed.`);
|
|
33
|
-
e.code = 'PDF_TOOL_UNAVAILABLE';
|
|
34
|
-
e.cause = err;
|
|
35
|
-
throw e;
|
|
36
|
-
}
|
|
37
|
-
const pkg = require(`${pkgName}/package.json`);
|
|
38
|
-
const bin = pkg.bin;
|
|
39
|
-
const binRel = typeof bin === 'string' ? bin : bin?.[binName];
|
|
40
|
-
if (!binRel) {
|
|
41
|
-
const e = new Error(`${pkgName} does not declare a "${binName}" bin entry.`);
|
|
42
|
-
e.code = 'PDF_TOOL_UNAVAILABLE';
|
|
43
|
-
throw e;
|
|
44
|
-
}
|
|
45
|
-
return path.join(path.dirname(pkgPath), binRel);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Spawn a PDF tool with stdin closed.
|
|
50
|
-
*
|
|
51
|
-
* 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf は
|
|
52
|
-
* 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
|
|
53
|
-
* ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
|
|
54
|
-
*/
|
|
55
|
-
function runPdfTool(bin, args, { cwd } = {}) {
|
|
56
|
-
return new Promise((resolve, reject) => {
|
|
57
|
-
const child = spawn(process.execPath, [bin, ...args], {
|
|
58
|
-
cwd,
|
|
59
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
|
-
});
|
|
61
|
-
let stdout = '';
|
|
62
|
-
let stderr = '';
|
|
63
|
-
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
64
|
-
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
65
|
-
|
|
66
|
-
const timer = setTimeout(() => {
|
|
67
|
-
child.kill('SIGTERM');
|
|
68
|
-
}, PDF_EXPORT_TIMEOUT_MS);
|
|
69
|
-
|
|
70
|
-
child.on('error', (err) => {
|
|
71
|
-
clearTimeout(timer);
|
|
72
|
-
reject(err);
|
|
73
|
-
});
|
|
74
|
-
child.on('close', (code, signal) => {
|
|
75
|
-
clearTimeout(timer);
|
|
76
|
-
if (code === 0) {
|
|
77
|
-
resolve({ stdout, stderr });
|
|
78
|
-
} else {
|
|
79
|
-
const err = new Error(`${path.basename(bin)} exited with code=${code} signal=${signal}`);
|
|
80
|
-
err.code = code;
|
|
81
|
-
err.signal = signal;
|
|
82
|
-
err.stdout = stdout;
|
|
83
|
-
err.stderr = stderr;
|
|
84
|
-
reject(err);
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
}
|
|
6
|
+
import { exportMarpPdf, exportMarkdownPdf } from '../services/pdf.js';
|
|
89
7
|
|
|
90
8
|
/**
|
|
91
9
|
* Resolve an optional user-selected file path under the server root.
|
|
10
|
+
*
|
|
92
11
|
* @param {string | undefined} relativePath - Path supplied by the web UI.
|
|
93
12
|
* @param {string} rootDir - Server root directory.
|
|
94
13
|
* @returns {Promise<string | null>} Absolute path or null.
|
|
@@ -106,65 +25,15 @@ async function resolveOptionalUserFile(relativePath, rootDir) {
|
|
|
106
25
|
return fullPath;
|
|
107
26
|
}
|
|
108
27
|
|
|
109
|
-
/**
|
|
110
|
-
* Export a regular markdown document with md-to-pdf.
|
|
111
|
-
*
|
|
112
|
-
* 注意 1: md-to-pdf CLI は出力ファイルを **ソース隣に同名 .pdf で書く** 仕様
|
|
113
|
-
* (例: foo.md → foo.pdf next to source)。既存の foo.pdf があると上書き
|
|
114
|
-
* してから temp に rename = ユーザーのワークスペース汚染 + 読み取り専用
|
|
115
|
-
* dir で失敗。これを避けるためソースを **temp dir にコピー** してそこで
|
|
116
|
-
* md-to-pdf を実行し、生成 PDF だけを最終 outputPath に rename する。
|
|
117
|
-
*
|
|
118
|
-
* 注意 2: JS API (mdToPdf()) を使う方法もあるが、puppeteer プロセスが
|
|
119
|
-
* 残って Node test runner が event loop drain 待ちで cancel される。
|
|
120
|
-
* spawn 経由なら子プロセスが exit するので確実に cleanup される。
|
|
121
|
-
*
|
|
122
|
-
* 注意 3: temp copy なので markdown 内の **相対パス画像/CSS** は壊れる。
|
|
123
|
-
* Style 機能は CSS で見た目を整える用途を想定しており、相対パス画像を
|
|
124
|
-
* 多用する markdown は対象外として割り切る。
|
|
125
|
-
*/
|
|
126
|
-
async function exportMarkdownPdf(inputPath, outputPath, stylesheetPath, pdfOptionsPath) {
|
|
127
|
-
const mdToPdfBin = resolvePkgBin('md-to-pdf', 'md-to-pdf');
|
|
128
|
-
const pdfOptions = await resolvePdfOptions(pdfOptionsPath || undefined);
|
|
129
|
-
|
|
130
|
-
const tempSourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mdv-md-'));
|
|
131
|
-
try {
|
|
132
|
-
const tempSourcePath = path.join(tempSourceDir, path.basename(inputPath));
|
|
133
|
-
await fs.copyFile(inputPath, tempSourcePath);
|
|
134
|
-
|
|
135
|
-
const args = [tempSourcePath, '--pdf-options', JSON.stringify(pdfOptions)];
|
|
136
|
-
if (stylesheetPath) {
|
|
137
|
-
args.push('--stylesheet', highlightStylesheet);
|
|
138
|
-
args.push('--stylesheet', stylesheetPath);
|
|
139
|
-
args.push('--highlight-style', 'atom-one-dark');
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
await runPdfTool(mdToPdfBin, args, { cwd: tempSourceDir });
|
|
143
|
-
|
|
144
|
-
const generatedPdf = tempSourcePath.replace(/\.(md|markdown)$/i, '.pdf');
|
|
145
|
-
await fs.rename(generatedPdf, outputPath);
|
|
146
|
-
} finally {
|
|
147
|
-
await fs.rm(tempSourceDir, { recursive: true, force: true });
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Export a Marp slide deck with marp-cli.
|
|
153
|
-
*
|
|
154
|
-
* `@marp-team/marp-cli` is an optionalDependency. When missing the route
|
|
155
|
-
* returns a 503; that is surfaced by the caller.
|
|
156
|
-
*/
|
|
157
|
-
async function exportMarpPdf(inputPath, outputPath) {
|
|
158
|
-
const marpBin = resolvePkgBin('@marp-team/marp-cli', 'marp');
|
|
159
|
-
await runPdfTool(marpBin, [inputPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
28
|
/**
|
|
163
29
|
* Setup PDF export routes.
|
|
164
30
|
*
|
|
165
31
|
* - Marp files → `marp-cli` (slide PDF, landscape, themed)
|
|
166
32
|
* - Plain Markdown → `md-to-pdf` (document PDF, optional CSS / PDF options)
|
|
167
33
|
*
|
|
34
|
+
* 実装は `src/services/pdf.js` に集約。CLI (`bin/mdv.js convert`) も同じ
|
|
35
|
+
* service を使う。
|
|
36
|
+
*
|
|
168
37
|
* @param {Express} app - Express application
|
|
169
38
|
* @returns {void}
|
|
170
39
|
*/
|
|
@@ -210,7 +79,10 @@ export function setupPdfRoutes(app) {
|
|
|
210
79
|
resolveOptionalUserFile(stylePath, rootDir),
|
|
211
80
|
resolveOptionalUserFile(pdfOptionsPath, rootDir),
|
|
212
81
|
]);
|
|
213
|
-
await exportMarkdownPdf(fullPath, outputPath,
|
|
82
|
+
await exportMarkdownPdf(fullPath, outputPath, {
|
|
83
|
+
stylesheetPath,
|
|
84
|
+
pdfOptionsPath: resolvedPdfOptionsPath,
|
|
85
|
+
});
|
|
214
86
|
}
|
|
215
87
|
|
|
216
88
|
res.download(outputPath, outputFileName, async (err) => {
|
|
@@ -224,7 +96,7 @@ export function setupPdfRoutes(app) {
|
|
|
224
96
|
try { await fs.unlink(outputPath); } catch { /* ignore */ }
|
|
225
97
|
if (err.code === 'PDF_TOOL_UNAVAILABLE') {
|
|
226
98
|
return res.status(503).json({
|
|
227
|
-
error: `PDF tool unavailable: ${err.message} Run \`npm install
|
|
99
|
+
error: `PDF tool unavailable: ${err.message} Run \`npm install --include=optional\` and retry.`,
|
|
228
100
|
});
|
|
229
101
|
}
|
|
230
102
|
res.status(500).json({ error: 'PDF export failed' });
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF generation service.
|
|
3
|
+
*
|
|
4
|
+
* 中央集約された PDF 化ロジック:
|
|
5
|
+
* - サーバー HTTP route (`src/api/pdf.js`) と CLI (`bin/mdv.js convert`) の
|
|
6
|
+
* 両方が同じ実装を共有することで、bug fix・security check・hoist 対応・
|
|
7
|
+
* stdin pipe ハング対応・workspace 汚染回避ロジックを 1 箇所に集める
|
|
8
|
+
* - Marp は `@marp-team/marp-cli` (optionalDependency)
|
|
9
|
+
* - Plain markdown は `md-to-pdf` (optionalDependency: 0.5.15 で降格)
|
|
10
|
+
* - どちらも欠如時は `PDF_TOOL_UNAVAILABLE` code を投げ、caller 側で
|
|
11
|
+
* ユーザー向けメッセージに変換 (HTTP 503 / CLI exit 1)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'child_process';
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
import fs from 'fs/promises';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
import { createRequire } from 'module';
|
|
20
|
+
import { resolvePdfOptions } from '../styles/index.js';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
const highlightStylesheet = path.resolve(
|
|
25
|
+
path.dirname(require.resolve('highlight.js')),
|
|
26
|
+
'..',
|
|
27
|
+
'styles',
|
|
28
|
+
'atom-one-dark.css',
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const PDF_EXPORT_TIMEOUT_MS = 180000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lazily resolve a package's bin script via require.resolve.
|
|
35
|
+
*
|
|
36
|
+
* 0.5.10〜0.5.12 の hoist 罠 + optionalDependencies 欠如対応:
|
|
37
|
+
* - npm hoisting で実体パスが top-level / nested いずれにもなる
|
|
38
|
+
* - optionalDep が欠ける環境 (--omit=optional) では import 時に throw すると
|
|
39
|
+
* サーバー起動が壊れる → request 時に lazy で解決し、欠ければ 503 / exit 1
|
|
40
|
+
*
|
|
41
|
+
* @param {string} pkgName - npm package name (e.g. '@marp-team/marp-cli')
|
|
42
|
+
* @param {string} binName - bin entry key (matches package.json bin)
|
|
43
|
+
* @returns {string} Absolute path to the bin script
|
|
44
|
+
* @throws {Error} `code === 'PDF_TOOL_UNAVAILABLE'` if package missing
|
|
45
|
+
*/
|
|
46
|
+
export function resolvePkgBin(pkgName, binName) {
|
|
47
|
+
let pkgPath;
|
|
48
|
+
try {
|
|
49
|
+
pkgPath = require.resolve(`${pkgName}/package.json`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const e = new Error(`${pkgName} is not installed.`);
|
|
52
|
+
e.code = 'PDF_TOOL_UNAVAILABLE';
|
|
53
|
+
e.cause = err;
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
const pkg = require(`${pkgName}/package.json`);
|
|
57
|
+
const bin = pkg.bin;
|
|
58
|
+
const binRel = typeof bin === 'string' ? bin : bin?.[binName];
|
|
59
|
+
if (!binRel) {
|
|
60
|
+
const e = new Error(`${pkgName} does not declare a "${binName}" bin entry.`);
|
|
61
|
+
e.code = 'PDF_TOOL_UNAVAILABLE';
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
return path.join(path.dirname(pkgPath), binRel);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Spawn a PDF tool with stdin closed.
|
|
69
|
+
*
|
|
70
|
+
* 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf は
|
|
71
|
+
* 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
|
|
72
|
+
* ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
|
|
73
|
+
*/
|
|
74
|
+
function runPdfTool(bin, args, { cwd } = {}) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const child = spawn(process.execPath, [bin, ...args], {
|
|
77
|
+
cwd,
|
|
78
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
79
|
+
});
|
|
80
|
+
let stdout = '';
|
|
81
|
+
let stderr = '';
|
|
82
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
83
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
84
|
+
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
child.kill('SIGTERM');
|
|
87
|
+
}, PDF_EXPORT_TIMEOUT_MS);
|
|
88
|
+
|
|
89
|
+
child.on('error', (err) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
reject(err);
|
|
92
|
+
});
|
|
93
|
+
child.on('close', (code, signal) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
if (code === 0) {
|
|
96
|
+
resolve({ stdout, stderr });
|
|
97
|
+
} else {
|
|
98
|
+
const err = new Error(`${path.basename(bin)} exited with code=${code} signal=${signal}`);
|
|
99
|
+
err.code = code;
|
|
100
|
+
err.signal = signal;
|
|
101
|
+
err.stdout = stdout;
|
|
102
|
+
err.stderr = stderr;
|
|
103
|
+
reject(err);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Export a regular markdown document with md-to-pdf.
|
|
111
|
+
*
|
|
112
|
+
* 課題:
|
|
113
|
+
* - md-to-pdf CLI は出力ファイルを **ソース隣に同名 .pdf で書く** 仕様
|
|
114
|
+
* (例: foo.md → foo.pdf next to source)。既存の foo.pdf を上書きしないこと
|
|
115
|
+
* - 同時に、`` のような **相対 asset 参照** を解決
|
|
116
|
+
* するためには md-to-pdf を **source dir で実行** する必要がある
|
|
117
|
+
* (`--basedir` を別dir にすると md-to-pdf が input path から URL を逆算
|
|
118
|
+
* して basedir 外と判定し asset がロードされない、codex round 2 P2)
|
|
119
|
+
*
|
|
120
|
+
* 解決:
|
|
121
|
+
* - source dir 内に **隠し一意名 (`.mdv-pdf-tmp.<stamp>.md`)** で copy 配置
|
|
122
|
+
* - md-to-pdf を source dir で実行 → 隠しPDFが source dir に生まれる
|
|
123
|
+
* - その隠し PDF を outputPath に rename
|
|
124
|
+
* - finally で隠し md / 隠し pdf を確実に削除 (source dir 残留物ゼロ)
|
|
125
|
+
*
|
|
126
|
+
* 副作用 / 制約:
|
|
127
|
+
* - source dir に書き込み権が必要 (read-only dir では fs.copyFile が
|
|
128
|
+
* EACCES/EROFS で throw → caller 側で 503/exit 1 を返す扱いに任せる)
|
|
129
|
+
* - 一意名 + mdv prefix なので既存ファイル衝突なし
|
|
130
|
+
*
|
|
131
|
+
* @param {string} inputPath - Source markdown file (absolute).
|
|
132
|
+
* @param {string} outputPath - Destination PDF file (absolute).
|
|
133
|
+
* @param {object} [options]
|
|
134
|
+
* @param {string|null} [options.stylesheetPath] - Custom CSS file path.
|
|
135
|
+
* @param {string|null} [options.pdfOptionsPath] - PDF options JSON path.
|
|
136
|
+
* @param {object} [options.styleConfig] - Pre-resolved style config (CLI use).
|
|
137
|
+
* When supplied, supersedes stylesheetPath / pdfOptionsPath.
|
|
138
|
+
* @returns {Promise<void>}
|
|
139
|
+
*/
|
|
140
|
+
export async function exportMarkdownPdf(inputPath, outputPath, options = {}) {
|
|
141
|
+
const { stylesheetPath = null, pdfOptionsPath = null, styleConfig = null } = options;
|
|
142
|
+
|
|
143
|
+
const mdToPdfBin = resolvePkgBin('md-to-pdf', 'md-to-pdf');
|
|
144
|
+
const pdfOptions = styleConfig?.pdfOptions
|
|
145
|
+
?? await resolvePdfOptions(pdfOptionsPath || undefined);
|
|
146
|
+
|
|
147
|
+
const sourceDir = path.dirname(inputPath);
|
|
148
|
+
const ext = path.extname(inputPath); // .md / .markdown
|
|
149
|
+
// randomUUID で temp 名を一意に。process.pid + Date.now() だと同一 ms の
|
|
150
|
+
// concurrent 呼出 (例: 並行 /api/pdf/export) で衝突して別リクエストの
|
|
151
|
+
// 入力/出力を上書きする race があった (codex round 3 P2)
|
|
152
|
+
const stamp = `${process.pid}-${randomUUID()}`;
|
|
153
|
+
const tempSourcePath = path.join(sourceDir, `.mdv-pdf-tmp.${stamp}${ext}`);
|
|
154
|
+
const tempPdfPath = tempSourcePath.replace(/\.(md|markdown)$/i, '.pdf');
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await fs.copyFile(inputPath, tempSourcePath);
|
|
158
|
+
|
|
159
|
+
const args = [tempSourcePath, '--pdf-options', JSON.stringify(pdfOptions)];
|
|
160
|
+
|
|
161
|
+
// CLI 経路: styleConfig (resolveStyle 済み) を優先
|
|
162
|
+
if (styleConfig) {
|
|
163
|
+
const stylesheetPaths = styleConfig.stylesheets
|
|
164
|
+
?? (styleConfig.stylesheet ? [styleConfig.stylesheet] : []);
|
|
165
|
+
for (const ssPath of stylesheetPaths) {
|
|
166
|
+
args.push('--stylesheet', ssPath);
|
|
167
|
+
}
|
|
168
|
+
if (styleConfig.highlightStyle) {
|
|
169
|
+
args.push('--highlight-style', styleConfig.highlightStyle);
|
|
170
|
+
}
|
|
171
|
+
if (styleConfig.css) {
|
|
172
|
+
args.push('--css', styleConfig.css);
|
|
173
|
+
}
|
|
174
|
+
} else if (stylesheetPath) {
|
|
175
|
+
// Web UI 経路: 単一 CSS path + デフォルト highlight
|
|
176
|
+
args.push('--stylesheet', highlightStylesheet);
|
|
177
|
+
args.push('--stylesheet', stylesheetPath);
|
|
178
|
+
args.push('--highlight-style', 'atom-one-dark');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await runPdfTool(mdToPdfBin, args, { cwd: sourceDir });
|
|
182
|
+
await fs.rename(tempPdfPath, outputPath);
|
|
183
|
+
} finally {
|
|
184
|
+
// 隠し md / 隠し pdf を確実に削除 (例外発生時も cleanup)
|
|
185
|
+
await fs.unlink(tempSourcePath).catch(() => {});
|
|
186
|
+
await fs.unlink(tempPdfPath).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Export a Marp slide deck with marp-cli.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} inputPath - Source Marp markdown file (absolute).
|
|
194
|
+
* @param {string} outputPath - Destination PDF file (absolute).
|
|
195
|
+
* @returns {Promise<void>}
|
|
196
|
+
*/
|
|
197
|
+
export async function exportMarpPdf(inputPath, outputPath) {
|
|
198
|
+
const marpBin = resolvePkgBin('@marp-team/marp-cli', 'marp');
|
|
199
|
+
await runPdfTool(marpBin, [
|
|
200
|
+
inputPath,
|
|
201
|
+
'-o', outputPath,
|
|
202
|
+
'--html',
|
|
203
|
+
'--allow-local-files',
|
|
204
|
+
'--no-stdin',
|
|
205
|
+
]);
|
|
206
|
+
}
|