WHtml2png.mjs

import fs from 'fs'
import path from 'path'
import { chromium } from 'playwright-core'
import get from 'lodash-es/get.js'
import each from 'lodash-es/each.js'
import size from 'lodash-es/size.js'
import isnum from 'wsemi/src/isnum.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import ispm from 'wsemi/src/ispm.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import cint from 'wsemi/src/cint.mjs'
import genID from 'wsemi/src/genID.mjs'
import now2strp from 'wsemi/src/now2strp.mjs'
import delay from 'wsemi/src/delay.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
import fsCreateFolder from 'wsemi/src/fsCreateFolder.mjs'
import fsDeleteFile from 'wsemi/src/fsDeleteFile.mjs'
import autoDownloadFiles from './autoDownloadFiles.mjs'


//調用chrome免安裝版, 須至just-cool.net下載:
//https://blog.just-cool.net/google-chrome-portable/


//timeIdle, alive模式閒置自動關閉常駐瀏覽器之時間(ms)
let timeIdle = 60 * 60 * 1000 //1hr


//常駐瀏覽器狀態(模組層單例), 供alive模式使用
let browserPm = null //chromium.launch之Promise, 非null代表可用或啟動中(await即可取得), null代表下次呼叫須重新啟動
let ctxPms = {} //以deviceScaleFactor為key之context Promise快取
let refCount = 0 //繪圖中之呼叫數
let timerIdle = null //閒置計時器


function isWindows() {
    return process.platform === 'win32'
}


//getLaunchOpt, 組合chromium啟動參數, alive與single模式共用
let getLaunchOpt = (executablePath, modeHeadless) => {

    //headless, playwright僅支援布林, modeHeadless=false時顯示UI, 其餘視為無頭
    let headless = modeHeadless !== false

    //launchOpt
    let launchOpt = {
        headless,
        timeout: 60 * 1000, //延長launch timeout
        args: [
            '--lang=en-US', //未指定font-family時預設字型依瀏覽器UI語系解析, zh-TW系統會變成微軟正黑(無襯線), 固定en-US使其與puppeteer時代輸出一致(Times New Roman襯線)
            '--no-sandbox',
            '--disable-gpu',
            '--disable-dev-shm-usage',
            '--disable-sync',
            '--disable-extensions',
            '--disable-default-apps',
            '--metrics-recording-only',
            '--mute-audio',
            '--no-first-run',
            '--safebrowsing-disable-auto-update',
        ],
    }
    if (isestr(executablePath)) {
        launchOpt.executablePath = executablePath
    }

    return launchOpt
}


//getBrowser, 取得常駐瀏覽器, 未啟動時lazy啟動, 併發呼叫共用同一個launch Promise
let getBrowser = (executablePath, modeHeadless) => {

    if (browserPm === null) {

        //launch, 同步指派browserPm, 同一tick後之併發呼叫直接await同一個Promise, 不會重複launch
        let pmSelf = chromium.launch(getLaunchOpt(executablePath, modeHeadless))
            .then((browser) => {

                //disconnected, 瀏覽器意外死亡時歸零, 使下次呼叫重新啟動; 僅在browserPm仍指向本瀏覽器時歸零, 避免蓋掉他處新啟動之瀏覽器
                browser.on('disconnected', () => {
                    if (browserPm === pmSelf) {
                        browserPm = null
                        ctxPms = {}
                    }
                })

                return browser
            })
            .catch((err) => {

                //啟動失敗歸零, 使下次呼叫可重試啟動
                if (browserPm === pmSelf) {
                    browserPm = null
                    ctxPms = {}
                }

                return Promise.reject(err)
            })
        browserPm = pmSelf

    }

    return browserPm
}


//getContext, 取得指定deviceScaleFactor之常駐context, playwright之deviceScaleFactor為context層級故依scale分快取
let getContext = async (executablePath, modeHeadless, scale) => {

    //browser
    let browser = await getBrowser(executablePath, modeHeadless)

    //key
    let key = `dsf-${scale}`

    //newContext
    if (ctxPms[key] === undefined) {
        let pmCtx = browser.newContext({
            viewport: {
                width: 1280,
                height: 720,
            },
            deviceScaleFactor: scale,
        })
            .catch((err) => {

                //建立失敗清除快取, 使下次呼叫可重試建立
                if (ctxPms[key] === pmCtx) {
                    delete ctxPms[key]
                }

                return Promise.reject(err)
            })
        ctxPms[key] = pmCtx
    }

    return ctxPms[key]
}


