mdv-live 0.5.11 → 0.5.13

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,56 @@ 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.13] - 2026-05-09
9
+
10
+ ### Restored
11
+
12
+ - **PDF Style customization (Watanabe @watanko `933147f` の機能)**:
13
+ 0.5.10 で僕が誤って "orphan" と判断し削除してしまった機能を復元。
14
+ README にも記載されている公開機能を黙って消したのは判断ミス。
15
+
16
+ - Web UI: `Style` ボタン + パネル (CSS path / PDF options JSON 入力)
17
+ - サーバー: `/api/pdf/export` の Markdown 経路 (md-to-pdf 経由) を復活
18
+ - `md-to-pdf` を `dependencies` に再追加
19
+
20
+ ### Added — A2 dispatch
21
+
22
+ Markdown PDF ボタンの動きを **Style 設定の有無** で切り替える:
23
+ - **Style 未設定** (デフォルト) → 印刷ダイアログ (`window.print()`) ← 0.5.10 の岡本意図
24
+ - **Style 設定済** (CSS or PDF options 入力 + Apply) → サーバー md-to-pdf
25
+ で styled PDF DL ← 渡邉さん設計
26
+
27
+ 実装: `PdfStyleManager.hasStyle()` を新設し、`PrintManager.print()` の
28
+ Markdown 分岐で分岐。Marp は引き続きサーバー一択。
29
+
30
+ ### Fixed
31
+
32
+ - `src/api/pdf.js` の md-to-pdf bin 解決を hoist 安全 + lazy 化:
33
+ `node_modules/.bin/md-to-pdf` 直叩きをやめ、`require.resolve('md-to-pdf/package.json')`
34
+ で解決。Marp 側と同じ `resolvePkgBin()` ヘルパに統一。
35
+ 欠如時は `code: 'PDF_TOOL_UNAVAILABLE'` で 503 (Marp と同じパス)
36
+
37
+ ### Tests
38
+
39
+ - 244 件全 PASS (plain markdown PDF テスト復活、415 テスト削除)
40
+
41
+ ## [0.5.12] - 2026-05-09
42
+
43
+ ### Fixed
44
+
45
+ - **Server fails to boot when `@marp-team/marp-cli` is missing** (codex P1):
46
+ - 0.5.11 で `require.resolve('@marp-team/marp-cli/package.json')` を
47
+ `src/api/pdf.js` の **module top-level** で実行していたため、optionalDependency
48
+ が欠ける環境 (`npm install --omit=optional`、platform 起因の install 失敗等)
49
+ で `import` 時に throw → サーバー全体が起動不能
50
+ - 解決を `runMarp()` 呼出時の lazy 実行に変更 (`resolveMarpEntry()`)
51
+ - 解決失敗時は `code: 'MARP_CLI_UNAVAILABLE'` を投げ、route handler 側で
52
+ 503 (+ 案内メッセージ) に変換。markdown / 415 経路は marp 不在でも動く
53
+
54
+ ### Tests
55
+
56
+ - 243 → **244 件 (+1)**: `src/api/pdf.js` の import が throw しない regression test
57
+
8
58
  ## [0.5.11] - 2026-05-09
9
59
 
10
60
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
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,35 +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);
10
- // marp-cli npm hoisting によりインストール先が変わる (top-level / nested)
11
- // require.resolve でパッケージの実体を特定し、その bin スクリプトを node で
12
- // 直接実行する。`node_modules/.bin/marp` 直叩きは fresh install で nested
13
- // パスが無い時に ENOENT する罠。
14
- const marpEntry = (() => {
15
- const pkgPath = require.resolve('@marp-team/marp-cli/package.json');
16
- const pkg = require('@marp-team/marp-cli/package.json');
17
- const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.marp;
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];
18
40
  if (!binRel) {
19
- throw new Error('@marp-team/marp-cli does not declare a "marp" bin entry');
41
+ const e = new Error(`${pkgName} does not declare a "${binName}" bin entry.`);
42
+ e.code = 'PDF_TOOL_UNAVAILABLE';
43
+ throw e;
20
44
  }
21
45
  return path.join(path.dirname(pkgPath), binRel);
22
- })();
23
- const PDF_EXPORT_TIMEOUT_MS = 180000;
46
+ }
24
47
 
25
48
  /**
26
- * Spawn marp-cli with stdin closed.
49
+ * Spawn a PDF tool with stdin closed.
27
50
  *
28
- * 注意: execFile は stdio オプションを受け付けない (Node 仕様) ため spawn を使う。
51
+ * 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf
52
+ * 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
53
+ * ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
29
54
  */
