import fs from 'fs'
import os from 'os'
import path from 'path'
import { fileURLToPath } from 'url'
import get from 'lodash-es/get.js'
import isestr from 'wsemi/src/isestr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import cint from 'wsemi/src/cint.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import execProcess from 'wsemi/src/execProcess.mjs'
import { prepDocxToc, checkDocxToc } from './docxToc.mjs'
import { toErrText, retryBusy, runExclusive } from './utils.mjs'
//fdSelf (場景B: 套件自帶之 updateDocxToc.vbs 隨模組所在資料夾定位, 不依工作目錄)
let fdSelf = path.dirname(fileURLToPath(import.meta.url))
/**
* 取Word更新腳本updateDocxToc.vbs之位置
*
* 依序查找模組所在資料夾、其上層之src資料夾:以src執行時腳本位於同層;以打包後之dist執行時模組位於dist,腳本則隨套件之src發布,故另查../src。
*
* @param {String} [fd] 輸入模組所在資料夾位置字串,未給則為本模組所在資料夾
* @returns {String} 回傳腳本之絕對路徑字串,查無則回傳空字串
* @example
*
* import { getFpVbs } from 'w-md2docx/src/addDocxToc.mjs'
*
* console.log(getFpVbs())
* // => 'D:\\…\\node_modules\\w-md2docx\\src\\updateDocxToc.vbs'
*
*/
function getFpVbs(fd) {
if (!isestr(fd)) {
fd = fdSelf
}
let fps = [
path.resolve(fd, 'updateDocxToc.vbs'),
path.resolve(fd, '..', 'src', 'updateDocxToc.vbs'),
]
for (let fp of fps) {
if (fsIsFile(fp)) {
return fp
}
}
return ''
}
//runWord: 以本機 Word 更新欄位並另存(cscript 執行 updateDocxToc.vbs)
// cscript 以系統字碼頁(中文 Windows 為 cp950)輸出 Word 回報之原因, 故以 codeCmd 'auto' 解碼, 否則中文原因成亂碼
// 逾時時 execProcess 結束 cscript 之程序樹, 惟經 COM 啟動之 WINWORD 不在其中, 可能殘留
async function runWord(fpVbs, fpIn, fpOut, timeout) {
let out = ''
let err = null
await execProcess('cscript', ['//nologo', fpVbs, fpIn, fpOut], { codeCmd: 'auto', timeout })
.then((r) => {
out = String(r).trim()
})
.catch((e) => {
err = toErrText(e).trim()
})
if (err !== null) {
return Promise.reject(`Word failed to update the fields: ${err}`)
}
if (!/(^|\n)ok$/.test(out)) {
return Promise.reject(`Word failed to update the fields: ${out}`)
}
let m = out.match(/pages=(\d+)/)
return { pages: m ? Number(m[1]) : null }
}
/**
* 於docx添加章節目錄、圖目錄、表目錄並重編頁碼
*
* 先以純結構處理準備內容(見docxToc.mjs之prepDocxToc:圖名表名編號改SEQ欄位、標題粗體改由樣式提供、插入目錄欄位、分節與頁碼),再以本機Microsoft Word更新欄位(目錄內容與頁碼只有Word能算),最後逐項核對目錄項目、連結落點、頁碼與分節,全部相符才覆寫原檔。故僅能於已安裝Word之Windows執行,Word之調用與轉檔共用同一佇列。
*
* 頁碼:第1個標題前有內容者視為封面,封面無頁碼;前置章節與目錄為大寫羅馬數字(I起);正文自1起。無封面者不另建封面頁。
*
* 標題以段落之大綱階層判斷,第1個標題即視為正文之始(前置章節除外):Markdown以單一#為文件標題、##為章者,文件標題會列為目錄第1層且正文自其起算;欲置於封面請以非標題之段落撰寫(如<div pretitle>)。
*
* Word更新以cscript執行updateDocxToc.vbs(VBScript):微軟已公告VBScript將轉為選用功能並於日後移除,屆時須另行啟用或改以其他方式調用Word。逾時被終止時,經COM啟動之WINWORD行程可能殘留,須人工確認後結束。
*
* 文件無標題段落時不處理並於回傳之skip說明原因;前提不符或核對不符時reject,原檔不變。
*
* @param {String} fpDocx 輸入docx檔位置字串,處理成功時就地覆寫
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {Object} [opt.labels={fig:'圖',tab:'表'}] 輸入圖名、表名之標籤(段首「圖N」「表N」,編號後須接空白),須為不含空白與引號之非空字串且兩者不同,英文報告可給{fig:'Figure',tab:'Table'}
* @param {Object} [opt.titles={toc:'目錄',fig:'圖目錄',tab:'表目錄'}] 輸入三目錄之標題
* @param {Array} [opt.frontHeadings=['摘要','ABSTRACT','Abstract']] 輸入前置章節之標題文字,文件開頭之標題與之相同者排在封面與目錄之間且不列入目錄
* @param {Integer} [opt.maxLevels=3] 輸入目錄列出之標題層數(自正文最高之大綱階層起算),上限9
* @param {String} [opt.titleStyle='TPC11報告目錄(標題)'] 輸入三目錄標題之段落樣式名稱(預設為本套件所附temp_tpc.docx之樣式),模板無此樣式時另建置中粗體樣式
* @param {Array} [opt.headStyles=['章標題','節標題','小節標題']] 輸入承接標題粗體之段落樣式名稱(依層),模板已有同名樣式時須為粗體
* @param {Boolean} [opt.pageNumbers=true] 輸入是否分節重編頁碼,false時只插入三目錄(各自起新頁),不動分節與頁碼
* @param {Boolean} [opt.keepLines=true] 輸入是否將目錄來源段落(列入目錄之標題、圖名、表名)設為段落內不分頁,使目錄之頁碼即該段落所在之頁(多行之表名跨頁時,首行留在前頁而表格在次頁,表目錄之頁碼會指向前頁)
* @param {Boolean} [opt.keepWithObject=true] 輸入是否使圖名、表名與其圖、表同頁(與keepCaption同一規則,見docxKeep.mjs之planKeepNext):與下段之表格或圖片、或上段之圖片同頁,上下皆有圖表時依同類圖名表名之多數位置,平手時圖名取上段、表名取下段(圖在頁底而圖名被擠到次頁時,圖目錄之頁碼會指向次頁)
* @param {Boolean} [opt.replaceTocStyles=true] 輸入是否以本套件之目錄項目樣式(第1層粗體、下層縮排並懸掛縮排、點線引導頁碼,字型沿用正文第1個標題)取代模板既有之toc N與table of figures樣式,false時沿用模板既有者、僅補建缺少者
* @param {Integer} [opt.timeoutMs=1800000] 輸入Word更新之逾時毫秒數,預設1800000(30分鐘)
* @returns {Promise} 回傳Promise,resolve回傳{skip,cover,levels,front,lists,toc,fig,tab,pagesFirst,pages,warns,ms}(toc、fig、tab為各目錄之項目數,lists為實際插入之目錄,pagesFirst為目錄第1項之頁碼),無標題時僅回傳{skip,ms};reject回傳錯誤訊息字串
* @example
*
* import addDocxToc from 'w-md2docx/src/addDocxToc.mjs'
*
* let r = await addDocxToc('./report.docx')
* console.log(r)
* // => { skip: '', cover: true, levels: [1, 2], front: [], lists: ['toc', 'fig', 'tab'], toc: 58, fig: 246, tab: 55, pagesFirst: '1', pages: 170, warns: [], ms: 12500 }
*
*/
async function addDocxToc(fpDocx, opt = {}) {
let msStart = Date.now()
//check
if (process.platform !== 'win32') {
return Promise.reject('addDocxToc requires Microsoft Word on Windows')
}
if (!isestr(fpDocx)) {
return Promise.reject('fpDocx must be a non-empty string')
}
fpDocx = path.resolve(fpDocx)
if (!fsIsFile(fpDocx)) {
return Promise.reject(`fpDocx[${fpDocx}] does not exist`)
}
//opt
if (!isobj(opt)) {
opt = {}
}
//timeoutMs
let timeoutMs = get(opt, 'timeoutMs', null)
if (!ispint(timeoutMs)) {
timeoutMs = 1800000
}
timeoutMs = cint(timeoutMs)
//fpVbs
let fpVbs = getFpVbs()
if (fpVbs === '') {
return Promise.reject(`updateDocxToc.vbs is not found in [${fdSelf}] or [${path.resolve(fdSelf, '..', 'src')}]`)
}
//準備內容(前提不符即拋錯, 原檔不變)
let prep = null
try {
prep = prepDocxToc(new Uint8Array(fs.readFileSync(fpDocx)), opt)
}
catch (err) {
return Promise.reject(`Failed to prepare the TOC: ${toErrText(err)}`)
}
let info = prep.info
if (info.skip) {
return { skip: info.skip, ms: Date.now() - msStart }
}
//Word 更新欄位(與轉檔共用佇列), 核對全部相符才覆寫原檔
let tag = `${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
let fpTmpIn = path.join(os.tmpdir(), `wmd2docx_toc_${tag}_in.docx`)
let fpTmpOut = path.join(os.tmpdir(), `wmd2docx_toc_${tag}_out.docx`)
try {
fs.writeFileSync(fpTmpIn, prep.u8)
let rWord = await runExclusive(() => runWord(fpVbs, fpTmpIn, fpTmpOut, timeoutMs))
if (!fsIsFile(fpTmpOut) || fs.statSync(fpTmpOut).size === 0) {
return Promise.reject('Word reported success but the updated docx was not generated')
}
let chk = checkDocxToc(new Uint8Array(fs.readFileSync(fpTmpOut)), info)
if (!chk.ok) {
return Promise.reject(`TOC check failed (${chk.errs.length}): ${chk.errs.slice(0, 5).join('; ')}`)
}
await retryBusy(() => fs.promises.copyFile(fpTmpOut, fpDocx))
return {
skip: '',
cover: info.cover,
levels: info.levels,
front: info.front,
lists: info.lists,
toc: chk.stats.toc,
fig: chk.stats.fig,
tab: chk.stats.tab,
pagesFirst: chk.stats.pagesFirst,
pages: rWord.pages,
warns: info.warns,
ms: Date.now() - msStart,
}
}
catch (err) {
return Promise.reject(toErrText(err))
}
finally {
for (let fp of [fpTmpIn, fpTmpOut]) {
try {
if (fsIsFile(fp)) {
fs.unlinkSync(fp)
}
}
catch (err) {
//暫存檔被鎖(如逾時殘留之 WINWORD 仍開啟), 留待系統暫存清理
}
}
}
}
export { getFpVbs }
export default addDocxToc