import fs from 'fs'
import path from 'path'
import process from 'process'
import get from 'lodash-es/get.js'
import isestr from 'wsemi/src/isestr.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import ispnum from 'wsemi/src/ispnum.mjs'
import isp0int from 'wsemi/src/isp0int.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import cint from 'wsemi/src/cint.mjs'
import str2b64 from 'wsemi/src/str2b64.mjs'
import execProcess from 'wsemi/src/execProcess.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
import fsCreateFolder from 'wsemi/src/fsCreateFolder.mjs'
import ensureBins from './ensureBins.mjs'
let fdSrv = path.resolve()
//fnExe, 轉檔器名稱(bin/manifest.json之name)
let fnExe = 'htmlToDocx.exe'
function isWindows() {
return process.platform === 'win32'
}
/**
* Html檔轉Docx檔
*
* 轉檔由htmlToDocx.exe調用本機Microsoft Word(win32com)完成,故須於Windows且已安裝Word之機器執行。htmlToDocx.exe不含於npm包,首次轉檔時經ensureBins自GitHub(釘選提交)或環境變數W_BIN_MIRROR之鏡像取得,以bin/manifest.json之SHA-256與大小校驗後存於快取(預設為套件根之bin-cache/,可由W_BIN_CACHE改位置),之後自快取取得(亦校驗);環境變數W_BIN_OFFLINE=1時不連網。取得失敗則reject字串,內含錯誤代碼(如E_OFFLINE_MISSING、E_HASH_MISMATCH)與原因,詳見README〈外部執行檔〉
* 轉檔器內部失敗(Word未安裝、模板無法開啟、另存失敗等)一律reject並帶轉檔器回報之原因;resolve前另確認輸出檔已產生且非空。輸出資料夾不存在時自動建立
* html內圖片皆內嵌於輸出docx,使docx不依賴原圖:data URI照舊,本機路徑與網址之圖片由Word取回後斷開連結內嵌;解析不到者(圖檔不存在、Word無法解析之路徑寫法如正斜線絕對路徑與百分比編碼路徑、網址取不到)不產生輸出檔並reject,訊息每行列出一個無法解析之圖片來源(`unresolved linked picture: <來源>`),可解析之路徑寫法詳見README〈圖片〉
*
* @param {String} fpInHtml 輸入來源Html檔位置字串
* @param {String} fpOutDocx 輸入轉出Docx檔位置字串
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.fpInTemp=''] 輸入Docx模板檔案位置字串,有給時須存在否則reject;未給時依序取模組載入當下工作路徑之`./src/tmp.docx`或`./node_modules/w-html2docx/src/tmp.docx`(套件內建模板),預設''
* @param {Array|String} [opt.fontFamilies=['標楷體','Times New Roman']] 輸入轉出Docx時強制更改字型陣列或字串,陣列順序為變更順序,若有要覆寫字型得放後面。預設['標楷體','Times New Roman']
* @param {Number} [opt.imgRatioWidthMax=1] 輸入圖片最大寬度比例,值介於0至1,圖片最大寬度為所在節之版心寬乘以本值,預設1
* @param {Number} [opt.imgRatioHeightMax=1] 輸入圖片最大高度比例,值介於0至1,圖片最大高度為所在節之版心高乘以0.937043054427296(為圖名預留空間)再乘以本值;有給imgHeightReserveLines且其生效時,改為其算出之高度乘以本值。預設1
* @param {Integer} [opt.imgHeightReserveLines=null] 輸入行格線模板之圖片下方預留格數,為非負整數,預設null(不啟用)。僅於圖片所在節設有文件格線(Word之「只指定行的格線」「指定行與字元的格線」「文字對齊字元格線」)時生效:圖片最大高度改為(每頁格數-本值)×行距-0.5點,行距為模板之文件格線行距(w:docGrid之w:linePitch),每頁格數為版心高÷行距之整數部分。行格線下圖片段佔「圖高÷行距」無條件進位之格數,故整頁高之圖下方仍留有本值格數,供間隔段與圖名使用,例如間隔段1格、圖名至多2行時至少給3。格數依實際所佔計,行距18點時12pt中文字每行佔1格、14pt之標楷體每行佔2格。建議再多給1格作為餘裕(如前例給4):帶段前段後距離之段落會使其後各行不再對齊格線而多佔,頁首頁尾高於邊界時Word亦會縮小本文區,使每頁實際格數少於推算值。給無效值、模板無行格線、或本值不小於每頁格數時不生效,沿用imgRatioHeightMax之預設算法
* @param {Number} [opt.timeout=null] 輸入轉檔逾時毫秒數,預設null表示不限制;逾時時強制關閉轉檔器並reject回傳逾時訊息,惟由轉檔器經COM啟動之Word不在其程序樹內,不會一併被關閉
* @returns {Promise} 回傳Promise,resolve回傳'ok',reject回傳錯誤訊息字串
* @example
*
* import w from 'wsemi'
* import WHtml2docx from './src/WHtml2docx.mjs'
* //import WHtml2docx from 'w-html2docx/src/WHtml2docx.mjs'
* //import WHtml2docx from 'w-html2docx'
*
* async function test() {
*
* let fpIn = `./test/ztmp.html`
* let fpOut = `./test/ztmp.docx`
* let opt = {
* imgRatioWidthMax: 0.5,
* }
*
* let r = await WHtml2docx(fpIn, fpOut, opt)
* console.log(r)
* // => ok
*
* w.fsDeleteFile(fpOut)
*
* }
* test()
* .catch((err) => {
* console.log('catch', err)
* })
*
*/
async function WHtml2docx(fpInHtml, fpOutDocx, opt = {}) {
let errTemp = null
//isWindows
if (!isWindows()) {
return Promise.reject('operating system is not windows')
}
//check
if (!fsIsFile(fpInHtml)) {
return Promise.reject(`fpInHtml[${fpInHtml}] does not exist`)
}
//fontFamilies
let fontFamilies = get(opt, 'fontFamilies')
if (isestr(fontFamilies)) {
fontFamilies = [fontFamilies]
}
if (!isearr(fontFamilies)) {
fontFamilies = ['標楷體', 'Times New Roman']
}
//imgRatioWidthMax
let imgRatioWidthMax = get(opt, 'imgRatioWidthMax')
if (!ispnum(imgRatioWidthMax)) {
imgRatioWidthMax = 1
}
imgRatioWidthMax = cdbl(imgRatioWidthMax)
//imgRatioHeightMax
let imgRatioHeightMax = get(opt, 'imgRatioHeightMax')
if (!ispnum(imgRatioHeightMax)) {
imgRatioHeightMax = 1
}
imgRatioHeightMax = cdbl(imgRatioHeightMax)
//imgHeightReserveLines, 非負整數才採用, 其餘(含Infinity等非有限值)為null(不啟用)
let imgHeightReserveLines = get(opt, 'imgHeightReserveLines', null)
if (isp0int(imgHeightReserveLines) && Number.isFinite(Number(imgHeightReserveLines))) {
imgHeightReserveLines = cint(imgHeightReserveLines)
}
else {
imgHeightReserveLines = null
}
//timeout
let timeout = get(opt, 'timeout')
if (!ispnum(timeout)) {
timeout = null
}
//fpInTemp
let fpInTemp = get(opt, 'fpInTemp')
if (isestr(fpInTemp)) {
//check, 呼叫端有提供時須存在, 與fpInHtml之檢查對稱; 否則會由轉檔器於開啟模板時失敗, 訊息為Word之COM錯誤而看不出是路徑問題
if (!fsIsFile(fpInTemp)) {
return Promise.reject(`fpInTemp[${fpInTemp}] does not exist`)
}
}
else {
//fnTmp
let fnTmp = `tmp.docx`
//fpInTemp
if (true) {
let fpTmpSrc = `${fdSrv}/src/${fnTmp}`
let fpTmpNM = `${fdSrv}/node_modules/w-html2docx/src/${fnTmp}`
if (fsIsFile(fpTmpSrc)) {
fpInTemp = fpTmpSrc
}
else if (fsIsFile(fpTmpNM)) {
fpInTemp = fpTmpNM
}
else {
return Promise.reject(`can not find folder for ${fnTmp}`)
}
}
}
//轉絕對路徑
fpInHtml = path.resolve(fpInHtml)
fpInTemp = path.resolve(fpInTemp)
fpOutDocx = path.resolve(fpOutDocx)
//fdOutDocx, 輸出資料夾不存在時自動建立, 否則Word另存時失敗
let fdOutDocx = path.dirname(fpOutDocx)
if (!fsIsFolder(fdOutDocx)) {
fsCreateFolder(fdOutDocx)
if (!fsIsFolder(fdOutDocx)) {
return Promise.reject(`can not create folder[${fdOutDocx}] for fpOutDocx`)
}
}
//prog, 經ensureBins取得htmlToDocx.exe(快取命中亦校驗, 未命中則自鏡像或GitHub取得並校驗), 本函數以字串reject, 故將帶code之Error轉為字串
let prog = ''
let errBin = null
await ensureBins({ names: fnExe })
.then((r) => {
prog = r[fnExe]
})
.catch((err) => {
errBin = `${isestr(get(err, 'code')) ? `[${err.code}] ` : ''}${isestr(get(err, 'message')) ? err.message : String(err)}`
})
if (errBin) {
return Promise.reject(errBin)
}
// console.log('prog', prog)
//inp
let inp = {
fpInSrc: fpInHtml,
fpInTemp,
fpOut: fpOutDocx,
fontFamilies,
imgRatioWidthMax,
imgRatioHeightMax,
imgHeightReserveLines,
}
// console.log('inp', inp)
//input to b64
let cInput = JSON.stringify(inp)
let b64Input = str2b64(cInput)
// console.log('b64Input', b64Input)
//execProcess, 轉檔器成功時印success且離開碼0, 失敗時印error: 原因且離開碼1, 故離開碼非0即reject並帶其輸出
// codeCmd給auto: 現行轉檔器以utf-8輸出, 尚未重新下載之舊版轉檔器以系統字碼頁(中文Windows為cp950)輸出, auto先以utf-8嚴格解碼, 遇非utf-8位元組改用系統字碼頁, 兩者皆可正確解碼; 轉檔器損毀時bootloader之ANSI訊息亦同
let output = ''
await execProcess(prog, b64Input, { codeCmd: 'auto', timeout })
.then((r) => {
output = r
})
.catch((err) => {
errTemp = String(err).trim()
})
//check
if (errTemp) {
return Promise.reject(errTemp)
}
//check, 轉檔器回報成功仍須確認輸出檔存在且非空: 舊版轉檔器於Word未安裝、模板無法開啟等失敗時仍以離開碼0結束, 只在輸出印出error
if (!fsIsFile(fpOutDocx) || fs.statSync(fpOutDocx).size === 0) {
return Promise.reject(`fpOutDocx[${fpOutDocx}] was not generated: ${output.trim()}`)
}
return 'ok'
}
//ensureBins, 以靜態屬性提供(不另加具名匯出, 使UMD打包後default匯出形態不變): 供部署前預先取得htmlToDocx.exe, 見README〈外部執行檔〉
WHtml2docx.ensureBins = ensureBins
export default WHtml2docx