WHtml2png.mjs

import fs from 'fs'
import path from 'path'
import puppeteer from 'puppeteer'
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 isp0int from 'wsemi/src/isp0int.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import ispm from 'wsemi/src/ispm.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import cint from 'wsemi/src/cint.mjs'
import now2strp from 'wsemi/src/now2strp.mjs'
import genID from 'wsemi/src/genID.mjs'
import delay from 'wsemi/src/delay.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
import fsDeleteFile from 'wsemi/src/fsDeleteFile.mjs'
import fsDeleteFolder from 'wsemi/src/fsDeleteFolder.mjs'
import fsDeleteFolderSafe from 'wsemi/src/fsDeleteFolderSafe.mjs'
import execProcessKillPid from 'wsemi/src/execProcessKillPid.mjs'


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


let fdSrv = path.resolve()


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


/**
 * 呼叫Chromium轉Html為png圖
 *
 * @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 {Array} [opt.scriptsHead=[]] 輸入引用js程式碼網址陣列,預設[]
 * @param {String|Array} [opt.execJsHead=''] 輸入插入head內執行js程式碼字串或陣列,預設''
 * @param {String|Array} [opt.execJsPost=''] 輸入於dom末插入執行js程式碼字串或陣列,預設''
 * @param {Integer} [opt.retry=3] 輸入失敗重試次數整數,預設3
 * @param {Boolean} [opt.writeError=false] 輸入是否輸出錯誤訊息至檔案布林值,預設false
 * @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)

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

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

    //height
    if (!isnum(height)) {
        return Promise.reject('height is not a number')
    }
    height = cdbl(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
    }

    //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
    })

    //fdBase
    let fdBaseSelf = `${fdSrv}/chrome/`
    let fdBaseDist = `${fdSrv}/node_modules/w-html2png/chrome/`
    let fdBase = fdBaseSelf
    if (fsIsFolder(fdBaseDist)) {
        fdBase = fdBaseDist
    }
    // console.log('fdBase', fdBase)

    //fdExe
    let fdExe = `${fdBase}portable/App/Chrome-bin/138.0.7204.97/`
    // console.log('fdExe', fdExe)

    //fpExe
    let fpExe = `${fdExe}chrome.exe`
    // console.log('fpExe', fpExe)

    //check
    if (!fsIsFile(fpExe)) {
        //已使用npm i postinstall, 預期有fpExe可執行
        throw new Error(`invalid fpExe[${fpExe}], need to run postinstall`)
    }

    //executablePath
    let 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
    }

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

    //iCore
    let iCore = 0

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

        //iCore
        iCore++

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

        //core
        let core = async () => {
            let earrs = []
            let earrsSpe = []

            //supplyHtml
            let supplyHtml = async (fun) => {
                let earrs = []

                //_fpPng
                let _fpPng = `./whpic_${id}.png` //一定要給副檔名, 否則puppeteer的screenshot會無法識別格式

                //fpPng
                let fpPng = path.resolve(_fpPng)
                // console.log('fpPng', fpPng)

                //_fpHtml
                let _fpHtml = `./whweb_${id}.html`

                //fpHtml
                let fpHtml = path.resolve(_fpHtml)
                // console.log('fpHtml', fpHtml)

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

  <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.21/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('{cScriptsHead}', cScriptsHead)
                g = g.replace('{cExecJsHead}', cExecJsHead)
                g = g.replace('{cExecJsPost}', cExecJsPost)

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

                //call fun
                if (size(earrs) === 0) {
                    try {
                        let r = fun(fpHtml, fpPng)
                        if (ispm(r)) {
                            r = await r
                        }
                    }
                    catch (err) {
                        //try catch也能攔截async函數
                        earrs.push({
                            anchor: 'fun(fpHtml, fpPng)',
                            err,
                        })
                    }
                }

                //delete
                if (fsIsFile(fpHtml)) {
                    try {
                        fs.unlinkSync(fpHtml)
                    }
                    catch (err) {}
                }

                //delete
                if (fsIsFile(fpPng)) {
                    try {
                        fs.unlinkSync(fpPng)
                    }
                    catch (err) {}
                }

                if (size(earrs) > 0) {
                    return {
                        state: 'error',
                        msg: earrs,
                    }
                }
                return {
                    state: 'success',
                    msg: '',
                }
            }

            //supplyBrowser
            let supplyBrowser = async (fun) => {
                let earrs = []

                //fdProfile
                let fdProfile = `./_puppeteer_profile_${id}`
                // console.log('fd', fd)

                //puppeteerOpt
                let puppeteerOpt = {
                    headless: modeHeadless,
                    slowMo: 20,
                    protocolTimeout: 60 * 1000, //延長protocol timeout
                    // dumpio: true, //console.log chrome的stdout/stderr訊息
                    userDataDir: fdProfile,
                    args: [
                        // '--single-process',
                        '--no-sandbox',
                        '--incognito',
                        '--disable-gpu',
                        '--disable-software-rasterizer',
                        '--disable-setuid-sandbox',
                        '--disable-dev-shm-usage',
                        '--disable-sync',
                        '--disable-extensions',
                        '--disable-default-apps',
                        '--disable-features=VizDisplayCompositor',
                        // '--disable-background-networking',
                        '--metrics-recording-only',
                        '--mute-audio',
                        '--no-first-run',
                        '--safebrowsing-disable-auto-update',
                        // '--log-level=3', //強制免安裝chrome不顯示info,debug
                    ],
                    // stdio: 'ignore', //強制免安裝chrome不顯示stdio
                    // stdout: 'ignore', //強制免安裝chrome不顯示stdout
                    // stderr: 'ignore', //強制免安裝chrome不顯示stderr
                }
                if (isestr(executablePath)) {
                    puppeteerOpt.executablePath = executablePath
                }
                // console.log('puppeteerOpt.executablePath', puppeteerOpt.executablePath)

                //browser
                let browser = null
                let prc = null
                let pid = null
                try {
                    browser = await puppeteer.launch(puppeteerOpt)
                    prc = browser.process()
                    pid = get(prc, 'pid', '')
                    // console.log('prc', prc)
                    // console.log('pid', pid)
                }
                catch (err) {
                    earrs.push({
                        anchor: 'puppeteer.launch(puppeteerOpt)',
                        err,
                    })
                }

                //call fun
                if (browser !== null) {
                    try {
                        let r = fun(browser)
                        if (ispm(r)) {
                            r = await r
                        }
                    }
                    catch (err) {
                        //try catch也能攔截async函數
                        earrs.push({
                            anchor: 'fun(browser)',
                            err,
                        })
                    }
                }

                // //disconnect, 不使用, 會出現Error: EBUSY: resource busy or locked, unlink '...first_party_sets.db-journal'
                // if (browser !== null) {
                //     try {
                //         await browser.disconnect()
                //     }
                //     catch (err) {
                //         //不一定能disconnect, 故不紀錄錯誤
                //         // earrs.push({
                //         //     anchor: 'browser.disconnect()',
                //         //     err,
                //         // })
                //     }
                // }

                //close
                if (browser !== null) {
                    try {
                        await browser.close()
                    }
                    catch (err) {
                        earrs.push({
                            anchor: 'browser.close()',
                            err,
                        })
                    }
                }

                //pid
                if (isp0int(pid)) {
                    await execProcessKillPid(pid)
                        .catch(() => {
                            // console.log('execProcessKillPid catch', err)
                        })
                }

                //fsDeleteFolderSafe
                if (true) {
                    try {
                        await fsDeleteFolderSafe(fdProfile)
                    }
                    catch (err) {
                        earrs.push({
                            anchor: 'fsDeleteFolderSafe(fd)',
                            err,
                        })
                    }
                }

                if (size(earrs) > 0) {
                    return {
                        state: 'error',
                        msg: earrs,
                    }
                }
                return {
                    state: 'success',
                    msg: '',
                }
            }

            //supplyPage
            let supplyPage = async(browser, fun) => {
                let earrs = []

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

                //call fun
                if (page !== null) {
                    try {
                        let r = fun(page)
                        if (ispm(r)) {
                            r = await r
                        }
                    }
                    catch (err) {
                        //try catch也能攔截async函數
                        earrs.push({
                            anchor: 'fun(page)',
                            err,
                        })
                    }
                }

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

                if (size(earrs) > 0) {
                    return {
                        state: 'error',
                        msg: earrs,
                    }
                }
                return {
                    state: 'success',
                    msg: '',
                }
            }

            //b64, resHtml, resBrowser, resPage
            let b64 = ''
            let resHtml = null
            let resBrowser = null
            let resPage = null

            //supplyHtml
            resHtml = await supplyHtml(async(fpHtml, fpPng) => {

                //supplyPage
                resBrowser = await supplyBrowser(async(browser) => {

                    //supplyPage
                    resPage = await supplyPage(browser, async(page) => {

                        //viewport
                        let viewport = {
                            x: 0,
                            y: 0,
                            width: Number(width),
                            height: Number(height),
                            deviceScaleFactor: Number(scale),
                        }
                        //console.log('viewport',viewport)

                        //show page
                        await page.goto(fpHtml, {
                            waitUntil: [
                                'domcontentloaded', //HTML 文件完全解析完成時觸發,但不一定等到圖片、樣式或附屬框架全載入也不會等到其他資源完成。它速度最快,但若你依賴圖像或 CSS,這個事件可能太早觸發,使得截圖不完整
                                'networkidle2', //在 500 毫秒內,網絡連線數不超過 2 條就被視為「較穩定、資料改動已過」,但仍容許少量持續活動(例如輪詢後台資源)
                            ],
                            timeout: 60 * 1000, //延長timeout
                        })
                        await page.setViewport(viewport)

                        // //delay 3s for highchart rendered
                        // await page.waitFor(3000)

                        //screenshot
                        await page.screenshot({
                            path: fpPng,
                            timeout: 60 * 1000, //延長timeout
                        })

                    })
                        .catch((err) => {
                            //已全攔截, 預期不會有catch
                            if (writeError) {
                                fs.writeFileSync(`./_err_${id}_supplyPage.json`, err.message, 'utf8')
                            }
                        })

                })
                    .catch((err) => {
                        //已全攔截, 預期不會有catch
                        if (writeError) {
                            fs.writeFileSync(`./_err_${id}_supplyBrowser.json`, err.message, 'utf8')
                        }
                    })

                //check
                if (get(resBrowser, 'state', '') === 'error') {
                    return //有錯誤, 錯誤已儲存於resBrowser故直接跳出
                }

                //check, chrome雖未出錯, 但仍有可能screenshot時未能存出fpPng, 故須此處偵測
                if (!fsIsFile(fpPng)) {
                    earrsSpe.push({
                        anchor: 'fsIsFile(fpPng)',
                        err: new Error(`no file[${fpPng}]`),
                    })
                    return //新錯誤, 儲存錯誤至errs並跳出
                }

                //readFileSync
                try {
                    b64 = fs.readFileSync(fpPng, { encoding: 'base64' })
                }
                catch (err) {
                    earrsSpe.push({
                        anchor: `fs.readFileSync(fpPng, { encoding: 'base64' })`,
                        err,
                    })
                    return //新錯誤, 儲存錯誤至errs並跳出
                }

                return null
            })
                .catch((err) => {
                    //已全攔截, 預期不會有catch
                    if (writeError) {
                        fs.writeFileSync(`./_err_${id}_supplyHtml.json`, err.message, 'utf8')
                    }
                })

            //merge earrs
            if (get(resHtml, 'state', '') === 'error') {
                earrs = [
                    ...earrs,
                    ...resHtml.msg,
                ]
            }
            if (get(resBrowser, 'state', '') === 'error') {
                earrs = [
                    ...earrs,
                    ...resBrowser.msg,
                ]
            }
            if (get(resPage, 'state', '') === 'error') {
                earrs = [
                    ...earrs,
                    ...resPage.msg,
                ]
            }
            if (size(earrsSpe) > 0) {
                earrs = [
                    ...earrs,
                    ...earrsSpe,
                ]
            }
            //長期運行安裝版chrome可能會發生之錯誤:
            // Navigating frame was detached in fun(page)
            // Navigation timeout of 30000 ms exceeded in fun(page)
            // Target.createTarget timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed. in browser.newPage()
            // Page.captureScreenshot timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed. in fun(page)
            // Network.enable timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed. in browser.newPage()
            // Timed out after waiting 30000ms in browser.newPage()
            // Timed out after 30000 ms while waiting for the WS endpoint URL to appear in stdout! in puppeteer.launch(puppeteerOpt)
            // EBUSY: resource busy or locked, unlink 'C:\Windows\TEMP\puppeteer_dev_chrome_profile-JqNjnX\first_party_sets.db-journal' in browser.close()
            // EBUSY: resource busy or locked, unlink 'C:\Windows\TEMP\puppeteer_dev_chrome_profile-vl41VZ\first_party_sets.db' in browser.close()

            //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) {
                    fs.writeFileSync(`./_err_${id}_all.json`, cearrs, 'utf8')
                }

                return Promise.reject(cearrs)
            }

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

            return b64
        }

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

                //_fpHtml
                let _fpHtml = `./whweb_${id}.html`
                if (fsIsFile(_fpHtml)) {
                    if (writeError) {
                        fs.writeFileSync(`./_err_${id}_fpHtml.json`, `can not delete html[${_fpHtml}]`, 'utf8')
                    }
                    fsDeleteFile(_fpHtml)
                }

                //fdProfile
                let fdProfile = `./_puppeteer_profile_${id}`
                if (fsIsFolder(fdProfile)) {
                    if (writeError) {
                        fs.writeFileSync(`./_err_${id}_profile.json`, `can not delete profile[${fdProfile}]`, 'utf8')
                    }
                    fsDeleteFolder(fdProfile)
                }

                //_fpPng
                let _fpPng = `./whpic_${id}.png`
                if (fsIsFile(_fpPng)) {
                    if (writeError) {
                        fs.writeFileSync(`./_err_${id}_fpPng.json`, `can not delete png[${_fpPng}]`, 'utf8')
                    }
                    fsDeleteFile(_fpPng)
                }

            })

        //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
            }

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

        }

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

        return b64
    }

    //proc
    let b64 = await proc()

    return b64
}


export default WHtml2png