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 +50 -0
- package/package.json +2 -1
- package/src/api/pdf.js +139 -26
- package/src/static/app.js +127 -4
- package/src/static/index.html +20 -0
- package/src/static/styles.css +34 -0
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.
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
49
|
+
* Spawn a PDF tool with stdin closed.
|
|
27
50
|
*
|
|
28
|
-
* 注意: execFile は stdio オプションを受け付けない (Node 仕様)
|
|
51
|
+
* 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf は
|
|
52
|
+
* 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
|
|
53
|
+
* ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
|
|
29
54
|
*/
|
|
30
|
-
function
|
|
55
|
+
function runPdfTool(bin, args, { cwd } = {}) {
|
|
31
56
|
return new Promise((resolve, reject) => {
|
|
32
|
-
const child = spawn(process.execPath, [
|
|
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(
|
|
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
|
-
*
|
|
65
|
-
*
|
|
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 (
|
|
103
|
-
|
|
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
|
|
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();
|
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="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
|
|
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;
|