30
- function runMarp(args) {
55
+ function runPdfTool(bin, args, { cwd } = {}) {
31
56
  return new Promise((resolve, reject) => {
32
- const child = spawn(process.execPath, [marpEntry, ...args], {
57
+ const child = spawn(process.execPath, [bin, ...args], {
58
+ cwd,
33
59
  stdio: ['ignore', 'pipe', 'pipe'],
34
60
  });
61
+ let stdout = '';
35
62
  let stderr = '';
63
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
36
64
  child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
37
65
 
38
66
  const timer = setTimeout(() => {
@@ -46,11 +74,12 @@ function runMarp(args) {
46
74
  child.on('close', (code, signal) => {
47
75
  clearTimeout(timer);
48
76
  if (code === 0) {
49
- resolve();
77
+ resolve({ stdout, stderr });
50
78
  } else {
51
- 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}`);
52
80
  err.code = code;
53
81
  err.signal = signal;
82
+ err.stdout = stdout;
54
83
  err.stderr = stderr;
55
84
  reject(err);
56
85
  }
@@ -58,12 +87,83 @@ function runMarp(args) {
58
87
  });
59
88
  }
60
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
+
61
162
  /**
62
163
  * Setup PDF export routes.
63
164
  *
64
- * Web UI からは Marp ファイルのみがこの経路を使う。通常 Markdown
65
- * クライアント側で window.print() (OS 印刷ダイアログ) に流す設計のため、
66
- * このエンドポイントは Marp 以外を 415 で拒否する。
165
+ * - Marp files `marp-cli` (slide PDF, landscape, themed)
166
+ * - Plain Markdown → `md-to-pdf` (document PDF, optional CSS / PDF options)
67
167
  *
68
168
  * @param {Express} app - Express application
69
169
  * @returns {void}
@@ -72,7 +172,7 @@ export function setupPdfRoutes(app) {
72
172
  const { rootDir } = app.locals;
73
173
 
74
174
  app.post('/api/pdf/export', async (req, res) => {
75
- const { filePath } = req.body;
175
+ const { filePath, stylePath, pdfOptionsPath } = req.body;
76
176
 
77
177
  if (!filePath) {
78
178
  return res.status(400).json({ error: 'filePath is required' });
@@ -81,6 +181,10 @@ export function setupPdfRoutes(app) {
81
181
  if (!validatePath(filePath, rootDir)) {
82
182
  return res.status(403).json({ error: 'Access denied' });
83
183
  }
184
+ // realpath check: symlink で root 外を指す source を拒否
185
+ if (!await validatePathReal(filePath, rootDir)) {
186
+ return res.status(403).json({ error: 'Access denied' });
187
+ }
84
188
 
85
189
  const fullPath = path.join(rootDir, filePath);
86
190
  const baseName = path.basename(fullPath, '.md');
@@ -99,12 +203,16 @@ export function setupPdfRoutes(app) {
99
203
  }
100
204
 
101
205
  const content = await fs.readFile(fullPath, 'utf-8');
102
- if (!isMarp(content)) {
103
- 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);
104
214
  }
105
215
 
106
- await runMarp([fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
107
-
108
216
  res.download(outputPath, outputFileName, async (err) => {
109
217
  if (err) {
110
218
  console.error('Download error:', err);
@@ -114,6 +222,11 @@ export function setupPdfRoutes(app) {
114
222
  } catch (err) {
115
223
  console.error('PDF export error:', err);
116
224
  try { await fs.unlink(outputPath); } catch { /* ignore */ }
225
+ if (err.code === 'PDF_TOOL_UNAVAILABLE') {
226
+ return res.status(503).json({
227
+ error: `PDF tool unavailable: ${err.message} Run \`npm install\` (without --omit=optional) and retry.`,
228
+ });
229
+ }
117
230
  res.status(500).json({ error: 'PDF export failed' });
118
231
  }
119
232
  });
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,106 @@
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
+ // Style 設定があるか (PDF dispatch を server vs print dialog で切り替えるため)
277
+ hasStyle() {
278
+ return !!(normalizeUserPath(state.pdfStylePath) || normalizeUserPath(state.pdfOptionsPath));
279
+ },
280
+
281
+ getExportOptions() {
282
+ return {
283
+ stylePath: normalizeUserPath(state.pdfStylePath),
284
+ pdfOptionsPath: normalizeUserPath(state.pdfOptionsPath)
285
+ };
286
+ },
287
+
288
+ async applyFromInputs() {
289
+ state.pdfStylePath = normalizeUserPath(elements.pdfStylePath.value);
290
+ state.pdfOptionsPath = normalizeUserPath(elements.pdfOptionsPath.value);
291
+ elements.pdfStylePath.value = state.pdfStylePath;
292
+ elements.pdfOptionsPath.value = state.pdfOptionsPath;
293
+ localStorage.setItem(STORAGE_KEYS.PDF_STYLE_PATH, state.pdfStylePath);
294
+ localStorage.setItem(STORAGE_KEYS.PDF_OPTIONS_PATH, state.pdfOptionsPath);
295
+ await this.loadPreviewCss();
296
+ TabManager.renderActive();
297
+ },
298
+
299
+ clear() {
300
+ state.pdfStylePath = '';
301
+ state.pdfOptionsPath = '';
302
+ elements.pdfStylePath.value = '';
303
+ elements.pdfOptionsPath.value = '';
304
+ localStorage.removeItem(STORAGE_KEYS.PDF_STYLE_PATH);
305
+ localStorage.removeItem(STORAGE_KEYS.PDF_OPTIONS_PATH);
306
+ const oldStyle = document.getElementById(this.scopedCssId);
307
+ if (oldStyle) oldStyle.remove();
308
+ TabManager.renderActive();
309
+ elements.statusText.textContent = 'PDF style cleared';
310
+ setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
311
+ },
312
+
313
+ async loadPreviewCss() {
314
+ const oldStyle = document.getElementById(this.scopedCssId);
315
+ if (oldStyle) oldStyle.remove();
316
+ if (!state.pdfStylePath) return;
317
+
318
+ try {
319
+ const response = await fetch(`/raw/${state.pdfStylePath}`);
320
+ if (!response.ok) throw new Error('CSS file not found');
321
+ const cssText = await response.text();
322
+ const style = document.createElement('style');
323
+ style.id = this.scopedCssId;
324
+ style.textContent = this.scopeCss(cssText);
325
+ document.head.appendChild(style);
326
+ elements.statusText.textContent = 'PDF style applied';
327
+ setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
328
+ } catch (error) {
329
+ console.error('PDF style preview error:', error);
330
+ elements.statusText.textContent = 'PDF style failed';
331
+ setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 2500);
332
+ }
333
+ },
334
+
335
+ scopeCss(cssText) {
336
+ const scope = '.markdown-body.pdf-style-preview';
337
+ const withoutComments = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
338
+ return withoutComments.replace(/([^{}]+)\{/g, (match, selectorText) => {
339
+ const selectors = selectorText.trim();
340
+ if (!selectors || selectors.startsWith('@')) return match;
341
+ const scopedSelectors = selectors.split(',').map((selector) => {
342
+ const trimmed = selector.trim();
343
+ if (trimmed === ':root' || trimmed === 'body') return scope;
344
+ if (trimmed.startsWith(scope)) return trimmed;
345
+ return `${scope} ${trimmed}`;
346
+ });
347
+ return `${scopedSelectors.join(', ')} {`;
348
+ });
349
+ }
350
+ };
351
+
238
352
  // ============================================================
239
353
  // Sidebar Management
240
354
  // ============================================================
@@ -771,7 +885,9 @@
771
885
  render(htmlContent, fileType) {
772
886
  const containerClass = fileType === 'code'
773
887
  ? 'markdown-body code-view-container'
774
- : 'markdown-body';
888
+ : fileType === 'markdown'
889
+ ? 'markdown-body pdf-style-preview'
890
+ : 'markdown-body';
775
891
  elements.content.innerHTML = `<div class="${containerClass}">${htmlContent}</div>`;
776
892
 
777
893
  elements.content.querySelectorAll('pre code').forEach(block => {
@@ -1598,6 +1714,11 @@
1598
1714
  await this.exportPdf(tab.path);
1599
1715
  } else if (this.isHtmlPreview()) {
1600
1716
  this.printHtmlPreview(tab.name);
1717
+ } else if (tab.fileType === 'markdown' && PdfStyleManager.hasStyle()) {
1718
+ // Style パネルで CSS / PDF options が設定されている場合のみ
1719
+ // サーバー側 md-to-pdf で styled PDF を生成 (Watanabe 設計)。
1720
+ // 設定がなければデフォルトの印刷ダイアログ経路に落とす。
1721
+ await this.exportPdf(tab.path);
1601
1722
  } else {
1602
1723
  this.browserPrint(tab.name);
1603
1724
  }
@@ -1625,8 +1746,9 @@
1625
1746
 
1626
1747
  try {
1627
1748
  statusText.textContent = 'Generating PDF...';
1749
+ const exportOptions = PdfStyleManager.getExportOptions();
1628
1750
 
1629
- const response = await MDVApi.exportPdf({ filePath });
1751
+ const response = await MDVApi.exportPdf({ filePath, ...exportOptions });
1630
1752
 
1631
1753
  if (!response.ok) {
1632
1754
  const error = await response.json();
@@ -2252,6 +2374,7 @@
2252
2374
  async function init() {
2253
2375
  // Initialize all managers
2254
2376
  ThemeManager.init();
2377
+ PdfStyleManager.init();
2255
2378
  SidebarManager.init();
2256
2379
  ResizeHandler.init();
2257
2380
  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="src/styles/report.example.css">
124
+ </label>
125
+ <label>
126
+ <span>PDF options</span>
127
+ <input type="text" id="pdfOptionsPath" placeholder="src/styles/report.pdf-options.example.json">
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;