234 lines
7.2 KiB
JavaScript
234 lines
7.2 KiB
JavaScript
/**
|
||
* 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, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
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 };
|