md2html.mjs

import get from 'lodash-es/get.js'
import each from 'lodash-es/each.js'
import map from 'lodash-es/map.js'
import join from 'lodash-es/join.js'
import isestr from 'wsemi/src/isestr.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import isWindow from 'wsemi/src/isWindow.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import replace from 'wsemi/src/replace.mjs'
import { Marked } from 'marked'
import markedKatex from 'marked-katex-extension'
import markedFootnote from 'marked-footnote'
import hljs from 'highlight.js'
import { markedHighlight } from 'marked-highlight'
import DOMPurify from 'dompurify'


/**
 * Markdown轉Html
 *
 * @param {String} md 輸入Markdown字串
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.tableBorderColor='#666'] 輸入表格邊框顏色 (CSS color)
 * @param {string[]} [opt.fontFamilies=['Microsoft JhengHei','Avenir','Helvetica','Arial','sans-serif']] 輸入內文字型優先順序列表
 * @param {String} [opt.fontSizeUnit='pt'] 輸入字型大小單位字串,可使用例如'pt'、'px'等,預設'pt'
 * @param {Number} [opt.fontSizeScale=1] 輸入字型大小縮放倍率數字,預設1
 * @param {Number|String} [opt.fontSizeDef] 輸入內文字型大小數字或字串,預設為12,自動轉字型為12*fontSizeScale+fontSizeUnit
 * @param {Number|String} [opt.fontSizeH1] 輸入h1字型大小數字或字串,預設為20,自動轉字型為20*fontSizeScale+fontSizeUnit
 * @param {Number|String} [opt.fontSizeH2] 輸入h2字型大小數字或字串,預設為16,自動轉字型為16*fontSizeScale+fontSizeUnit
 * @param {Number|String} [opt.fontSizeH3] 輸入h3字型大小數字或字串,預設為14,自動轉字型為14*fontSizeScale+fontSizeUnit
 * @param {Number|String} [opt.fontSizeH4] 輸入h4字型大小數字或字串,預設為12,自動轉字型為12*fontSizeScale+fontSizeUnit
 * @param {Number|String} [opt.fontSizeH5] 輸入h5字型大小數字或字串,預設為12,自動轉字型為12*fontSizeScale+fontSizeUnit
 * @param {Number|String} [opt.fontSizeH6] 輸入h6字型大小數字或字串,預設為12,自動轉字型為12*fontSizeScale+fontSizeUnit
 * @param {Number|String} [opt.fontSizeP] 輸入p字型大小數字或字串,預設為12,自動轉字型為12*fontSizeScale+fontSizeUnit
 * @param {Number|String} [opt.fontSizeTab] 輸入表格文字字型大小數字或字串,預設為11,自動轉字型為11*fontSizeScale+fontSizeUnit
 * @param {Number|String} [opt.fontSizeCode] 輸入程式碼區塊字型大小數字或字串,預設為10,自動轉字型為10*fontSizeScale+fontSizeUnit
 * @param {String} [opt.textAlignH1='left'] 輸入h1對齊方式,可使用'left'、'center'、'right',預設'left'
 * @param {String} [opt.textAlignH2='left'] 輸入h2對齊方式,可使用'left'、'center'、'right',預設'left'
 * @param {String} [opt.textAlignH3='left'] 輸入h3對齊方式,可使用'left'、'center'、'right',預設'left'
 * @param {String} [opt.textAlignH4='left'] 輸入h4對齊方式,可使用'left'、'center'、'right',預設'left'
 * @param {String} [opt.textAlignH5='left'] 輸入h5對齊方式,可使用'left'、'center'、'right',預設'left'
 * @param {String} [opt.textAlignH6='left'] 輸入h6對齊方式,可使用'left'、'center'、'right',預設'left'
 * @param {Number|String} [opt.imgWidthMax=null] 輸入圖片樣式給予最大寬度,可輸入數字500單位為px,或是字串例如'100%',預設null
 * @param {Function} [opt.funWalkTokens=null] 輸入marked轉換時的walkTokens函數,可執行進階處理,預設null
 * @param {Boolean} [opt.mergeStyle=false] 輸入是否將style放入html內布林值,預設false
 * @returns {Promise} 回傳Promise,resolve回傳轉換後物件,包含html、styleSrcs、styleDef,reject回傳錯誤訊息
 * @example
 *
 * let markdown = `...`
 * md2html(markdown,{mergeStyle:true})
 *     .then((res)=>{
 *         console.log(res)
 *         ele.innerHTML = res.html
 *     })
 *     .catch((err)=>{
 *         console.log(err)
 *     })
 *
 */
