GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。 NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは 別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
140 lines
5.0 KiB
JavaScript
140 lines
5.0 KiB
JavaScript
/**
|
||
* md-to-docx.js
|
||
* ------------------------------------------------------------
|
||
* Markdownファイル(見出し/表/太字/リンク/箇条書き/水平線程度の書式)を
|
||
* 同名の .docx として書き出します。Markdown→HTML変換はmd-to-pdf.jsの
|
||
* markdownToHtml()を再利用(外部npmパッケージ非依存)。
|
||
* docx化にはOS標準のMicrosoft Word(COM自動化)を使用します。
|
||
* ClaudePleasanter直下に置く共通実装。全プロジェクトの薄いラッパーから require される。
|
||
* 入出力パスは引数で受け取るのみのためbaseDir/layoutに依存しない。
|
||
*
|
||
* 使い方:
|
||
* node md-to-docx.js ./docs/site-475384_overview.md
|
||
* node md-to-docx.js ./docs/*.md (シェルのグロブ展開に依存。複数ファイル可)
|
||
*
|
||
* 出力:
|
||
* 入力と同じフォルダに {同名}.docx を生成(中間HTML・PowerShellスクリプトは生成後に削除)
|
||
* ------------------------------------------------------------
|
||
*/
|
||
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const os = require("os");
|
||
const { execFileSync } = require("child_process");
|
||
const { markdownToHtml } = require("./md-to-pdf.js");
|
||
|
||
const CSS = `
|
||
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; }
|
||
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 escapeHtml(s) {
|
||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
|
||
// PowerShellのシングルクォート文字列リテラル用エスケープ('を''にする)
|
||
function psSingleQuote(s) {
|
||
return `'${String(s).replace(/'/g, "''")}'`;
|
||
}
|
||
|
||
function convertOne(mdPath) {
|
||
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 tmpId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
const tmpHtmlPath = path.join(os.tmpdir(), `md-to-docx-${tmpId}.html`);
|
||
const tmpPs1Path = path.join(os.tmpdir(), `md-to-docx-${tmpId}.ps1`);
|
||
// SaveAs2はパスに # を含むと内部でURLのフラグメント区切りと誤認識し失敗するため、
|
||
// 一旦 # を含まないTempフォルダへ保存してから最終的な保存先へ移動する。
|
||
const tmpDocxPath = path.join(os.tmpdir(), `md-to-docx-${tmpId}.docx`);
|
||
const docxPath = srcPath.replace(/\.md$/i, ".docx");
|
||
|
||
fs.writeFileSync(tmpHtmlPath, htmlDoc, "utf-8");
|
||
|
||
// wdFormatXMLDocument = 12 (.docx)
|
||
const ps1 = `
|
||
$ErrorActionPreference = "Stop"
|
||
$word = New-Object -ComObject Word.Application
|
||
$word.Visible = $false
|
||
try {
|
||
$doc = $word.Documents.Open(${psSingleQuote(tmpHtmlPath)})
|
||
$doc.SaveAs2(${psSingleQuote(tmpDocxPath)}, 12)
|
||
$doc.Close()
|
||
} finally {
|
||
$word.Quit()
|
||
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($word) | Out-Null
|
||
}
|
||
`;
|
||
fs.writeFileSync(tmpPs1Path, ps1, "utf-8");
|
||
|
||
try {
|
||
execFileSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmpPs1Path], {
|
||
stdio: "pipe",
|
||
});
|
||
fs.copyFileSync(tmpDocxPath, docxPath);
|
||
console.log(`[OK] docx生成: ${docxPath}`);
|
||
return true;
|
||
} catch (err) {
|
||
console.error(`[エラー] docx生成に失敗しました: ${srcPath}`);
|
||
console.error(err.stderr ? err.stderr.toString() : err.message);
|
||
return false;
|
||
} finally {
|
||
fs.unlinkSync(tmpHtmlPath);
|
||
fs.unlinkSync(tmpPs1Path);
|
||
if (fs.existsSync(tmpDocxPath)) fs.unlinkSync(tmpDocxPath);
|
||
}
|
||
}
|
||
|
||
if (require.main === module) {
|
||
const targets = process.argv.slice(2);
|
||
if (targets.length === 0) {
|
||
console.error("[エラー] 変換対象のMarkdownファイルを引数で指定してください。");
|
||
console.error("例: node md-to-docx.js ./docs/site-475384_overview.md");
|
||
process.exit(1);
|
||
}
|
||
|
||
let ok = true;
|
||
for (const target of targets) {
|
||
ok = convertOne(target) && ok;
|
||
}
|
||
if (!ok) process.exit(1);
|
||
}
|
||
|
||
module.exports = { convertOne };
|