import fs from 'fs'
import os from 'os'
import path from 'path'
import get from 'lodash-es/get.js'
import isestr from 'wsemi/src/isestr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import WMd2html from 'w-md2html/src/WMd2html.mjs'
import WHtml2docx from 'w-html2docx/src/WHtml2docx.mjs'
import addDocxToc from './addDocxToc.mjs'
import { prepDocxKeep } from './docxKeep.mjs'
import { toErrText, retryBusy, runExclusive } from './utils.mjs'
/**
* Markdown檔轉Docx檔
*
* 內部流程為md -> html -> docx。docx階段係調用本機Microsoft Word(win32com),故僅能於已安裝Word之Windows執行,且同時間僅一份轉檔作業(內部佇列自動排隊)。
*
* 轉檔器htmlToDocx.exe之定位與補齊由w-html2docx負責:其以當前工作目錄(cwd)查找,缺檔(如npm封鎖安裝腳本致postinstall未執行)時於轉檔當下自動下載,故須於含node_modules/w-html2docx之專案根執行,且首次轉檔須可連網。
*
* w-html2docx(≥1.0.29)於轉檔器失敗(Word未安裝、模板無法開啟、另存失敗等)時以離開碼1 reject並帶轉檔器回報之原因,resolve前亦自行確認docx已產生;本函數將其訊息加上前綴原樣傳出,並再以產物實體檔之存在與大小把關一次,不會回報假成功。
*
* @param {String} fpInMd 輸入來源Markdown檔位置字串
* @param {String} fpOutDocx 輸入轉出Docx檔位置字串
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.fpInTemp=''] 輸入Docx模板檔位置字串,未給則由w-html2docx使用其內建模板,預設''
* @param {String} [opt.fpOutHtml=''] 輸入另存中介Html檔位置字串,未給則中介檔產於系統暫存夾並於轉檔後刪除,預設''
* @param {Object} [opt.optMd2html={}] 輸入傳予w-md2html之設定物件(如imgWidthMax、fontSizeScale等),預設{}
* @param {Object} [opt.optHtml2docx={}] 輸入傳予w-html2docx之設定物件(如imgRatioWidthMax、fontFamilies等),其imgHeightReserveLines未給時預設4:行格線模板之圖片下方預留4格,供圖區塊間之空段落與至多2行之圖名使用,使整頁高之圖片與其圖名可同頁,無行格線之模板不生效,給null停用(見w-html2docx),預設{}
* @param {Boolean|Object} [opt.keepCaption=false] 輸入是否使圖名、表名與其圖、表同頁(不依附目錄):轉檔後圖名表名設段落內不分頁,並與其下之表格或圖片、或其上之圖片設為同頁(可越過空段落;上下皆有圖表時依同類圖名表名之多數位置,平手時圖名取上方、表名取下方,見docxKeep.mjs之planKeepNext),於添加目錄之前執行;true用預設,物件可給{labels}(見docxKeep.mjs之prepDocxKeep,與toc之labels各自獨立);處理失敗時刪除本次產出之docx並reject,預設false
* @param {Boolean|Object} [opt.toc=false] 輸入是否添加章節目錄、圖目錄、表目錄並重編頁碼(封面無頁碼、目錄為大寫羅馬數字、正文自1起),true用預設,物件為addDocxToc之設定(見addDocxToc.mjs);添加失敗時刪除本次產出之docx並reject,預設false
* @returns {Promise} 回傳Promise,resolve回傳結果物件{fpOutDocx,sizeDocx,sizeHtml,ms,[fpOutHtml],[keepCaption],[toc]},keepCaption為{figs,tabs,keepNext,keepLines,changed,warns,ms}(圖名數、表名數、新增與下段同頁及段落內不分頁之段落數、是否改寫、提示、耗時),toc為addDocxToc之結果(文件無標題時僅含skip與ms),reject回傳錯誤訊息
* @example
*
* import cvMdToDocx from 'w-md2docx/src/cvMdToDocx.mjs'
*
* let r = await cvMdToDocx('./test/report.md', './test/report.docx', {
* fpInTemp: './src/templates/temp_tpc.docx',
* optMd2html: {
* imgWidthMax: '500px',
* },
* toc: true,
* })
* console.log(r)
* // => { fpOutDocx: '…', sizeDocx: 41210, sizeHtml: 12345, ms: 12342, toc: { skip: '', cover: true, levels: [2, 4], lists: ['toc'], toc: 26, … } }
*
*/
async function cvMdToDocx(fpInMd, fpOutDocx, opt = {}) {
let msStart = Date.now()
//check fpInMd
if (!isestr(fpInMd)) {
return Promise.reject('fpInMd must be a non-empty string')
}
fpInMd = path.resolve(fpInMd)
if (!fsIsFile(fpInMd)) {
return Promise.reject(`fpInMd[${fpInMd}] does not exist`)
}
//check fpOutDocx
if (!isestr(fpOutDocx)) {
return Promise.reject('fpOutDocx must be a non-empty string')
}
fpOutDocx = path.resolve(fpOutDocx)
//fpOutHtml (未指定則產於系統暫存夾, 轉檔後刪除)
let fpOutHtml = get(opt, 'fpOutHtml', '')
let bKeepHtml = isestr(fpOutHtml)
if (bKeepHtml) {
fpOutHtml = path.resolve(fpOutHtml)
}
else {
//檔名唯一, 避免與被鎖定之同名殘檔衝突造成 EBUSY
fpOutHtml = path.join(os.tmpdir(), `wmd2docx_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.html`)
}
//optMd2html
let optMd2html = get(opt, 'optMd2html', {})
if (!isobj(optMd2html)) {
optMd2html = {}
}
//optHtml2docx (複製一份, 避免改動呼叫端物件)
let optHtml2docx = get(opt, 'optHtml2docx', {})
if (!isobj(optHtml2docx)) {
optHtml2docx = {}
}
optHtml2docx = { ...optHtml2docx }
//imgHeightReserveLines (未給時預設 4; 給 null 停用, 給其他值原樣交 w-html2docx 判斷, 其視無效值為不啟用)
//why: 行格線模板(如 temp_tpc.docx)下 Word 將段落高進位為行距之整數倍, 原圖高上限(版心高之 0.937 倍)進位後擠掉其下之圖名,
// 2026-10-10 Word 16 實測整頁高長圖 4 張與圖名皆不同頁, 預留 4 格後皆同頁; 4 = 圖區塊間之空段落 1 格 + 圖名至多 2 行(12pt 各 1 格) + 餘裕 1 格
// (段前段後距離、頁首頁尾高於邊界時每頁實際格數少於推算), 無行格線之模板不生效
optHtml2docx.imgHeightReserveLines = get(optHtml2docx, 'imgHeightReserveLines', 4)
//fpInTemp (未給則交由 w-html2docx 使用內建模板)
let fpInTemp = get(opt, 'fpInTemp', '')
if (isestr(fpInTemp)) {
fpInTemp = path.resolve(fpInTemp)
if (!fsIsFile(fpInTemp)) {
return Promise.reject(`fpInTemp[${fpInTemp}] does not exist`)
}
optHtml2docx.fpInTemp = fpInTemp
}
try {
//md -> html
//note: w-md2html 係以 md 所在資料夾解析其內引用之相對路徑圖片, 並預設轉為 base64 內嵌,
// 故中介 html 置於他處亦不影響圖片
fs.mkdirSync(path.dirname(fpOutHtml), { recursive: true })
let errHtml = null
await retryBusy(() => WMd2html(fpInMd, fpOutHtml, optMd2html))
.catch((err) => {
errHtml = toErrText(err)
})
if (errHtml !== null) {
return Promise.reject(`Failed to convert md to html: ${errHtml}`)
}
//驗證 html(產物實體檔為準, 不憑回傳值)
if (!fsIsFile(fpOutHtml) || fs.statSync(fpOutHtml).size === 0) {
return Promise.reject('Failed to convert md to html: html was not generated or is empty')
}
let sizeHtml = fs.statSync(fpOutHtml).size
//html -> docx(排隊逐一執行, 避免同時調用本機 Word)
//note: 轉檔器之定位與缺檔下載由 WHtml2docx 內部 autoDownloadFiles 負責, 此處不得事前檢查,
// 否則下層之自動補齊永遠不會被執行
fs.mkdirSync(path.dirname(fpOutDocx), { recursive: true })
let errDocx = null
await runExclusive(() => retryBusy(() => WHtml2docx(fpOutHtml, fpOutDocx, optHtml2docx)))
.catch((err) => {
errDocx = toErrText(err)
})
if (errDocx !== null) {
//w-html2docx 以 cwd 查找 htmlToDocx.exe 與預設模板, 查找失敗之訊息含 can not find, 保留原文並補上 cwd 與執行位置提示
if (errDocx.indexOf('can not find') >= 0) {
errDocx = `${errDocx} (cwd=${path.resolve()}, w-html2docx resolves htmlToDocx.exe and its default template from cwd and downloads htmlToDocx.exe automatically when absent, please run from the project root that contains node_modules/w-html2docx)`
}
return Promise.reject(`Failed to convert html to docx: ${errDocx}`)
}
//驗證 docx(第二道防線)
//note: w-html2docx ≥1.0.29 於轉檔器失敗時已自行 reject(離開碼1, 帶轉檔器原因), resolve 前亦確認 docx 已產生且非空,
// 此處再以產物實體檔為準把關一次, 涵蓋其 resolve 後檔案又消失等非預期情形, 不憑回傳值
if (!fsIsFile(fpOutDocx) || fs.statSync(fpOutDocx).size === 0) {
return Promise.reject('docx was not generated: please make sure Microsoft Word is installed and no stale WINWORD process is locking the files')
}
let sizeDocx = fs.statSync(fpOutDocx).size
//圖名表名與其圖表同頁(不依附目錄)
//note: 於添加目錄之前獨立執行, 使目錄略過(文件無標題)或目錄之 keepWithObject 為 false 時仍套用; 兩者同開時目錄之同一處理遇已有之設定即原樣回傳
let keepCaption = get(opt, 'keepCaption', false)
let rKeep = null
if (keepCaption === true || isobj(keepCaption)) {
let msKeep = Date.now()
let errKeep = null
try {
let r = prepDocxKeep(new Uint8Array(fs.readFileSync(fpOutDocx)), isobj(keepCaption) ? keepCaption : {})
if (r.info.changed) {
//以暫存檔改名取代, 寫入中斷時不留半份 docx
let fpTmp = `${fpOutDocx}.${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.tmp`
try {
fs.writeFileSync(fpTmp, r.u8)
await retryBusy(() => fs.renameSync(fpTmp, fpOutDocx))
}
finally {
if (fsIsFile(fpTmp)) {
try {
fs.unlinkSync(fpTmp)
}
catch (err) {
//殘檔被鎖, 略過
}
}
}
}
rKeep = { ...r.info, ms: Date.now() - msKeep }
}
catch (err) {
errKeep = toErrText(err)
}
if (errKeep !== null) {
//why: 已產出之 docx 缺所要求之同頁設定, 留著會被以檔案存在判斷成敗之呼叫端當成成功, 故刪除後才 reject
try {
fs.unlinkSync(fpOutDocx)
}
catch (err) {
//被鎖則略過, 仍以 reject 回報失敗
}
return Promise.reject(`Failed to keep the captions with their figures and tables: ${errKeep}`)
}
sizeDocx = fs.statSync(fpOutDocx).size
}
//添加目錄並重編頁碼
//note: 須於轉檔佇列之外呼叫, addDocxToc 內部之 Word 更新另行排隊; 若於佇列內呼叫, 兩者互等而永不完成
let toc = get(opt, 'toc', false)
let rToc = null
if (toc === true || isobj(toc)) {
let errToc = null
rToc = await addDocxToc(fpOutDocx, isobj(toc) ? toc : {})
.catch((err) => {
errToc = toErrText(err)
})
if (errToc !== null) {
//why: 已產出之 docx 缺所要求之目錄, 留著會被以檔案存在判斷成敗之呼叫端當成成功, 故刪除後才 reject
try {
fs.unlinkSync(fpOutDocx)
}
catch (err) {
//被鎖則略過, 仍以 reject 回報失敗
}
return Promise.reject(`Failed to add the TOC: ${errToc}`)
}
sizeDocx = fs.statSync(fpOutDocx).size
}
let rt = {
fpOutDocx,
sizeDocx,
sizeHtml,
ms: Date.now() - msStart,
}
if (bKeepHtml) {
rt.fpOutHtml = fpOutHtml
}
if (rKeep !== null) {
rt.keepCaption = rKeep
}
if (rToc !== null) {
rt.toc = rToc
}
return rt
}
finally {
//刪除中介 html(使用者指定保留者不刪; 被鎖就略過, 留待系統暫存清理)
if (!bKeepHtml && fsIsFile(fpOutHtml)) {
try {
fs.unlinkSync(fpOutHtml)
}
catch (err) {
//殘檔被鎖, 略過
}
}
}
}
export default cvMdToDocx