mdv-live 0.5.12 → 0.5.14
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 +65 -0
- package/README.md +26 -4
- package/package.json +2 -1
- package/src/api/pdf.js +122 -37
- package/src/static/app.js +142 -6
- package/src/static/index.html +20 -0
- package/src/static/styles.css +35 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,71 @@ 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.14] - 2026-05-09
|
|
9
|
+
|
|
10
|
+
### Changed — Style PDF dispatch をリファイン
|
|
11
|
+
|
|
12
|
+
`PdfStyleManager` の dispatch 判定を **「PDF options JSON の有無」** に変更
|
|
13
|
+
(これまでは「CSS or JSON のいずれか」で server PDF 経路に切り替わっていた)。
|
|
14
|
+
|
|
15
|
+
| CSS | PDF options | PDF ボタン押下で |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| 空 | 空 | 印刷ダイアログ |
|
|
18
|
+
| 入れる | 空 | **印刷ダイアログ** (preview CSS が styled DOM で print engine に渡る) |
|
|
19
|
+
| 入れる/空 | 入れる | サーバー md-to-pdf で styled PDF 自動 DL |
|
|
20
|
+
|
|
21
|
+
JSON は margin/format/printBackground 等の細かい制御用。CSS だけ当てて
|
|
22
|
+
PDF にしたい普通のケースは印刷ダイアログで十分なので、md-to-pdf 経路に
|
|
23
|
+
無闇に流さない。`shouldUseServerPdf()` メソッドに rename。
|
|
24
|
+
|
|
25
|
+
### UX improvements
|
|
26
|
+
|
|
27
|
+
- **Style パネルの placeholder/tooltip** を rootDir 相対パス前提のヒント
|
|
28
|
+
に変更 (`report.css (rootDir からの相対パス)` 等)。Claude Code 等で
|
|
29
|
+
ファイル生成して入力する人が迷わないように
|
|
30
|
+
- **失敗時のステータスメッセージ詳細化**: 旧「Style failed」→ 新
|
|
31
|
+
「Style failed: CSS not found: <path>」のように原因を出す。表示時間
|
|
32
|
+
も 2.5s → 4.5s に延長 (読み切る時間)
|
|
33
|
+
- **PDF export 失敗時** もサーバーから返されたエラーメッセージを
|
|
34
|
+
ステータスバーに表示
|
|
35
|
+
|
|
36
|
+
### Docs
|
|
37
|
+
|
|
38
|
+
- README の `Web UI` 節を全面改訂: dispatch テーブル + Claude Code 連携手順
|
|
39
|
+
|
|
40
|
+
## [0.5.13] - 2026-05-09
|
|
41
|
+
|
|
42
|
+
### Restored
|
|
43
|
+
|
|
44
|
+
- **PDF Style customization (Watanabe @watanko `933147f` の機能)**:
|
|
45
|
+
0.5.10 で僕が誤って "orphan" と判断し削除してしまった機能を復元。
|
|
46
|
+
README にも記載されている公開機能を黙って消したのは判断ミス。
|
|
47
|
+
|
|
48
|
+
- Web UI: `Style` ボタン + パネル (CSS path / PDF options JSON 入力)
|
|
49
|
+
- サーバー: `/api/pdf/export` の Markdown 経路 (md-to-pdf 経由) を復活
|
|
50
|
+
- `md-to-pdf` を `dependencies` に再追加
|
|
51
|
+
|
|
52
|
+
### Added — A2 dispatch
|
|
53
|
+
|
|
54
|
+
Markdown PDF ボタンの動きを **Style 設定の有無** で切り替える:
|
|
55
|
+
- **Style 未設定** (デフォルト) → 印刷ダイアログ (`window.print()`) ← 0.5.10 の岡本意図
|
|
56
|
+
- **Style 設定済** (CSS or PDF options 入力 + Apply) → サーバー md-to-pdf
|
|
57
|
+
で styled PDF DL ← 渡邉さん設計
|
|
58
|
+
|
|
59
|
+
実装: `PdfStyleManager.hasStyle()` を新設し、`PrintManager.print()` の
|
|
60
|
+
Markdown 分岐で分岐。Marp は引き続きサーバー一択。
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
- `src/api/pdf.js` の md-to-pdf bin 解決を hoist 安全 + lazy 化:
|
|
65
|
+
`node_modules/.bin/md-to-pdf` 直叩きをやめ、`require.resolve('md-to-pdf/package.json')`
|
|
66
|
+
で解決。Marp 側と同じ `resolvePkgBin()` ヘルパに統一。
|
|
67
|
+
欠如時は `code: 'PDF_TOOL_UNAVAILABLE'` で 503 (Marp と同じパス)
|
|
68
|
+
|
|
69
|
+
### Tests
|
|
70
|
+
|
|
71
|
+
- 244 件全 PASS (plain markdown PDF テスト復活、415 テスト削除)
|
|
72
|
+
|
|
8
73
|
## [0.5.12] - 2026-05-09
|
|
9
74
|
|
|
10
75
|
### Fixed
|
package/README.md
CHANGED
|
@@ -107,12 +107,34 @@ mdv convert \
|
|
|
107
107
|
|
|
108
108
|
### Web UI
|
|
109
109
|
|
|
110
|
-
ビューア上部の `Style`
|
|
110
|
+
ビューア上部の `Style` ボタンを押すとパネルが開き、以下を指定できます。
|
|
111
111
|
|
|
112
|
-
- CSS
|
|
113
|
-
- PDF options
|
|
112
|
+
- **CSS** — サーバー起動時の `rootDir` からの相対パス (例: `report.css`、`subdir/style.css`)
|
|
113
|
+
- **PDF options** — `rootDir` からの相対パス (例: `pdf-options.json`)。省略可
|
|
114
114
|
|
|
115
|
-
CSS は Markdown
|
|
115
|
+
`Apply` を押すと CSS は Markdown プレビューにも反映されます。`Clear` で解除。
|
|
116
|
+
|
|
117
|
+
#### PDF ボタン押下時の挙動 (Markdown ファイル)
|
|
118
|
+
|
|
119
|
+
| CSS | PDF options | PDF ボタン押下で |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| 空 | 空 | OS の **印刷ダイアログ** (`window.print()`) |
|
|
122
|
+
| 入れる | 空 | OS の印刷ダイアログ。preview の CSS が styled DOM として print engine に渡る |
|
|
123
|
+
| 入れる/空 | **入れる** | サーバー側 `md-to-pdf` で **styled PDF を自動 DL** (`@page` で margin/format を JSON 制御したいときの本格モード) |
|
|
124
|
+
|
|
125
|
+
`PDF options` を入れない限り印刷ダイアログ経由になるので、ふだんは CSS だけ指定すれば OK。
|
|
126
|
+
|
|
127
|
+
#### Claude Code との連携
|
|
128
|
+
|
|
129
|
+
CSS を Claude Code に生成させるとき、**サーバーの `rootDir` 配下** に保存して相対パスを Style パネルに入力してください。例:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
$ mdv ~/notes # rootDir = ~/notes
|
|
133
|
+
# Claude Code で ~/notes/report.css を生成
|
|
134
|
+
# Style パネル CSS 欄に "report.css" を入力 → Apply
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
ファイルが見つからない場合 `Style failed: CSS not found: <path>` がステータスバーに出ます。
|
|
116
138
|
|
|
117
139
|
### ポート自動増分
|
|
118
140
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mdv-live",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
4
4
|
"description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -51,6 +51,7 @@
|
|
|
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",
|
|
54
55
|
"mime-types": "^2.1.35",
|
|
55
56
|
"multer": "^1.4.5-lts.1",
|
|
56
57
|
"open": "^10.1.0",
|
package/src/api/pdf.js
CHANGED
|
@@ -4,58 +4,63 @@ import path from 'path';
|
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { createRequire } from 'module';
|
|
6
6
|
import { isMarp } from '../rendering/markdown.js';
|
|
7
|
-
import { validatePath } from '../utils/path.js';
|
|
7
|
+
import { validatePath, validatePathReal } from '../utils/path.js';
|
|
8
|
+
import { resolvePdfOptions } from '../styles/index.js';
|
|
8
9
|
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
11
|
+
const highlightStylesheet = path.resolve(path.dirname(require.resolve('highlight.js')), '..', 'styles', 'atom-one-dark.css');
|
|
10
12
|
const PDF_EXPORT_TIMEOUT_MS = 180000;
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
* Lazily resolve
|
|
15
|
+
* Lazily resolve a package's bin script via require.resolve.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
19
21
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* @returns {string} Absolute path to the marp-cli bin script.
|
|
25
|
-
* @throws {Error} `code === 'MARP_CLI_UNAVAILABLE'` if the package is missing.
|
|
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
26
|
*/
|
|
27
|
-
function
|
|
27
|
+
function resolvePkgBin(pkgName, binName) {
|
|
28
28
|
let pkgPath;
|
|
29
29
|
try {
|
|
30
|
-
pkgPath = require.resolve(
|
|
30
|
+
pkgPath = require.resolve(`${pkgName}/package.json`);
|
|
31
31
|
} catch (err) {
|
|
32
|
-
const e = new Error(
|
|
33
|
-
e.code = '
|
|
32
|
+
const e = new Error(`${pkgName} is not installed.`);
|
|
33
|
+
e.code = 'PDF_TOOL_UNAVAILABLE';
|
|
34
34
|
e.cause = err;
|
|
35
35
|
throw e;
|
|
36
36
|
}
|
|
37
|
-
const pkg = require(
|
|
38
|
-
const
|
|
37
|
+
const pkg = require(`${pkgName}/package.json`);
|
|
38
|
+
const bin = pkg.bin;
|
|
39
|
+
const binRel = typeof bin === 'string' ? bin : bin?.[binName];
|
|
39
40
|
if (!binRel) {
|
|
40
|
-
const e = new Error(
|
|
41
|
-
e.code = '
|
|
41
|
+
const e = new Error(`${pkgName} does not declare a "${binName}" bin entry.`);
|
|
42
|
+
e.code = 'PDF_TOOL_UNAVAILABLE';
|
|
42
43
|
throw e;
|
|
43
44
|
}
|
|
44
45
|
return path.join(path.dirname(pkgPath), binRel);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
|
-
* Spawn
|
|
49
|
+
* Spawn a PDF tool with stdin closed.
|
|
49
50
|
*
|
|
50
|
-
* 注意: execFile は stdio オプションを受け付けない (Node 仕様)
|
|
51
|
+
* 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf は
|
|
52
|
+
* 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
|
|
53
|
+
* ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
|
|
51
54
|
*/
|
|
52
|
-
function
|
|
53
|
-
const marpEntry = resolveMarpEntry();
|
|
55
|
+
function runPdfTool(bin, args, { cwd } = {}) {
|
|
54
56
|
return new Promise((resolve, reject) => {
|
|
55
|
-
const child = spawn(process.execPath, [
|
|
57
|
+
const child = spawn(process.execPath, [bin, ...args], {
|
|
58
|
+
cwd,
|
|
56
59
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
60
|
});
|
|
61
|
+
let stdout = '';
|
|
58
62
|
let stderr = '';
|
|
63
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
59
64
|
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
60
65
|
|
|
61
66
|
const timer = setTimeout(() => {
|
|
@@ -69,11 +74,12 @@ function runMarp(args) {
|
|
|
69
74
|
child.on('close', (code, signal) => {
|
|
70
75
|
clearTimeout(timer);
|
|
71
76
|
if (code === 0) {
|
|
72
|
-
resolve();
|
|
77
|
+
resolve({ stdout, stderr });
|
|
73
78
|
} else {
|
|
74
|
-
const err = new Error(
|
|
79
|
+
const err = new Error(`${path.basename(bin)} exited with code=${code} signal=${signal}`);
|
|
75
80
|
err.code = code;
|
|
76
81
|
err.signal = signal;
|
|
82
|
+
err.stdout = stdout;
|
|
77
83
|
err.stderr = stderr;
|
|
78
84
|
reject(err);
|
|
79
85
|
}
|
|
@@ -81,12 +87,83 @@ function runMarp(args) {
|
|
|
81
87
|
});
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Resolve an optional user-selected file path under the server root.
|
|
92
|
+
* @param {string | undefined} relativePath - Path supplied by the web UI.
|
|
93
|
+
* @param {string} rootDir - Server root directory.
|
|
94
|
+
* @returns {Promise<string | null>} Absolute path or null.
|
|
95
|
+
*/
|
|
96
|
+
async function resolveOptionalUserFile(relativePath, rootDir) {
|
|
97
|
+
if (!relativePath) return null;
|
|
98
|
+
if (!await validatePathReal(relativePath, rootDir)) {
|
|
99
|
+
throw new Error(`Access denied: ${relativePath}`);
|
|
100
|
+
}
|
|
101
|
+
const fullPath = path.join(rootDir, relativePath);
|
|
102
|
+
const stat = await fs.stat(fullPath);
|
|
103
|
+
if (!stat.isFile()) {
|
|
104
|
+
throw new Error(`Not a file: ${relativePath}`);
|
|
105
|
+
}
|
|
106
|
+
return fullPath;
|
|
107
|
+
}
|
|
108
|
+
|
|
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
|
+
|
|
84
162
|
/**
|
|
85
163
|
* Setup PDF export routes.
|
|
86
164
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* このエンドポイントは Marp 以外を 415 で拒否する。
|
|
165
|
+
* - Marp files → `marp-cli` (slide PDF, landscape, themed)
|
|
166
|
+
* - Plain Markdown → `md-to-pdf` (document PDF, optional CSS / PDF options)
|
|
90
167
|
*
|
|
91
168
|
* @param {Express} app - Express application
|
|
92
169
|
* @returns {void}
|
|
@@ -95,7 +172,7 @@ export function setupPdfRoutes(app) {
|
|
|
95
172
|
const { rootDir } = app.locals;
|
|
96
173
|
|
|
97
174
|
app.post('/api/pdf/export', async (req, res) => {
|
|
98
|
-
const { filePath } = req.body;
|
|
175
|
+
const { filePath, stylePath, pdfOptionsPath } = req.body;
|
|
99
176
|
|
|
100
177
|
if (!filePath) {
|
|
101
178
|
return res.status(400).json({ error: 'filePath is required' });
|
|
@@ -104,6 +181,10 @@ export function setupPdfRoutes(app) {
|
|
|
104
181
|
if (!validatePath(filePath, rootDir)) {
|
|
105
182
|
return res.status(403).json({ error: 'Access denied' });
|
|
106
183
|
}
|
|
184
|
+
// realpath check: symlink で root 外を指す source を拒否
|
|
185
|
+
if (!await validatePathReal(filePath, rootDir)) {
|
|
186
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
187
|
+
}
|
|
107
188
|
|
|
108
189
|
const fullPath = path.join(rootDir, filePath);
|
|
109
190
|
const baseName = path.basename(fullPath, '.md');
|
|
@@ -122,12 +203,16 @@ export function setupPdfRoutes(app) {
|
|
|
122
203
|
}
|
|
123
204
|
|
|
124
205
|
const content = await fs.readFile(fullPath, 'utf-8');
|
|
125
|
-
if (
|
|
126
|
-
|
|
206
|
+
if (isMarp(content)) {
|
|
207
|
+
await exportMarpPdf(fullPath, outputPath);
|
|
208
|
+
} else {
|
|
209
|
+
const [stylesheetPath, resolvedPdfOptionsPath] = await Promise.all([
|
|
210
|
+
resolveOptionalUserFile(stylePath, rootDir),
|
|
211
|
+
resolveOptionalUserFile(pdfOptionsPath, rootDir),
|
|
212
|
+
]);
|
|
213
|
+
await exportMarkdownPdf(fullPath, outputPath, stylesheetPath, resolvedPdfOptionsPath);
|
|
127
214
|
}
|
|
128
215
|
|
|
129
|
-
await runMarp([fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
|
|
130
|
-
|
|
131
216
|
res.download(outputPath, outputFileName, async (err) => {
|
|
132
217
|
if (err) {
|
|
133
218
|
console.error('Download error:', err);
|
|
@@ -137,9 +222,9 @@ export function setupPdfRoutes(app) {
|
|
|
137
222
|
} catch (err) {
|
|
138
223
|
console.error('PDF export error:', err);
|
|
139
224
|
try { await fs.unlink(outputPath); } catch { /* ignore */ }
|
|
140
|
-
if (err.code === '
|
|
225
|
+
if (err.code === 'PDF_TOOL_UNAVAILABLE') {
|
|
141
226
|
return res.status(503).json({
|
|
142
|
-
error:
|
|
227
|
+
error: `PDF tool unavailable: ${err.message} Run \`npm install\` (without --omit=optional) and retry.`,
|
|
143
228
|
});
|
|
144
229
|
}
|
|
145
230
|
res.status(500).json({ error: 'PDF export failed' });
|
package/src/static/app.js
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
const STORAGE_KEYS = {
|
|
13
13
|
THEME: 'mdv-theme',
|
|
14
|
-
SIDEBAR_WIDTH: 'mdv-sidebar-width'
|
|
14
|
+
SIDEBAR_WIDTH: 'mdv-sidebar-width',
|
|
15
|
+
PDF_STYLE_PATH: 'mdv-pdf-style-path',
|
|
16
|
+
PDF_OPTIONS_PATH: 'mdv-pdf-options-path'
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
const HLJS_THEMES = {
|
|
@@ -85,7 +87,9 @@
|
|
|
85
87
|
isResizing: false,
|
|
86
88
|
skipScrollRestore: false,
|
|
87
89
|
uploadTargetPath: '',
|
|
88
|
-
rootPath: ''
|
|
90
|
+
rootPath: '',
|
|
91
|
+
pdfStylePath: localStorage.getItem(STORAGE_KEYS.PDF_STYLE_PATH) || '',
|
|
92
|
+
pdfOptionsPath: localStorage.getItem(STORAGE_KEYS.PDF_OPTIONS_PATH) || ''
|
|
89
93
|
};
|
|
90
94
|
|
|
91
95
|
// ============================================================
|
|
@@ -121,6 +125,12 @@
|
|
|
121
125
|
statusText: document.getElementById('statusText'),
|
|
122
126
|
resizeHandle: document.getElementById('resizeHandle'),
|
|
123
127
|
editToggle: document.getElementById('editToggle'),
|
|
128
|
+
pdfStyleToggle: document.getElementById('pdfStyleToggle'),
|
|
129
|
+
pdfStylePanel: document.getElementById('pdfStylePanel'),
|
|
130
|
+
pdfStylePath: document.getElementById('pdfStylePath'),
|
|
131
|
+
pdfOptionsPath: document.getElementById('pdfOptionsPath'),
|
|
132
|
+
pdfStyleApply: document.getElementById('pdfStyleApply'),
|
|
133
|
+
pdfStyleClear: document.getElementById('pdfStyleClear'),
|
|
124
134
|
editLabel: document.getElementById('editLabel'),
|
|
125
135
|
editorStatus: document.getElementById('editorStatus'),
|
|
126
136
|
shutdownBtn: document.getElementById('shutdownBtn'),
|
|
@@ -163,6 +173,10 @@
|
|
|
163
173
|
});
|
|
164
174
|
}
|
|
165
175
|
|
|
176
|
+
function normalizeUserPath(path) {
|
|
177
|
+
return path.trim().replace(/^\/+/, '');
|
|
178
|
+
}
|
|
179
|
+
|
|
166
180
|
async function apiRequest(url, options = {}) {
|
|
167
181
|
const response = await fetch(url, options);
|
|
168
182
|
const data = await response.json();
|
|
@@ -235,6 +249,116 @@
|
|
|
235
249
|
}
|
|
236
250
|
};
|
|
237
251
|
|
|
252
|
+
// ============================================================
|
|
253
|
+
// PDF Style Preview
|
|
254
|
+
// ============================================================
|
|
255
|
+
|
|
256
|
+
const PdfStyleManager = {
|
|
257
|
+
scopedCssId: 'pdf-style-preview-css',
|
|
258
|
+
|
|
259
|
+
init() {
|
|
260
|
+
elements.pdfStylePath.value = state.pdfStylePath;
|
|
261
|
+
elements.pdfOptionsPath.value = state.pdfOptionsPath;
|
|
262
|
+
elements.pdfStyleToggle.addEventListener('click', () => {
|
|
263
|
+
elements.pdfStylePanel.classList.toggle('hidden');
|
|
264
|
+
});
|
|
265
|
+
elements.pdfStyleApply.addEventListener('click', () => this.applyFromInputs());
|
|
266
|
+
elements.pdfStyleClear.addEventListener('click', () => this.clear());
|
|
267
|
+
elements.pdfStylePath.addEventListener('keydown', (event) => {
|
|
268
|
+
if (event.key === 'Enter') this.applyFromInputs();
|
|
269
|
+
});
|
|
270
|
+
elements.pdfOptionsPath.addEventListener('keydown', (event) => {
|
|
271
|
+
if (event.key === 'Enter') this.applyFromInputs();
|
|
272
|
+
});
|
|
273
|
+
this.loadPreviewCss();
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
// PDF dispatch 切替: PDF options JSON が指定されている時だけサーバー
|
|
277
|
+
// md-to-pdf を使う。CSS のみ (or 何もなし) の場合は印刷ダイアログ経由
|
|
278
|
+
// で OS のページ設定を活かしつつ、preview に当たっている CSS が
|
|
279
|
+
// そのまま print engine に渡って styled PDF が出る。
|
|
280
|
+
shouldUseServerPdf() {
|
|
281
|
+
return !!normalizeUserPath(state.pdfOptionsPath);
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
getExportOptions() {
|
|
285
|
+
return {
|
|
286
|
+
stylePath: normalizeUserPath(state.pdfStylePath),
|
|
287
|
+
pdfOptionsPath: normalizeUserPath(state.pdfOptionsPath)
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async applyFromInputs() {
|
|
292
|
+
state.pdfStylePath = normalizeUserPath(elements.pdfStylePath.value);
|
|
293
|
+
state.pdfOptionsPath = normalizeUserPath(elements.pdfOptionsPath.value);
|
|
294
|
+
elements.pdfStylePath.value = state.pdfStylePath;
|
|
295
|
+
elements.pdfOptionsPath.value = state.pdfOptionsPath;
|
|
296
|
+
localStorage.setItem(STORAGE_KEYS.PDF_STYLE_PATH, state.pdfStylePath);
|
|
297
|
+
localStorage.setItem(STORAGE_KEYS.PDF_OPTIONS_PATH, state.pdfOptionsPath);
|
|
298
|
+
await this.loadPreviewCss();
|
|
299
|
+
TabManager.renderActive();
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
clear() {
|
|
303
|
+
state.pdfStylePath = '';
|
|
304
|
+
state.pdfOptionsPath = '';
|
|
305
|
+
elements.pdfStylePath.value = '';
|
|
306
|
+
elements.pdfOptionsPath.value = '';
|
|
307
|
+
localStorage.removeItem(STORAGE_KEYS.PDF_STYLE_PATH);
|
|
308
|
+
localStorage.removeItem(STORAGE_KEYS.PDF_OPTIONS_PATH);
|
|
309
|
+
const oldStyle = document.getElementById(this.scopedCssId);
|
|
310
|
+
if (oldStyle) oldStyle.remove();
|
|
311
|
+
TabManager.renderActive();
|
|
312
|
+
elements.statusText.textContent = 'PDF style cleared';
|
|
313
|
+
setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
async loadPreviewCss() {
|
|
317
|
+
const oldStyle = document.getElementById(this.scopedCssId);
|
|
318
|
+
if (oldStyle) oldStyle.remove();
|
|
319
|
+
if (!state.pdfStylePath) return;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const response = await fetch(`/raw/${state.pdfStylePath}`);
|
|
323
|
+
if (!response.ok) {
|
|
324
|
+
if (response.status === 404) {
|
|
325
|
+
throw new Error(`CSS not found: ${state.pdfStylePath}`);
|
|
326
|
+
}
|
|
327
|
+
throw new Error(`CSS load error (HTTP ${response.status}): ${state.pdfStylePath}`);
|
|
328
|
+
}
|
|
329
|
+
const cssText = await response.text();
|
|
330
|
+
const style = document.createElement('style');
|
|
331
|
+
style.id = this.scopedCssId;
|
|
332
|
+
style.textContent = this.scopeCss(cssText);
|
|
333
|
+
document.head.appendChild(style);
|
|
334
|
+
elements.statusText.textContent = 'PDF style applied';
|
|
335
|
+
setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.error('PDF style preview error:', error);
|
|
338
|
+
// エラー詳細を status に出す (Claude Code 連携時の自己解決を助ける)
|
|
339
|
+
const detail = (error.message || 'unknown error').slice(0, 100);
|
|
340
|
+
elements.statusText.textContent = `Style failed: ${detail}`;
|
|
341
|
+
setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 4500);
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
scopeCss(cssText) {
|
|
346
|
+
const scope = '.markdown-body.pdf-style-preview';
|
|
347
|
+
const withoutComments = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
348
|
+
return withoutComments.replace(/([^{}]+)\{/g, (match, selectorText) => {
|
|
349
|
+
const selectors = selectorText.trim();
|
|
350
|
+
if (!selectors || selectors.startsWith('@')) return match;
|
|
351
|
+
const scopedSelectors = selectors.split(',').map((selector) => {
|
|
352
|
+
const trimmed = selector.trim();
|
|
353
|
+
if (trimmed === ':root' || trimmed === 'body') return scope;
|
|
354
|
+
if (trimmed.startsWith(scope)) return trimmed;
|
|
355
|
+
return `${scope} ${trimmed}`;
|
|
356
|
+
});
|
|
357
|
+
return `${scopedSelectors.join(', ')} {`;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
238
362
|
// ============================================================
|
|
239
363
|
// Sidebar Management
|
|
240
364
|
// ============================================================
|
|
@@ -771,7 +895,9 @@
|
|
|
771
895
|
render(htmlContent, fileType) {
|
|
772
896
|
const containerClass = fileType === 'code'
|
|
773
897
|
? 'markdown-body code-view-container'
|
|
774
|
-
: 'markdown
|
|
898
|
+
: fileType === 'markdown'
|
|
899
|
+
? 'markdown-body pdf-style-preview'
|
|
900
|
+
: 'markdown-body';
|
|
775
901
|
elements.content.innerHTML = `<div class="${containerClass}">${htmlContent}</div>`;
|
|
776
902
|
|
|
777
903
|
elements.content.querySelectorAll('pre code').forEach(block => {
|
|
@@ -1598,6 +1724,11 @@
|
|
|
1598
1724
|
await this.exportPdf(tab.path);
|
|
1599
1725
|
} else if (this.isHtmlPreview()) {
|
|
1600
1726
|
this.printHtmlPreview(tab.name);
|
|
1727
|
+
} else if (tab.fileType === 'markdown' && PdfStyleManager.shouldUseServerPdf()) {
|
|
1728
|
+
// PDF options JSON が指定されている時のみサーバー md-to-pdf。
|
|
1729
|
+
// CSS だけ / 何もなしの場合は printDialog で OS にページ制御
|
|
1730
|
+
// を委ね、preview の CSS injection が styled PDF を作る。
|
|
1731
|
+
await this.exportPdf(tab.path);
|
|
1601
1732
|
} else {
|
|
1602
1733
|
this.browserPrint(tab.name);
|
|
1603
1734
|
}
|
|
@@ -1625,8 +1756,9 @@
|
|
|
1625
1756
|
|
|
1626
1757
|
try {
|
|
1627
1758
|
statusText.textContent = 'Generating PDF...';
|
|
1759
|
+
const exportOptions = PdfStyleManager.getExportOptions();
|
|
1628
1760
|
|
|
1629
|
-
const response = await MDVApi.exportPdf({ filePath });
|
|
1761
|
+
const response = await MDVApi.exportPdf({ filePath, ...exportOptions });
|
|
1630
1762
|
|
|
1631
1763
|
if (!response.ok) {
|
|
1632
1764
|
const error = await response.json();
|
|
@@ -1650,10 +1782,13 @@
|
|
|
1650
1782
|
}, 2000);
|
|
1651
1783
|
} catch (error) {
|
|
1652
1784
|
console.error('PDF export error:', error);
|
|
1653
|
-
|
|
1785
|
+
// サーバーが返したエラーメッセージを status に表示
|
|
1786
|
+
// (Claude Code 連携などで「何が悪いか分からない」を防ぐ)
|
|
1787
|
+
const detail = (error.message || 'unknown error').slice(0, 100);
|
|
1788
|
+
statusText.textContent = `PDF export failed: ${detail}`;
|
|
1654
1789
|
setTimeout(() => {
|
|
1655
1790
|
statusText.textContent = originalStatus;
|
|
1656
|
-
},
|
|
1791
|
+
}, 4500);
|
|
1657
1792
|
}
|
|
1658
1793
|
},
|
|
1659
1794
|
|
|
@@ -2252,6 +2387,7 @@
|
|
|
2252
2387
|
async function init() {
|
|
2253
2388
|
// Initialize all managers
|
|
2254
2389
|
ThemeManager.init();
|
|
2390
|
+
PdfStyleManager.init();
|
|
2255
2391
|
SidebarManager.init();
|
|
2256
2392
|
ResizeHandler.init();
|
|
2257
2393
|
EditorManager.init();
|
package/src/static/index.html
CHANGED
|
@@ -95,6 +95,13 @@
|
|
|
95
95
|
PDF
|
|
96
96
|
</button>
|
|
97
97
|
|
|
98
|
+
<button class="toolbar-btn" id="pdfStyleToggle" title="PDF style settings" aria-label="PDF style settings">
|
|
99
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
100
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
|
101
|
+
</svg>
|
|
102
|
+
Style
|
|
103
|
+
</button>
|
|
104
|
+
|
|
98
105
|
<div class="toolbar-spacer"></div>
|
|
99
106
|
|
|
100
107
|
<span class="editor-status" id="editorStatus" style="display: none;">Ready</span>
|
|
@@ -110,6 +117,19 @@
|
|
|
110
117
|
</div>
|
|
111
118
|
</div>
|
|
112
119
|
|
|
120
|
+
<div class="pdf-style-panel hidden" id="pdfStylePanel">
|
|
121
|
+
<label>
|
|
122
|
+
<span>CSS</span>
|
|
123
|
+
<input type="text" id="pdfStylePath" placeholder="report.css (rootDir からの相対パス)" title="rootDir 配下の .css ファイルパス。例: report.css または subdir/style.css">
|
|
124
|
+
</label>
|
|
125
|
+
<label>
|
|
126
|
+
<span>PDF options</span>
|
|
127
|
+
<input type="text" id="pdfOptionsPath" placeholder="pdf-options.json (省略可。空なら印刷ダイアログ)" title="rootDir 配下の .json ファイルパス。指定すると md-to-pdf が margin/format を適用してサーバー PDF 自動 DL。空なら印刷ダイアログ経路。">
|
|
128
|
+
</label>
|
|
129
|
+
<button class="toolbar-btn" id="pdfStyleApply">Apply</button>
|
|
130
|
+
<button class="toolbar-btn" id="pdfStyleClear">Clear</button>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
113
133
|
<!-- Tab bar -->
|
|
114
134
|
<div class="tab-bar" id="tabBar" role="tablist" aria-label="Open files"></div>
|
|
115
135
|
|
package/src/static/styles.css
CHANGED
|
@@ -241,6 +241,40 @@ body {
|
|
|
241
241
|
|
|
242
242
|
.toolbar-spacer { flex: 1; }
|
|
243
243
|
|
|
244
|
+
.pdf-style-panel {
|
|
245
|
+
display: flex;
|
|
246
|
+
align-items: center;
|
|
247
|
+
gap: 10px;
|
|
248
|
+
padding: 8px 16px;
|
|
249
|
+
border-bottom: 1px solid var(--border);
|
|
250
|
+
background: var(--bg-secondary);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.pdf-style-panel label {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: center;
|
|
256
|
+
gap: 6px;
|
|
257
|
+
min-width: 0;
|
|
258
|
+
color: var(--text-secondary);
|
|
259
|
+
font-size: 12px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.pdf-style-panel input {
|
|
263
|
+
width: min(34vw, 360px);
|
|
264
|
+
min-width: 160px;
|
|
265
|
+
padding: 6px 8px;
|
|
266
|
+
background: var(--bg-primary);
|
|
267
|
+
border: 1px solid var(--border);
|
|
268
|
+
border-radius: 6px;
|
|
269
|
+
color: var(--text-primary);
|
|
270
|
+
font-size: 12px;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.pdf-style-panel input:focus {
|
|
274
|
+
border-color: var(--accent);
|
|
275
|
+
outline: none;
|
|
276
|
+
}
|
|
277
|
+
|
|
244
278
|
.status {
|
|
245
279
|
display: flex;
|
|
246
280
|
align-items: center;
|
|
@@ -774,7 +808,7 @@ body {
|
|
|
774
808
|
padding: 0 !important;
|
|
775
809
|
}
|
|
776
810
|
|
|
777
|
-
.sidebar, .resize-handle, .toolbar, .tab-bar { display: none !important; }
|
|
811
|
+
.sidebar, .resize-handle, .toolbar, .tab-bar, .pdf-style-panel { display: none !important; }
|
|
778
812
|
|
|
779
813
|
body {
|
|
780
814
|
height: auto !important;
|