ken_nogi/ClaudePleasanter/新・稟議申請システム/md-to-pdf.js
Kenichiro NOGI 88a402ce0f up
2026-07-10 18:13:30 +09:00

234 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* md-to-pdf.js
* ------------------------------------------------------------
* Markdownファイル見出し/表/太字/リンク/箇条書き/水平線程度の書式)を
* 同名の .pdf として書き出します。外部npmパッケージ非依存プロジェクトに
* package.jsonが無いことに合わせ、Node標準モジュールのみで完結させています
* PDF化にはOS標準のMicrosoft Edge無ければGoogle Chromeのヘッドレス印刷機能を使用します。
*
* 使い方:
* node md-to-pdf.js ./docs/site-475384_overview.md
* node md-to-pdf.js ./docs/*.md (シェルのグロブ展開に依存。複数ファイル可)
*
* 出力:
* 入力と同じフォルダに {同名}.pdf を生成中間HTMLは生成後に削除
*
* 注意:
* フォルダ名に # を含むパス(例: #GitHubは file:// URL上でフラグメント区切りと
* 誤認識されるため、url.pathToFileURL() で必ずエンコードしてからブラウザに渡すこと。
* ------------------------------------------------------------
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const url = require("url");
const { execFileSync } = require("child_process");
const CANDIDATE_BROWSERS = [
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
];
function findBrowser() {
for (const p of CANDIDATE_BROWSERS) {
if (fs.existsSync(p)) return p;
}
console.error("[エラー] Microsoft EdgeまたはGoogle Chromeが見つかりませんでした。");
console.error("インストール済みのブラウザパスをCANDIDATE_BROWSERSに追加してください。");
process.exit(1);
}
// --- 最小限のMarkdown→HTML変換見出し/表/太字/リンク/箇条書き/水平線/段落のみ対応) ---
function escapeHtml(s) {
return s
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function renderInline(text) {
let t = escapeHtml(text);
t = t.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
return t;
}
function isTableSeparator(line) {
return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line) && line.includes("-");
}
function splitTableRow(line) {
let cells = line.trim();
if (cells.startsWith("|")) cells = cells.slice(1);
if (cells.endsWith("|")) cells = cells.slice(0, -1);
return cells.split("|").map((c) => c.trim());
}
function markdownToHtml(mdText) {
const lines = mdText.split(/\r?\n/);
const html = [];
let i = 0;
let inList = false;
function closeList() {
if (inList) {
html.push("</ul>");
inList = false;
}
}
while (i < lines.length) {
const line = lines[i];
if (/^\s*$/.test(line)) {
closeList();
i++;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
closeList();
const level = heading[1].length;
html.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
i++;
continue;
}
if (/^\s*---+\s*$/.test(line)) {
closeList();
html.push("<hr>");
i++;
continue;
}
// テーブル: 次行がセパレータ行であること
if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
closeList();
const headerCells = splitTableRow(line);
html.push("<table><thead><tr>" + headerCells.map((c) => `<th>${renderInline(c)}</th>`).join("") + "</tr></thead><tbody>");
i += 2;
while (i < lines.length && lines[i].includes("|") && !/^\s*$/.test(lines[i])) {
const rowCells = splitTableRow(lines[i]);
html.push("<tr>" + rowCells.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>");
i++;
}
html.push("</tbody></table>");
continue;
}
const listItem = line.match(/^\s*-\s+(.*)$/);
if (listItem) {
if (!inList) {
html.push("<ul>");
inList = true;
}
html.push(`<li>${renderInline(listItem[1])}</li>`);
i++;
continue;
}
closeList();
html.push(`<p>${renderInline(line)}</p>`);
i++;
}
closeList();
return html.join("\n");
}
const CSS = `
@page { size: A4; margin: 18mm 16mm; }
body {
font-family: "Yu Gothic", "Meiryo", "Hiragino Sans", sans-serif;
font-size: 10.5pt;
line-height: 1.7;
color: #1a1a1a;
}
h1 { font-size: 18pt; border-bottom: 3px solid #2a5599; padding-bottom: 6px; margin-top: 0; }
h2 { font-size: 14pt; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-top: 28px; color: #2a5599; }
h3 { font-size: 12pt; margin-top: 20px; }
table { border-collapse: collapse; width: 100%; margin: 10px 0 16px; font-size: 9.5pt; }
th, td { border: 1px solid #999; padding: 5px 8px; text-align: left; vertical-align: top; }
th { background-color: #2a5599; color: #fff; }
tr:nth-child(even) td { background-color: #f4f7fb; }
ul, ol { margin: 6px 0; padding-left: 22px; }
li { margin: 3px 0; }
p { margin: 8px 0; }
code { background: #eee; padding: 1px 4px; border-radius: 3px; }
hr { border: none; border-top: 1px solid #ccc; margin: 20px 0; }
a { color: #2a5599; }
`;
function convertOne(mdPath, browser) {
const srcPath = path.resolve(mdPath);
if (!fs.existsSync(srcPath)) {
console.error(`[エラー] ファイルが見つかりません: ${srcPath}`);
return false;
}
const mdText = fs.readFileSync(srcPath, "utf-8");
const titleMatch = mdText.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1] : path.basename(srcPath);
const bodyHtml = markdownToHtml(mdText);
const htmlDoc = `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>${escapeHtml(title)}</title>
<style>${CSS}</style>
</head>
<body>
${bodyHtml}
</body>
</html>`;
const tmpHtmlPath = path.join(os.tmpdir(), `md-to-pdf-${Date.now()}-${Math.random().toString(36).slice(2)}.html`);
const pdfPath = srcPath.replace(/\.md$/i, ".pdf");
fs.writeFileSync(tmpHtmlPath, htmlDoc, "utf-8");
try {
const fileUrl = url.pathToFileURL(tmpHtmlPath).href;
execFileSync(browser, [
"--headless",
"--disable-gpu",
"--no-sandbox",
`--print-to-pdf=${pdfPath}`,
"--print-to-pdf-no-header",
"--no-pdf-header-footer",
fileUrl,
]);
console.log(`[OK] PDF生成: ${pdfPath}`);
return true;
} catch (err) {
console.error(`[エラー] PDF生成に失敗しました: ${srcPath}`);
console.error(err.message);
return false;
} finally {
fs.unlinkSync(tmpHtmlPath);
}
}
if (require.main === module) {
const targets = process.argv.slice(2);
if (targets.length === 0) {
console.error("[エラー] 変換対象のMarkdownファイルを引数で指定してください。");
console.error("例: node md-to-pdf.js ./docs/site-475384_overview.md");
process.exit(1);
}
const browser = findBrowser();
let ok = true;
for (const target of targets) {
ok = convertOne(target, browser) && ok;
}
if (!ok) process.exit(1);
}
module.exports = { convertOne, markdownToHtml };