//closeBrowser, 關閉常駐瀏覽器; 先歸零再關閉, 使後續呼叫直接啟動新瀏覽器, 不會取得關閉中之瀏覽器
let closeBrowser = async () => {

    //pmOld
    let pmOld = browserPm

    //歸零
    browserPm = null
    ctxPms = {}

    //close
    if (pmOld !== null) {
        await pmOld
            .then((browser) => {
                return browser.close()
            })
            .catch(() => {})
    }

}


//scheduleIdleClose, 重設閒置計時器, 閒置超過timeIdle且無繪圖中呼叫時自動關閉常駐瀏覽器
let scheduleIdleClose = () => {

    //clear
    clearTimeout(timerIdle)
    timerIdle = null

    //check, 無瀏覽器則無須排程
    if (browserPm === null) {
        return
    }

    //setTimeout
    timerIdle = setTimeout(() => {
        timerIdle = null
        if (refCount === 0 && browserPm !== null) {
            closeBrowser()
        }
    }, timeIdle)

}


//aliveStrategy, 常駐模式: 使用模組層常駐瀏覽器與context, 出圖後不釋放, 失敗重試前重啟瀏覽器自癒
let aliveStrategy = {
    acquire: async ({ executablePath, modeHeadless, scale }) => {
        let context = await getContext(executablePath, modeHeadless, scale)
        return {
            context,
            release: null, //常駐不釋放, 瀏覽器交由閒置計時器或WHtml2png.close()關閉
        }
    },
    heal: async () => {
        await closeBrowser()
    },
}


//singleStrategy, 單次模式: 每次啟動新瀏覽器, 出圖後即關閉釋放
let singleStrategy = {
    acquire: async ({ executablePath, modeHeadless, scale }) => {

        //browser
        let browser = await chromium.launch(getLaunchOpt(executablePath, modeHeadless))

        //context
        let context = null
        try {
            context = await browser.newContext({
                viewport: {
                    width: 1280,
                    height: 720,
                },
                deviceScaleFactor: scale,
            })
        }
        catch (err) {
            //建立context失敗須關閉已啟動之瀏覽器避免洩漏
            await browser.close()
                .catch(() => {})
            throw err
        }

        return {
            context,
            release: async () => {
                await browser.close()
            },
        }
    },
    heal: async () => {},
}


