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 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 JSON ファイルパス
112
+ - **CSS** — サーバー起動時の `rootDir` からの相対パス (例: `report.css`、`subdir/style.css`)
113
+ - **PDF options** `rootDir` からの相対パス (例: `pdf-options.json`)。省略可
114
114
 
115
- CSS は Markdown プレビューにも反映されます。指定を解除する場合は `Clear` を押してください。
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.12",
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 the marp-cli bin script.
15
+ * Lazily resolve a package's bin script via require.resolve.
14
16
  *
15
- * `@marp-team/marp-cli` optionalDependencies。`npm install --omit=optional`
16
- * platform 起因の install 失敗で欠けることがあるため、サーバー起動時に解決
17
- * すると import 段階で throw → サーバー全体が起動できなくなる。PDF export
18
- * 経路でだけ解決し、欠けていれば export だけ 503 を返す設計に倒す。
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
- * npm hoisting により実体パスは top-level / nested で変わる。`node_modules/
21
- * .bin/marp` 直叩きは fresh install ENOENT に落ちる罠なので、package.json
22
- * bin エントリ経由で解決する。
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 resolveMarpEntry() {
27
+ function resolvePkgBin(pkgName, binName) {
28
28
  let pkgPath;
29
29
  try {
30
- pkgPath = require.resolve('@marp-team/marp-cli/package.json');
30
+ pkgPath = require.resolve(`${pkgName}/package.json`);
31
31
  } catch (err) {
32
- const e = new Error('@marp-team/marp-cli is not installed (optionalDependency missing).');
33
- e.code = 'MARP_CLI_UNAVAILABLE';
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('@marp-team/marp-cli/package.json');
38
- const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.marp;
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('@marp-team/marp-cli does not declare a "marp" bin entry.');
41
- e.code = 'MARP_CLI_UNAVAILABLE';
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 marp-cli with stdin closed.
49
+ * Spawn a PDF tool with stdin closed.
49
50
  *
50
- * 注意: execFile は stdio オプションを受け付けない (Node 仕様) ため spawn を使う。
51
+ * 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf
52
+ * 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
53
+ * ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
51
54
  */
52
- function runMarp(args) {
53
- const marpEntry = resolveMarpEntry();
55
+ function runPdfTool(bin, args, { cwd } = {}) {
54
56
  return new Promise((resolve, reject) => {
55
- const child = spawn(process.execPath, [marpEntry, ...args], {
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(`marp exited with code=${code} signal=${signal}`);
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
- * Web UI からは Marp ファイルのみがこの経路を使う。通常 Markdown
88
- * クライアント側で window.print() (OS 印刷ダイアログ) に流す設計のため、
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 (!isMarp(content)) {
126
- return res.status(415).json({ error: 'Server-side PDF export supports Marp files only. Use the browser print dialog for regular Markdown.' });
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 === 'MARP_CLI_UNAVAILABLE') {
225
+ if (err.code === 'PDF_TOOL_UNAVAILABLE') {
141
226
  return res.status(503).json({
142
- error: 'Marp PDF export is unavailable: install @marp-team/marp-cli or run `npm install` without --omit=optional.',
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-body';
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
- statusText.textContent = 'PDF export failed';
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
- }, 3000);
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();
@@ -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
 
@@ -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;