async function md2html(md, opt = {}) {

    //tableBorderColor
    let tableBorderColor = get(opt, 'tableBorderColor', '')
    if (!isestr(tableBorderColor)) {
        tableBorderColor = '#666'
    }

    //fontFamilies
    let fontFamilies = get(opt, 'fontFamilies', [])
    if (!isearr(fontFamilies)) {
        fontFamilies = ['Microsoft JhengHei', 'Avenir', 'Helvetica', 'Arial', 'sans-serif']
        // fontFamilies = ['Times New Roman', '標楷體'] //網頁使用, 因由左往右設定可不覆蓋無字元, 故可先設定Times New Roman再設定標楷體
    }
    fontFamilies = join(map(fontFamilies, (v) => {
        return `'${v}'`
    }), ', ')

    //fontSizeUnit
    let fontSizeUnit = get(opt, 'fontSizeUnit', '')
    if (!isestr(fontSizeUnit)) {
        fontSizeUnit = 'pt'
    }

    //fontSizeScale
    let fontSizeScale = get(opt, 'fontSizeScale', '')
    if (!isnum(fontSizeScale)) {
        fontSizeScale = 1
    }
    fontSizeScale = cdbl(fontSizeScale)

    //fontSizeDef
    let fontSizeDef = get(opt, 'fontSizeDef', '')
    if (!isestr(fontSizeDef)) {
        fontSizeDef = 12 * fontSizeScale + fontSizeUnit
    }

    //fontSizeH1
    let fontSizeH1 = get(opt, 'fontSizeH1', '')
    if (!isestr(fontSizeH1)) {
        fontSizeH1 = 20 * fontSizeScale + fontSizeUnit
    }

    //fontSizeH2
    let fontSizeH2 = get(opt, 'fontSizeH2', '')
    if (!isestr(fontSizeH2)) {
        fontSizeH2 = 16 * fontSizeScale + fontSizeUnit
    }

    //fontSizeH3
    let fontSizeH3 = get(opt, 'fontSizeH3', '')
    if (!isestr(fontSizeH3)) {
        fontSizeH3 = 14 * fontSizeScale + fontSizeUnit
    }

    //fontSizeH4
    let fontSizeH4 = get(opt, 'fontSizeH4', '')
    if (!isestr(fontSizeH4)) {
        fontSizeH4 = 12 * fontSizeScale + fontSizeUnit
    }

    //fontSizeH5
    let fontSizeH5 = get(opt, 'fontSizeH5', '')
    if (!isestr(fontSizeH5)) {
        fontSizeH5 = 12 * fontSizeScale + fontSizeUnit
    }

    //fontSizeH6
    let fontSizeH6 = get(opt, 'fontSizeH6', '')
    if (!isestr(fontSizeH6)) {
        fontSizeH6 = 12 * fontSizeScale + fontSizeUnit
    }

    //fontSizeP
    let fontSizeP = get(opt, 'fontSizeP', '')
    if (!isestr(fontSizeP)) {
        fontSizeP = 12 * fontSizeScale + fontSizeUnit
    }

    //fontSizeTab
    let fontSizeTab = get(opt, 'fontSizeTab', '')
    if (!isestr(fontSizeTab)) {
        fontSizeTab = 11 * fontSizeScale + fontSizeUnit
    }

    //fontSizeCode
    let fontSizeCode = get(opt, 'fontSizeCode', '')
    if (!isestr(fontSizeCode)) {
        fontSizeCode = 10 * fontSizeScale + fontSizeUnit
    }

    //textAlignH1
    let textAlignH1 = get(opt, 'textAlignH1', '')
    if (!isestr(textAlignH1)) {
        textAlignH1 = 'left'
    }

    //textAlignH2
    let textAlignH2 = get(opt, 'textAlignH2', '')
    if (!isestr(textAlignH2)) {
        textAlignH2 = 'left'
    }

    //textAlignH3
    let textAlignH3 = get(opt, 'textAlignH3', '')
    if (!isestr(textAlignH3)) {
        textAlignH3 = 'left'
    }

    //textAlignH4
    let textAlignH4 = get(opt, 'textAlignH4', '')
    if (!isestr(textAlignH4)) {
        textAlignH4 = 'left'
    }

    //textAlignH5
    let textAlignH5 = get(opt, 'textAlignH5', '')
    if (!isestr(textAlignH5)) {
        textAlignH5 = 'left'
    }

    //textAlignH6
    let textAlignH6 = get(opt, 'textAlignH6', '')
    if (!isestr(textAlignH6)) {
        textAlignH6 = 'left'
    }

    //imgWidthMax
    let imgWidthMax = get(opt, 'imgWidthMax', null)
    if (isnum(imgWidthMax)) {
        imgWidthMax = `${imgWidthMax}px`
    }
    if (!isestr(imgWidthMax)) {
        imgWidthMax = ''
    }

    //funWalkTokens
    let funWalkTokens = get(opt, 'funWalkTokens', null)

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

    //marked
    let marked = new Marked()

    //擴充KaTeX
    marked.use(markedKatex({
        throwOnError: false, //不要遇錯就丟例外
        nonStandard: true, //允許單行公式$...$可支援沒有空白格式
    }))

    //擴充註腳Footnote
    marked.use(markedFootnote())

    //擴充highlight
    marked.use(markedHighlight({
        langPrefix: 'hljs language-', //指定給code用的class為'hljs language-js'
        emptyLangClass: 'hljs', //沒指定語言時的class
        highlight(code, lang) {
            let language = lang && hljs.getLanguage(lang) ? lang : 'plaintext'
            return hljs.highlight(code, { language }).value
        },
    }))

    //renderer
    let renderer = {
        image: ({ href, title, text }) => {
            // console.log('href', href, 'title', title, 'text', text)

            //attrTitle
            let attrTitle = isestr(title) ? `title="${title}"` : ''

            //st
            let st = ''
            if (isestr(imgWidthMax)) {
                st = `max-width:${imgWidthMax}; height:auto;`
            }

            //h
            let h = `<img src="${href}" alt="${text}" ${attrTitle} style="${st}">`

            return h
        },
    }

    //optMd
    let optMd = {
        async: true,
        renderer, //無法使用async
    }
    if (isfun(funWalkTokens)) {
        optMd.walkTokens = funWalkTokens //可用async, 圖片轉成base64時因svg轉換須async, 故只能通過walkTokens攔截進行轉換
    }

    //其他擴充
    marked.use(optMd)

    //h
    let h = await marked.parse(md)
    // console.log('h', h)

    //封裝h
    h = `
        <div class="md" style="contain:layout;">
            ${h}
        </div>
    `

    //styleSrcs
    let styleSrcs = [
        `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css`, //highlight
        `https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css`, //KaTeX
    ]

    //styleDef
    let styleDef = `

.md {
    font-family: {fontFamilies};
    font-size: {fontSizeDef};
}

.md table {
    border-collapse: collapse;
    margin: 0;
    padding: 0;
    word-break: initial;
    font-family: {fontFamilies};
    font-size: {fontSizeTab};
}

.md tr {
    margin: 0;
    padding: 0;
}

.md table tr th {
    font-weight: bold;
    background: #eee;
    border: 1px solid {tableBorderColor};
    border-bottom: 0;
    margin: 0;
    padding: 6px 13px;
}

.md table tr td {
    border: 1px solid {tableBorderColor};
    margin: 0;
    padding: 6px 13px;
}

.md h1 {
    display: block;
    font-size: {fontSizeH1};
    margin: 1rem 0;
    padding: 0;
    font-weight: bold;
    text-align: {textAlignH1};
}

.md h2 {
    display: block;
    font-size: {fontSizeH2};
    margin: 1rem 0;
    padding: 0;
    font-weight: bold;
    text-align: {textAlignH2};
}

.md h3 {
    display: block;
    font-size: {fontSizeH3};
    margin: 1rem 0;
    padding: 0;
    font-weight: bold;
    text-align: {textAlignH3};
}

.md h4 {
    display: block;
    font-size: {fontSizeH4};
    margin: 1rem 0;
    padding: 0;
    font-weight: bold;
    text-align: {textAlignH4};
}

.md h5 {
    display: block;
    font-size: {fontSizeH5};
    margin: 1rem 0;
    padding: 0;
    font-weight: bold;
    text-align: {textAlignH5};
}

.md h6 {
    display: block;
    font-size: {fontSizeH6};
    margin: 1rem 0;
    padding: 0;
    font-weight: bold;
    text-align: {textAlignH6};
}

.md p {
    display: block;
    font-size: {fontSizeP};
    margin: 1rem 0;
    padding: 0;
} 

.md ol {
    display: block;
    list-style-type: decimal;
    margin: 1rem 0;
    padding: 0;
    padding-inline-start: 40px;
} 

.md li {
    display: list-item;
    /* margin: 0.4rem 0; */ /* 須個別元素用style給予才能用於docx */
    padding: 0;
}

.md blockquote {
    font-style: italic;
    border-left: 5px solid #debc76;
    background: #f9f2e3;
    margin: 1rem 0;
    padding: 0.1rem 1.0rem 0.1rem 1.0rem;
}

.md pre {
    display: block;
    font-size: {fontSizeCode};
    overflow: auto;
    background: #eee;
    margin: 1rem 0;
    padding: 0;
}
.md pre code.hljs {
    background: transparent !important;
    line-height: 1.6 !important;
}

.md code {
    font-family: Consolas, "Courier New", monospace;
}

.md a {
    color: #0066cc;
    cursor: pointer;
}

.md a[data-footnote-ref]::before {
    content: "[";
}
.md a[data-footnote-ref]::after {
    content: "]";
}
.md sup:has(a[data-footnote-ref]) {
    position: relative;
    vertical-align: baseline; /* 不要推高行距 */
    top: -0.5em;              /* 往上移動看起來像上標 */
    line-height: 0;           /* 避免影響行距 */
}

.md hr {
    margin: 2rem 0;
    padding: 0;
}

.md section {
    margin: 0;
    padding: 0;
}

    `

    //replace
    styleDef = replace(styleDef, '{fontFamilies}', fontFamilies)
    styleDef = replace(styleDef, '{fontSizeDef}', fontSizeDef)
    styleDef = replace(styleDef, '{fontSizeH1}', fontSizeH1)
    styleDef = replace(styleDef, '{fontSizeH2}', fontSizeH2)
    styleDef = replace(styleDef, '{fontSizeH3}', fontSizeH3)
    styleDef = replace(styleDef, '{fontSizeH4}', fontSizeH4)
    styleDef = replace(styleDef, '{fontSizeH5}', fontSizeH5)
    styleDef = replace(styleDef, '{fontSizeH6}', fontSizeH6)
    styleDef = replace(styleDef, '{fontSizeP}', fontSizeP)
    styleDef = replace(styleDef, '{fontSizeTab}', fontSizeTab)
    styleDef = replace(styleDef, '{fontSizeCode}', fontSizeCode)
    styleDef = replace(styleDef, '{textAlignH1}', textAlignH1)
    styleDef = replace(styleDef, '{textAlignH2}', textAlignH2)
    styleDef = replace(styleDef, '{textAlignH3}', textAlignH3)
    styleDef = replace(styleDef, '{textAlignH4}', textAlignH4)
    styleDef = replace(styleDef, '{textAlignH5}', textAlignH5)
    styleDef = replace(styleDef, '{textAlignH6}', textAlignH6)
    styleDef = replace(styleDef, '{tableBorderColor}', tableBorderColor)

    //dp
    let dp = null
    if (isWindow()) {
        dp = DOMPurify
    }
    else {
        let clib = 'jsdom'
        let { JSDOM } = await import(clib)
        let { window } = new JSDOM('')
        dp = DOMPurify(window)
    }

    //sanitize
    h = dp.sanitize(h)

    //r
    let r = {}
    if (mergeStyle) {
        let _h = `\n`
        each(styleSrcs, (v) => {
            let t = `<link rel="stylesheet" href="${v}">\n`
            _h += t
        })
        _h += `<style>${styleDef}</style>\n`
        _h += h
        r = {
            html: _h,
            styleSrcs: [],
            styleDef: '',
        }
    }
    else {
        r = {
            html: h,
            styleSrcs,
            styleDef,
        }
    }

    return r
}


export default md2html