//core, alive與single共用之出圖流程: 驗證輸入, 組合html, 依strategy取得context, 開page載入渲染截圖, 失敗重試, 回傳base64
async function core(width, height, scale, html, opt, strategy) {

    //isWindows
    if (!isWindows()) {
        return Promise.reject('operating system is not windows')
    }

    //width
    if (!isnum(width)) {
        return Promise.reject('width is not a number')
    }
    width = cint(width)
    if (width <= 0) {
        return Promise.reject('width <= 0')
    }

    //height
    if (!isnum(height)) {
        return Promise.reject('height is not a number')
    }
    height = cint(height)
    if (height <= 0) {
        return Promise.reject('height <= 0')
    }

    //scale
    if (!isnum(scale)) {
        return Promise.reject('scale is not a number')
    }
    scale = cdbl(scale)
    if (scale <= 0) {
        return Promise.reject('scale <= 0')
    }

    //html
    if (!isestr(html)) {
        return Promise.reject('html is not an effective string')
    }
    let cHtml = html

    //modeHeadless
    let modeHeadless = get(opt, 'modeHeadless')
    if (modeHeadless !== true && modeHeadless !== false && modeHeadless !== 'new' && modeHeadless !== 'shell') {
        modeHeadless = 'new' //無頭, 不顯示UI
        // modeHeadless = false //顯示UI
    }

    //stylesHead
    let stylesHead = get(opt, 'stylesHead')
    if (!isearr(stylesHead)) {
        stylesHead = []
    }

    //cStylesHead
    let cStylesHead = ''
    each(stylesHead, (v) => {
        let c = `<link href="${v}" rel="stylesheet" />`
        cStylesHead += c
    })

    //scriptsHead
    let scriptsHead = get(opt, 'scriptsHead')
    if (!isearr(scriptsHead)) {
        scriptsHead = []
    }

    //cScriptsHead
    let cScriptsHead = ''
    each(scriptsHead, (v) => {
        let c = `<script src="${v}"></script>\n`
        cScriptsHead += c
    })

    //execJsHead
    let execJsHead = get(opt, 'execJsHead')
    if (isestr(execJsHead)) {
        execJsHead = [execJsHead]
    }
    if (!isearr(execJsHead)) {
        execJsHead = []
    }

    //cExecJsHead
    let cExecJsHead = ''
    each(execJsHead, (v) => {
        let c = `<script>${v}</script>\n\n`
        cExecJsHead += c
    })

    //execJsPost
    let execJsPost = get(opt, 'execJsPost')
    if (isestr(execJsPost)) {
        execJsPost = [execJsPost]
    }
    if (!isearr(execJsPost)) {
        execJsPost = []
    }

    //cExecJsPost
    let cExecJsPost = ''
    each(execJsPost, (v) => {
        let c = `<script>${v}</script>\n\n`
        cExecJsPost += c
    })

    //funGetUrl
    let funGetUrl = get(opt, 'funGetUrl')

    //funPageWait
    let funPageWait = get(opt, 'funPageWait')

    //executablePath, 可由外部指定瀏覽器執行檔(如本機安裝Chrome或playwright託管chromium), 未指定則使用套件自帶之免安裝chrome
    let executablePath = get(opt, 'executablePath')
    if (isestr(executablePath)) {

        //check
        if (!fsIsFile(executablePath)) {
            throw new Error(`invalid opt.executablePath[${executablePath}]`)
        }

    }
    else {

        //executablePath, 定位套件自帶之免安裝chrome, 若無檔案則自動下載, 取不到時autoDownloadFiles會reject錯誤訊息
        let { fpExe } = await autoDownloadFiles()
        executablePath = fpExe

    }

    //retry
    let retry = get(opt, 'retry')
    if (!ispint(retry)) {
        retry = 3
    }
    retry = cint(retry)

    //writeError
    let writeError = get(opt, 'writeError')
    if (!isbol(writeError)) {
        writeError = false
    }

    //fdHtml
    let fdHtml = get(opt, 'fdHtml')
    if (!isestr(fdHtml)) {
        fdHtml = './_convertTemp'
    }

    //fdErr
    let fdErr = get(opt, 'fdErr')
    if (!isestr(fdErr)) {
        fdErr = './_convertTemp'
    }

    //idpm
    let idpm = `${now2strp()}-${genID(6)}`

    //iCore
    let iCore = 0

    //exec, 增加計數器, 執行coreDraw, 檢測非預期問題
    let exec = async() => {
        let errTemp = null
        let b64 = ''

        //iCore
        iCore++

        //id
        let id = `${idpm}-${iCore}`

        //coreDraw
        let coreDraw = async () => {
            let earrs = []

            //html
            let g = `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>highcharts to png</title>

  {cStylesHead}

  <script src="https://cdn.jsdelivr.net/npm/lodash/lodash.min.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/wsemi/dist/wsemi.umd.js"></script>
  <script>
      let w = wsemi
  </script>

  {cScriptsHead}

  {cExecJsHead}

</head>
<body style="padding:0; margin:0;">

    <div id="pl">
        {cHtml}
    </div>

    {cExecJsPost}

</body>
</html>
`

            //html與style先取代, 避免取代到引入程式碼
            g = g.replace('{cHtml}', cHtml)

            //引入程式碼
            g = g.replace('{cStylesHead}', cStylesHead)
            g = g.replace('{cScriptsHead}', cScriptsHead)
            g = g.replace('{cExecJsHead}', cExecJsHead)
            g = g.replace('{cExecJsPost}', cExecJsPost)

            //fpHtml與url, 僅funGetUrl場景需寫html暫存檔, 否則直接以setContent灌入html字串
            let fpHtml = null
            let url = null
            if (isfun(funGetUrl)) {

                //fsCreateFolder
                if (!fsIsFolder(fdHtml)) {
                    fsCreateFolder(fdHtml)
                }

                //fpHtml
                fpHtml = path.resolve(fdHtml, `whweb_${id}.html`)
                // console.log('fpHtml', fpHtml)

                //writeFileSync
                try {
                    fs.writeFileSync(fpHtml, g, 'utf8')
                }
                catch (err) {
                    earrs.push({
                        anchor: `fs.writeFileSync(fpHtml, g, 'utf8')`,
                        err,
                    })
                }

                //funGetUrl
                if (size(earrs) === 0) {
                    try {
                        url = funGetUrl(fpHtml)
                        if (ispm(url)) {
                            url = await url
                        }
                    }
                    catch (err) {
                        earrs.push({
                            anchor: 'funGetUrl(fpHtml)',
                            err,
                        })
                    }
                }

            }

            //b64
            let b64 = ''

            //page流程
            if (size(earrs) === 0) {

                //acquire, 依strategy取得context與釋放函數
                let context = null
                let release = null
                try {
                    let ac = await strategy.acquire({ executablePath, modeHeadless, scale })
                    context = ac.context
                    release = ac.release
                }
                catch (err) {
                    earrs.push({
                        anchor: 'strategy.acquire',
                        err,
                    })
                }

                //page
                let page = null
                if (context !== null) {
                    try {
                        page = await context.newPage()
                        page.setDefaultNavigationTimeout(60 * 1000) //延長timeout
                    }
                    catch (err) {
                        earrs.push({
                            anchor: 'context.newPage()',
                            err,
                        })
                    }
                }

                //draw
                if (page !== null) {
                    try {

                        //setViewportSize, deviceScaleFactor已由context給定
                        await page.setViewportSize({
                            width: Number(width),
                            height: Number(height),
                        })

                        //show page, networkidle代表500ms內無網路連線, 對應puppeteer之networkidle系列
                        if (url !== null) {
                            await page.goto(url, {
                                waitUntil: 'networkidle',
                                timeout: 60 * 1000, //延長timeout
                            })
                        }
                        else {
                            await page.setContent(g, {
                                waitUntil: 'networkidle',
                                timeout: 60 * 1000, //延長timeout
                            })
                        }

                        //delay
                        if (isfun(funPageWait)) {
                            await page.waitForFunction(funPageWait, null, { polling: 200, timeout: 60 * 1000 }) //200ms偵測一次, 延長timeout
                        }

                        //screenshot, 直接取Buffer, 不落地png暫存檔
                        let buf = await page.screenshot({
                            timeout: 60 * 1000, //延長timeout
                        })

                        //b64
                        b64 = Buffer.from(buf).toString('base64')

                    }
                    catch (err) {
                        //try catch也能攔截async函數
                        earrs.push({
                            anchor: 'fun(page)',
                            err,
                        })
                    }

                    //close
                    try {
                        await page.close()
                    }
                    catch (err) {
                        earrs.push({
                            anchor: 'page.close()',
                            err,
                        })
                    }
                }

                //release, single模式關閉本次啟動之瀏覽器, alive模式為null不釋放
                if (isfun(release)) {
                    try {
                        await release()
                    }
                    catch (err) {
                        earrs.push({
                            anchor: 'release()',
                            err,
                        })
                    }
                }

            }

            //delete
            if (fpHtml !== null && fsIsFile(fpHtml)) {
                try {
                    fs.unlinkSync(fpHtml)
                }
                catch (err) {}
            }

            //check
            if (size(earrs) > 0) {

                //cearrs
                let cearrs = ''
                each(earrs, (earr) => {
                    let m = get(earr, 'err.message', '')
                    let c = `${m} in ${earr.anchor}`
                    cearrs += c + '\n'
                })

                //writeFileSync
                if (writeError) {
                    if (!fsIsFolder(fdErr)) {
                        fsCreateFolder(fdErr)
                    }
                    let fpJson = path.resolve(fdErr, `err_${id}_all.json`)
                    // console.log('fpJson', fpJson)
                    fs.writeFileSync(fpJson, cearrs, 'utf8')
                }

                return Promise.reject(cearrs)
            }

            //check
            if (!isestr(b64)) {
                return Promise.reject(`b64 is not an effective string`)
            }

            return b64
        }

        //coreDraw
        await coreDraw()
            .then((res) => {
                b64 = res
            })
            .catch((err) => {
                errTemp = err
            })
            .finally(() => {

                //fpHtml, 防禦性清除, 預期coreDraw內已刪除
                let fpHtml = path.resolve(fdHtml, `whweb_${id}.html`)
                if (fsIsFile(fpHtml)) {
                    fsDeleteFile(fpHtml)
                }
                if (fsIsFile(fpHtml) && writeError) {
                    if (!fsIsFolder(fdErr)) {
                        fsCreateFolder(fdErr)
                    }
                    let fpJson = path.resolve(fdErr, `err_${id}_fpHtml.json`)
                    fs.writeFileSync(fpJson, `can not delete html[whweb_${id}.html}]`, 'utf8')
                }

            })

        //state
        let state = errTemp === null ? 'success' : 'error'

        //r
        let r = {
            state,
            message: errTemp,
            b64,
        }

        return r
    }

    //proc, 失敗時重試
    let proc = async() => {
        let errTemp = null
        let b64 = ''

        while (true) {

            //exec
            let r = await exec()
                .catch(() => {
                    //已全攔截, 預期不會有catch
                })

            //check, r.state='success'
            if (get(r, 'state', '') === 'success') {
                //儲存b64並跳出
                b64 = r.b64
                break
            }

            //check, 來到此處必為r.state='error'
            if (iCore >= retry) {
                //若retry=3, 執行3次都失敗時iCore=3, 則視為不再重試, 儲存錯誤並跳出
                errTemp = r.message //使用最後執行之錯誤訊息回傳
                break
            }

            //自癒, 失敗重試前依strategy處置, 常駐模式重啟瀏覽器使重試取得全新瀏覽器
            await strategy.heal()

            //延遲再重試
            await delay(5000)

        }

        //check
        if (errTemp !== null) {
            return Promise.reject(errTemp)
        }

        return b64
    }

    //proc
    let b64 = await proc()

    return b64
}


