/** * 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, ">"); } // 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 = ` ${escapeHtml(title)} ${bodyHtml} `; 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 };