//coreAlive, 常駐模式: 出圖後瀏覽器留存供同行程後續呼叫重用, 含引用計數, 結束時重設閒置關閉排程
async function coreAlive(width, height, scale, html, opt = {}) {
    refCount++
    try {
        let b64 = await core(width, height, scale, html, opt, aliveStrategy)
        return b64
    }
    finally {
        refCount--
        scheduleIdleClose()
    }
}


//coreSingle, 單次模式: 每次啟動新瀏覽器出圖後即關閉釋放, Node行程可自然結束
async function coreSingle(width, height, scale, html, opt = {}) {
    let b64 = await core(width, height, scale, html, opt, singleStrategy)
    return b64
}


/**
 * 呼叫Chromium轉Html為png圖
 *
 * 提供兩種瀏覽器使用模式(opt.mode):
 * 'single'為每次呼叫啟動新瀏覽器, 出圖後即關閉釋放, Node行程可自然結束, 適合排程批次;
 * 'alive'為常駐瀏覽器, 第一次呼叫時啟動, 同行程後續呼叫重用故出圖較快, 閒置超過1小時自動關閉,
 * 批次腳本結束前可呼叫WHtml2png.close()主動關閉常駐瀏覽器, 使Node行程可立即退出
 *
 * 未指定opt.executablePath時使用套件自帶之免安裝chrome, 該chrome由安裝時(postinstall)自GitHub下載切割zip分片合併解壓而來,
 * 位於套件chrome/目錄; 若因npm封鎖scripts致postinstall未執行, 則於首次調用時自動重新下載
 *
 * @class
 * @param {Number} [width=700] 輸入圖片原始寬度數字,單位px,預設700
 * @param {Number} [height=400] 輸入圖片原始高度數字,單位px,預設400
 * @param {Number} [scale=3] 輸入欲將圖片放大比例數字,單位px,預設3
 * @param {String} [html=''] 輸入HTML字串,預設''
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.mode='single'] 輸入瀏覽器使用模式字串,'single'代表每次呼叫啟閉瀏覽器,'alive'代表常駐瀏覽器供同行程重用,預設'single'
 * @param {String} [opt.executablePath=''] 輸入瀏覽器執行檔路徑字串,可指定本機安裝Chrome或playwright託管chromium等,未給則使用套件自帶之免安裝chrome(無檔案時自動下載);注意alive模式之常駐瀏覽器由同行程第一次呼叫決定執行檔,欲切換須先呼叫WHtml2png.close(),預設''
 * @param {Array} [opt.stylesHead=[]] 輸入引用css程式碼網址陣列,預設[]
 * @param {Array} [opt.scriptsHead=[]] 輸入引用js程式碼網址陣列,預設[]
 * @param {String|Array} [opt.execJsHead=''] 輸入插入head內執行js程式碼字串或陣列,預設''
 * @param {String|Array} [opt.execJsPost=''] 輸入於dom末插入執行js程式碼字串或陣列,預設''
 * @param {Function} [opt.funGetUrl=null] 輸入轉換goto所使用本機網址(fpHtml)成為url之函數,預設null
 * @param {Function} [opt.funPageWait=null] 輸入前端瀏覽器內偵測等待完成之函數,可用window或document等,回傳true則代表渲染完成可進行截圖,預設null
 * @param {Integer} [opt.retry=3] 輸入失敗重試次數整數,預設3
 * @param {Boolean} [opt.writeError=false] 輸入是否輸出錯誤訊息至檔案布林值,預設false
 * @param {String} [opt.fdHtml='./_convertTemp'] 輸入臨時儲存繪圖用html檔之資料夾位置字串,僅設定opt.funGetUrl時會產生html暫存檔,預設'./_convertTemp'
 * @param {String} [opt.fdErr='./_convertTemp'] 輸入臨時儲存錯誤檔之資料夾位置字串,預設'./_convertTemp'
 * @returns {Promise} 回傳Promise,resolve為回傳base64圖片,reject為錯誤訊息
 * @example
 *
 * async function testa() {
 *
 *     let html = `
 * <div style="padding:10px; display:inline-block;">
 *     <div style="background-color: rgb(255, 255, 255); border-radius: 5px; width: 600px; box-shadow:0 3px 1px -2px rgba(0,0,0,.2), 0 2px 2px 0 rgba(0,0,0,.14), 0 1px 5px 0 rgba(0,0,0,.12);">
 *         <div style="padding: 20px; border-bottom: 1px solid rgb(221, 221, 221); background-color: rgb(250, 250, 250); border-radius: 5px 5px 0px 0px; display: flex; justify-content: flex-start; align-items: center;">
 *             <div>
 *                 <div style="font-size: 2rem;">Panel Title</div>
 *             </div>
 *         </div>
 *         <div style="border-radius: 0px;">
 *             <div style="padding: 20px;">
 *                 Here is a panel content, Morbi mattis ullamcorper velit. Donec orci lectus, aliquam ut, faucibus non, euismod id, nulla. In ut quam vitae odio lacinia tincidunt.
 *             </div>
 *         </div>
 *         <div style="padding: 20px; border-top: 1px solid rgb(221, 221, 221); background-color: rgb(250, 250, 250); border-radius: 0px 0px 5px 5px;">
 *             Here is a panel footer
 *         </div>
 *     </div>
 * </div>
 *     `
 *     let width = 620
 *     let height = 235
 *     let scale = 3
 *
 *     let b64 = await WHtml2png(width, height, scale, html)
 *     // console.log('b64', b64)
 *
 *     // fs.writeFileSync('./test-scla.b64', b64, 'utf8')
 *     fs.writeFileSync('./test-scla.png', b64, { encoding: 'base64' })
 *
 *     console.log('finish')
 * }
 * testa()
 *     .catch((err) => {
 *         console.log(err)
 *     })
 *
 */
async function WHtml2png(width = 700, height = 400, scale = 3, html = '', opt = {}) {
    // console.log('WHtml2png', width, height, scale, opt)

    //mode
    let mode = get(opt, 'mode')
    if (mode !== 'single' && mode !== 'alive') {
        mode = 'single'
    }

    //dispatch
    if (mode === 'alive') {
        return coreAlive(width, height, scale, html, opt)
    }
    return coreSingle(width, height, scale, html, opt)
}


//close, 主動關閉alive模式之常駐瀏覽器並清除閒置計時器, 供批次腳本結束前呼叫使Node行程可立即退出
WHtml2png.close = async () => {
    clearTimeout(timerIdle)
    timerIdle = null
    await closeBrowser()
}


export default WHtml2png