docxToc.mjs

import get from 'lodash-es/get.js'
import { unzipSync, strFromU8 } from 'fflate'
import isobj from 'wsemi/src/isobj.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import cint from 'wsemi/src/cint.mjs'
import { unesc, esc, escRe, textOf, tElem, pPrOf, scanParas, createEdits, writeDocx } from './docxXml.mjs'
import { labelsDef, normLabels, hasObj, findCaptions, warnCapsLike, addKeepLines, addKeepNext, planKeepNext } from './docxKeep.mjs'


//目錄之預設設定(中文報告)
let tocDef = {
    labels: { ...labelsDef }, //圖名、表名之標籤(段首「圖N」「表N」; 亦為 SEQ 欄位之識別名稱)
    titles: { toc: '目錄', fig: '圖目錄', tab: '表目錄' }, //三目錄之標題
    frontHeadings: ['摘要', 'ABSTRACT', 'Abstract'], //位於封面與目錄之間之前置章節(文件開頭之標題文字與之相同者)
    maxLevels: 3, //目錄列出之標題層數(自正文最高之大綱階層起算)
    titleStyle: 'TPC11報告目錄(標題)', //三目錄標題之段落樣式名稱(本套件所附 temp_tpc.docx 之樣式), 模板無此樣式時另建置中粗體之樣式
    headStyles: ['章標題', '節標題', '小節標題'], //承接標題粗體之段落樣式名稱(依層)
    pageNumbers: true, //是否分節: 封面無頁碼、前置章節與三目錄之頁碼為大寫羅馬數字(I 起)、正文之頁碼自 1 起
    keepLines: true, //目錄來源段落(列入目錄之標題、圖名、表名)設為段落內不分頁, 使目錄之頁碼即該段落所在之頁(多行之表名跨頁時, 首行留在前頁而表格在次頁, 表目錄之頁碼會指向前頁)
    keepWithObject: true, //圖名、表名與其圖、表同頁(與 keepCaption 同一規則, 見 docxKeep.mjs 之 planKeepNext): 與下段之表格或圖片、或上段之圖片同頁, 上下皆有圖表時依同類圖名表名之多數位置, 平手時圖名取上段、表名取下段(圖在頁底而圖名被擠到次頁時, 圖目錄之頁碼會指向次頁)
    replaceTocStyles: true, //是否以本套件之目錄項目樣式取代模板既有之同名樣式(toc N、table of figures)
}


/**
 * 整理添加目錄之設定
 *
 * 各設定非有效值時採預設:labels見docxKeep.mjs之normLabels(fig與tab須為不含空白、引號與反斜線之非空字串,否則該項退回預設,兩者相同時皆退回預設);titles之各值須為非空字串;frontHeadings須為陣列(僅保留非空字串);maxLevels須為正整數,上限9;titleStyle須為非空字串;headStyles須為非空字串組成之非空陣列;pageNumbers、keepLines、keepWithObject、replaceTocStyles須為布林值。
 *
 * @param {Object|Boolean} [opt={}] 輸入設定物件,true或非物件時皆用預設,預設{}
 * @returns {Object} 回傳設定物件{labels,titles,frontHeadings,maxLevels,titleStyle,titleStyleGiven,headStyles,pageNumbers,keepLines,keepWithObject,replaceTocStyles},titleStyleGiven表示titleStyle是否由呼叫端指定
 */
function normTocOpt(opt = {}) {

    //opt
    if (!isobj(opt)) {
        opt = {}
    }

    //labels, 須為單一字詞(SEQ 識別名稱不可含空白; 引號與反斜線會截斷 TOC 欄位之 \c 參數), 兩者相同時無法區分圖名與表名
    let labels = normLabels(get(opt, 'labels', {}))

    //titles
    let titles = {}
    for (let k of ['toc', 'fig', 'tab']) {
        let v = get(opt, `titles.${k}`, '')
        titles[k] = isestr(v) ? v : tocDef.titles[k]
    }

    //frontHeadings
    let frontHeadings = get(opt, 'frontHeadings', null)
    if (isarr(frontHeadings)) {
        frontHeadings = frontHeadings.filter(isestr)
    }
    else {
        frontHeadings = [...tocDef.frontHeadings]
    }

    //maxLevels
    let maxLevels = get(opt, 'maxLevels', null)
    if (ispint(maxLevels)) {
        maxLevels = Math.min(cint(maxLevels), 9)
    }
    else {
        maxLevels = tocDef.maxLevels
    }

    //titleStyle
    let titleStyle = get(opt, 'titleStyle', '')
    let titleStyleGiven = isestr(titleStyle)
    if (!titleStyleGiven) {
        titleStyle = tocDef.titleStyle
    }

    //headStyles
    let headStyles = get(opt, 'headStyles', null)
    if (!isarr(headStyles) || headStyles.length === 0 || !headStyles.every(isestr)) {
        headStyles = [...tocDef.headStyles]
    }

    //pageNumbers, keepLines, keepWithObject, replaceTocStyles
    let bols = {}
    for (let k of ['pageNumbers', 'keepLines', 'keepWithObject', 'replaceTocStyles']) {
        let v = get(opt, k, null)
        bols[k] = isbol(v) ? v : tocDef[k]
    }

    return {
        labels,
        titles,
        frontHeadings,
        maxLevels,
        titleStyle,
        titleStyleGiven,
        headStyles,
        pageNumbers: bols.pageNumbers,
        keepLines: bols.keepLines,
        keepWithObject: bols.keepWithObject,
        replaceTocStyles: bols.replaceTocStyles,
    }
}


//styleInfo: 樣式表之查詢(依名稱取編號、段落樣式之大綱階層含 basedOn 鏈、預設段落樣式)
function styleInfo(sty) {
    let list = []
    for (let m of sty.matchAll(/<w:style\b([^>]*)>([\s\S]*?)<\/w:style>/g)) {
        let at = m[1]
        let body = m[2]
        list.push({
            id: (at.match(/w:styleId="([^"]+)"/) || [])[1],
            type: (at.match(/w:type="([^"]+)"/) || [])[1],
            isDef: /w:default="(1|true)"/.test(at),
            name: unesc((body.match(/<w:name w:val="([^"]+)"\/>/) || [])[1] || ''),
            ol: (body.match(/<w:outlineLvl w:val="(\d)"\/>/) || [])[1],
            based: (body.match(/<w:basedOn w:val="([^"]+)"\/>/) || [])[1],
        })
    }
    let byId = {}
    for (let s of list) {
        byId[s.id] = s
    }
    let olOf = (id, n = 0) => {
        let s = byId[id]
        if (!s || n > 20) {
            return undefined
        }
        return s.ol !== undefined ? s.ol : olOf(s.based, n + 1)
    }
    let idOf = (name) => (list.find((s) => s.type === 'paragraph' && s.name === name) || {}).id
    let defId = (list.find((s) => s.type === 'paragraph' && s.isDef) || {}).id || ''
    return { olOf, idOf, defId }
}


//toSeq: 段落文字中 [s, e) 之編號改為 SEQ 欄位, 顯示之編號不變(編號所在之文字段須只含文字)
function toSeq(p, s, e, ident) {
    let runs = []
    let re = /<w:r\b[^>]*>([\s\S]*?)<\/w:r>/g
    let m
    let off = 0
    while ((m = re.exec(p)) !== null) {
        let t = textOf(m[0])
        runs.push({ i0: m.index, i1: m.index + m[0].length, inner: m[1], t, a: off, b: off + t.length })
        off += t.length
    }
    let hit = runs.filter((r) => r.b > s && r.a < e)
    if (hit.length === 0) {
        throw new Error(`the caption number is not inside a text run: ${textOf(p).slice(0, 30)}`)
    }
    for (let r of hit) {
        //lastRenderedPageBreak 為 Word 記錄上次分頁位置之提示, 存檔時重算, 可略去
        if (!/^(<w:rPr>[\s\S]*?<\/w:rPr>)?(<w:t(?: xml:space="preserve")?>[^<]*<\/w:t>)+$/.test(r.inner.replace(/<w:lastRenderedPageBreak\/>/g, ''))) {
            throw new Error(`the text run of the caption number contains elements other than text: ${textOf(p).slice(0, 30)}`)
        }
    }
    let r0 = hit[0]
    let r1 = hit[hit.length - 1]
    let rPr0 = (r0.inner.match(/^<w:rPr>[\s\S]*?<\/w:rPr>/) || [''])[0]
    let rPr1 = (r1.inner.match(/^<w:rPr>[\s\S]*?<\/w:rPr>/) || [''])[0]
    let run = (rPr, inner) => `<w:r>${rPr}${inner}</w:r>`
    let all = runs.map((r) => r.t).join('')
    let pre = r0.t.slice(0, s - r0.a)
    let post = r1.t.slice(e - r1.a)
    let x = ''
    if (pre) {
        x += run(rPr0, tElem(pre))
    }
    x += run(rPr0, '<w:fldChar w:fldCharType="begin"/>')
    x += run(rPr0, `<w:instrText xml:space="preserve"> SEQ ${esc(ident)} \\* ARABIC </w:instrText>`)
    x += run(rPr0, '<w:fldChar w:fldCharType="separate"/>')
    x += run(rPr0, tElem(all.slice(s, e)))
    x += run(rPr0, '<w:fldChar w:fldCharType="end"/>')
    if (post) {
        x += run(rPr1, tElem(post))
    }
    return p.slice(0, r0.i0) + x + p.slice(r1.i1)
}


//moveBold: 標題段落之粗體改由段落樣式提供(Word 產生目錄時會把標題文字之直接格式帶進目錄項目, 改由樣式提供後目錄項目不帶粗體, 標題外觀不變)
function moveBold(p, styleId) {
    if (/^<w:p\b[^>]*>\s*<w:pPr>/.test(p)) {
        p = p.replace(/^(<w:p\b[^>]*>\s*)<w:pPr>/, `$1<w:pPr><w:pStyle w:val="${styleId}"/>`)
    }
    else {
        p = p.replace(/^(<w:p\b[^>]*>)/, `$1<w:pPr><w:pStyle w:val="${styleId}"/></w:pPr>`)
    }
    let reOn = /<w:b\/>|<w:b w:val="(?:1|true|on)"\/>/
    let off = '<w:b w:val="0"/><w:bCs w:val="0"/>'
    return p.replace(/<w:r\b[^>]*>[\s\S]*?<\/w:r>/g, (r) => {
        if (!/<w:t[ >]/.test(r)) {
            return r
        }
        if (reOn.test(r)) {
            return r.replace(/<w:b\/>|<w:b w:val="(?:1|true|on)"\/>/g, '').replace(/<w:bCs\/>|<w:bCs w:val="(?:1|true|on)"\/>/g, '')
        }
        if (/<w:b w:val="(?:0|false|off)"\/>/.test(r)) {
            return r
        }
        //原非粗體之文字段明確設為不粗體, 維持原外觀(rPr 子元素順序: rStyle, rFonts, b, bCs, …)
        if (/<w:rPr>/.test(r)) {
            if (/<w:rFonts\b[^>]*\/>/.test(r)) {
                return r.replace(/(<w:rFonts\b[^>]*\/>)/, `$1${off}`)
            }
            if (/<w:rStyle\b[^>]*\/>/.test(r)) {
                return r.replace(/(<w:rStyle\b[^>]*\/>)/, `$1${off}`)
            }
            return r.replace('<w:rPr>', `<w:rPr>${off}`)
        }
        return r.replace(/^(<w:r\b[^>]*>)/, `$1<w:rPr>${off}</w:rPr>`)
    })
}


//dropLeadBreak: 移除段落開頭之分頁(段落前分頁、開頭只含分頁符號之文字段); 新的一節已自新頁開始, 留著會多出空白頁
function dropLeadBreak(p) {
    let p2 = p.replace(/<w:pageBreakBefore\/>/, '')
    p2 = p2.replace(/^(<w:p\b[^>]*>\s*(?:<w:pPr>[\s\S]*?<\/w:pPr>)?)<w:r\b[^>]*>(?:<w:rPr>[\s\S]*?<\/w:rPr>)?<w:br w:type="page"\/><\/w:r>/, '$1')
    return p2
}


/**
 * 準備添加目錄之docx內容(純結構處理, 不含Word)
 *
 * 處理內容:圖名與表名之編號改為SEQ欄位、標題粗體改由段落樣式提供、目錄來源段落(列入目錄之標題、圖名、表名)設為段落內不分頁(keepLines)、圖名表名與其圖表同頁(keepWithObject)、於正文第1個標題前插入目錄與圖目錄、表目錄之欄位(無圖名或表名時不列該目錄)、分節與頁碼、目錄項目之樣式。目錄之內容與頁碼須再交Word更新。
 *
 * 分節(pageNumbers為true):第1個標題前有內容(含文字或圖片之段落、表格)者視為有封面,分為封面(無頁碼)、前置章節與目錄(大寫羅馬數字自I起)、正文(自1起)共3節;無封面者不建封面節,分為2節,以免多出空白首頁。
 *
 * 圖名、表名以段首「標籤+編號+空白」判斷;段首為標籤與編號但其後非空白者(如「圖1:名稱」)不視為圖名表名,以warns提示筆數與前3例。目錄項目之字型沿用正文第1個標題之文字段字型,查無則沿用模板預設。
 *
 * 前提不符時拋錯(不產出錯誤之目錄):含文字方塊、已有目錄欄位、分節不只1個(pageNumbers為true時)、圖名或表名之編號未依文件順序自1連續、編號所在之文字段含文字以外之元素、headStyles指定之既有樣式非粗體。
 *
 * @param {Uint8Array} u8 輸入docx檔之內容
 * @param {Object|Boolean} [opt={}] 輸入設定物件,見normTocOpt
 * @returns {Object} 回傳{u8,info},u8為處理後之docx內容,info為{skip,cover,levels,front,expect,labels,lists,bookmark,pageNumbers,warns};無標題時info僅含skip(原因字串)且u8為原內容
 */
function prepDocxToc(u8, opt = {}) {
    let o = normTocOpt(opt)
    let z = unzipSync(u8)
    if (!z['word/document.xml'] || !z['word/styles.xml']) {
        throw new Error('the docx lacks word/document.xml or word/styles.xml')
    }
    let doc = strFromU8(z['word/document.xml'])
    let sty = strFromU8(z['word/styles.xml'])
    let warns = []

    //前提
    if (/<w:txbxContent/.test(doc)) {
        throw new Error('text boxes are not supported')
    }
    if (/<w:instrText[^>]*>\s*TOC\b/.test(doc) || /<w:fldSimple w:instr="\s*TOC\b/.test(doc)) {
        throw new Error('the document already has a table of contents field, the TOC must be added to a docx just converted')
    }
    let nSect = (doc.match(/<w:sectPr\b/g) || []).length
    if (o.pageNumbers && nSect !== 1) {
        throw new Error(`the document has ${nSect} sections, renumbering pages requires a document of 1 section (set pageNumbers to false to keep the sections)`)
    }

    let si = styleInfo(sty)
    let paras = scanParas(doc)
    let tops = paras.filter((p) => !p.inTbl)
    let levelOf = (p) => {
        let pPr = pPrOf(p)
        let m = pPr.match(/<w:outlineLvl w:val="(\d)"\/>/)
        if (m) {
            return Number(m[1])
        }
        let ps = pPr.match(/<w:pStyle w:val="([^"]+)"\/>/)
        if (ps) {
            let ol = si.olOf(ps[1])
            if (ol !== undefined) {
                return Number(ol)
            }
        }
        return 9
    }
    let heads = tops.map((p) => ({ ...p, lv: levelOf(p.xml), text: textOf(p.xml).trim() })).filter((p) => p.lv < 9 && p.text !== '')
    if (heads.length === 0) {
        return { u8, info: { skip: 'no heading (paragraph with an outline level) is found, the TOC is not added' } }
    }
    let iFront = 0
    while (iFront < heads.length && o.frontHeadings.includes(heads[iFront].text)) {
        iFront++
    }
    let front = heads.slice(0, iFront)
    let body = heads.slice(iFront)
    if (body.length === 0) {
        return { u8, info: { skip: 'only front headings are found, the TOC is not added' } }
    }
    let h0 = heads[0]
    let bodyFirst = body[0]
    let l0 = Math.min(...body.map((h) => h.lv))
    let l1 = Math.min(Math.max(...body.map((h) => h.lv)), l0 + o.maxLevels - 1)
    let headsIn = body.filter((h) => h.lv <= l1)

    //hasCover: 第 1 個標題之前有內容(含文字或圖片之段落、表格)才視為有封面; 無封面時不建封面節, 免得多出空白首頁
    let hasCover = /<w:tbl\b/.test(doc.slice(0, h0.start)) || tops.some((p) => p.end <= h0.start && (textOf(p.xml).trim() !== '' || hasObj(p)))

    //圖名與表名(辨識規則與 keepCaption 共用, 見 docxKeep.mjs), 編號須依文件順序自 1 連續
    let { caps, capsLike } = findCaptions(tops, o.labels)
    if (capsLike.length > 0) {
        warns.push(warnCapsLike(capsLike))
    }
    for (let kind of ['fig', 'tab']) {
        let nums = caps.filter((c) => c.kind === kind).map((c) => c.num)
        if (!nums.every((v, i) => v === i + 1)) {
            throw new Error(`the numbers of the captions labeled ${o.labels[kind]} are not consecutive from 1 in document order (${nums.slice(0, 10).join(',')}...), Word would renumber them after they become SEQ fields`)
        }
    }
    let capsBody = caps.filter((c) => c.start > bodyFirst.start)
    let nFig = capsBody.filter((c) => c.kind === 'fig').length
    let nTab = capsBody.filter((c) => c.kind === 'tab').length

    //樣式: 標題粗體之承接樣式(依層)
    let idHead = {}
    for (let lv = l0; lv <= l1; lv++) {
        let name = o.headStyles[Math.min(lv - l0, o.headStyles.length - 1)]
        let id = si.idOf(name)
        if (id) {
            //既有同名樣式須為粗體, 否則標題移除直接粗體後外觀會改變
            let mSty = sty.match(new RegExp(`<w:style\\b[^>]*w:styleId="${escRe(id)}"[^>]*>[\\s\\S]*?</w:style>`))
            if (!mSty || !/<w:b\/>|<w:b w:val="(?:1|true|on)"\/>/.test(mSty[0])) {
                throw new Error(`the template has a style named "${name}" that is not bold, please specify another name with headStyles`)
            }
        }
        else {
            id = `WMdTocHead${lv - l0 + 1}`
            sty = sty.replace('</w:styles>', `<w:style w:type="paragraph" w:customStyle="1" w:styleId="${id}"><w:name w:val="${esc(name)}"/>${si.defId ? `<w:basedOn w:val="${si.defId}"/><w:next w:val="${si.defId}"/>` : ''}<w:qFormat/><w:rPr><w:b/><w:bCs/></w:rPr></w:style></w:styles>`)
        }
        idHead[lv] = id
    }

    //各段落之改寫(以段落起點為鍵): 改寫函數依登錄順序套用, addPre 之內容依序插於該段落之前
    let { addEdit, addPre, apply } = createEdits()
    for (let c of caps) {
        addEdit(c, (x) => toSeq(x, c.s, c.e, o.labels[c.kind]))
    }
    for (let h of headsIn) {
        if (/<w:pStyle /.test(pPrOf(h.xml))) {
            warns.push(`the heading already has a paragraph style, its TOC entry may carry its direct formatting: ${h.text}`)
            continue
        }
        addEdit(h, (x) => moveBold(x, idHead[h.lv]))
    }
    if (o.keepLines) {
        for (let p of [...headsIn, ...caps]) {
            addEdit(p, addKeepLines)
        }
    }
    if (o.keepWithObject) {
        //圖名表名與其圖表同頁(相鄰判斷與 keepCaption 共用, 見 docxKeep.mjs 之 planKeepNext)
        for (let p of planKeepNext(doc, paras, caps)) {
            addEdit(p, addKeepNext)
        }
    }

    //三目錄區塊
    let idTitle = si.idOf(o.titleStyle)
    if (!idTitle) {
        idTitle = 'WMdTocTitle'
        sty = sty.replace('</w:styles>', `<w:style w:type="paragraph" w:customStyle="1" w:styleId="${idTitle}"><w:name w:val="${esc(o.titleStyle)}"/>${si.defId ? `<w:basedOn w:val="${si.defId}"/>` : ''}<w:qFormat/><w:pPr><w:jc w:val="center"/></w:pPr><w:rPr><w:b/><w:bCs/><w:sz w:val="32"/><w:szCs w:val="32"/></w:rPr></w:style></w:styles>`)
        if (o.titleStyleGiven) {
            //使用預設名稱(本套件所附模板之樣式)而模板無此樣式屬常態, 僅於呼叫端指定時提示
            warns.push(`the template has no paragraph style named "${o.titleStyle}", a centered bold style is created for the TOC titles`)
        }
    }
    let bookmark = ''
    let bmId = 0
    if (front.length > 0) {
        //書籤名不以 _Toc 開頭(Word 更新目錄時會自行管理 _Toc 開頭之書籤)
        bookmark = '_WMdTocBody'
        let ids = [...doc.matchAll(/<w:bookmarkStart\b[^>]*w:id="(\d+)"/g)].map((m) => Number(m[1]))
        bmId = ids.length ? Math.max(...ids) + 1 : 0
    }
    let swB = bookmark ? ` \\b ${bookmark}` : ''
    let title = (t, pb) => `<w:p><w:pPr><w:pStyle w:val="${idTitle}"/>${pb ? '<w:pageBreakBefore/>' : ''}<w:spacing w:after="240"/></w:pPr><w:r>${tElem(t)}</w:r></w:p>`
    let field = (instr) => `<w:p><w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r><w:r><w:instrText xml:space="preserve"> ${esc(instr)} </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>Update field to build the table</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>`
    let lists = ['toc']
    //分節: 封面節(未指定頁尾即無頁尾, 無封面者不建)、前置章節與三目錄節(沿用原頁尾, 大寫羅馬數字自 I 起)、正文節(頁尾沿用前節, 自 1 起)
    let pageNumbers = o.pageNumbers
    //目錄標題之段落前分頁: 前有前置章節時須換頁; 不分節而有封面時亦須換頁; 分節時新的一節已自新頁開始
    let firstPb = front.length > 0 || (!pageNumbers && hasCover)
    let block = title(o.titles.toc, firstPb) + field(`TOC \\o "${l0 + 1}-${l1 + 1}" \\h \\z \\u${swB}`)
    if (nFig > 0) {
        block += title(o.titles.fig, true) + field(`TOC \\h \\z \\c "${o.labels.fig}"${swB}`)
        lists.push('fig')
    }
    if (nTab > 0) {
        block += title(o.titles.tab, true) + field(`TOC \\h \\z \\c "${o.labels.tab}"${swB}`)
        lists.push('tab')
    }
    let sectFinalNew = ''
    let iSect = -1
    let sectFinalOld = ''
    if (pageNumbers) {
        let mSect = doc.match(/<w:sectPr\b[^>]*>([\s\S]*?)<\/w:sectPr>(?=\s*<\/w:body>)/)
        if (!mSect) {
            throw new Error('the final section properties of the document are not found')
        }
        iSect = doc.lastIndexOf(mSect[0])
        sectFinalOld = mSect[0]
        let inner = mSect[1]
        let part = (tag) => (inner.match(new RegExp(`<w:${tag}\\b[^>]*/>|<w:${tag}\\b[^>]*>[\\s\\S]*?</w:${tag}>`)) || [''])[0]
        let refs = (inner.match(/<w:headerReference\b[^>]*\/>/g) || []).join('') + (inner.match(/<w:footerReference\b[^>]*\/>/g) || []).join('')
        if (!/<w:footerReference\b/.test(refs)) {
            warns.push('the document has no footer, so the page numbers are not shown')
        }
        //sectPr 子元素順序: headerReference, footerReference, …, pgSz, pgMar, …, pgNumType, cols, …, docGrid
        let sect1 = `<w:sectPr>${part('pgSz')}${part('pgMar')}${part('cols')}${part('docGrid')}</w:sectPr>`
        let sect2 = `<w:sectPr>${refs}${part('pgSz')}${part('pgMar')}<w:pgNumType w:fmt="upperRoman" w:start="1"/>${part('cols')}${part('docGrid')}</w:sectPr>`
        let inner3 = inner.replace(/<w:headerReference\b[^>]*\/>|<w:footerReference\b[^>]*\/>/g, '')
        if (/<w:pgNumType\b/.test(inner3)) {
            inner3 = inner3.replace(/<w:pgNumType\b([^>]*?)\/>/, (x, a) => `<w:pgNumType${/w:start=/.test(a) ? a.replace(/w:start="\d+"/, 'w:start="1"') : `${a} w:start="1"`}/>`)
        }
        else {
            inner3 = inner3.replace(/(<w:pgMar\b[^>]*\/>)/, '$1<w:pgNumType w:start="1"/>')
        }
        sectFinalNew = sectFinalOld.replace(inner, inner3)
        //目錄節之結束段落: 行高與字級 1 點, 免得最後一頁恰好滿頁時被擠到下一頁成為空白頁
        block += `<w:p><w:pPr><w:snapToGrid w:val="0"/><w:spacing w:before="0" w:after="0" w:line="20" w:lineRule="exact"/><w:rPr><w:sz w:val="2"/><w:szCs w:val="2"/></w:rPr>${sect2}</w:pPr></w:p>`
        if (hasCover) {
            //封面之結束: 第 1 個標題前之段落帶封面節之分節設定(不另加段落, 免得封面滿頁時多出空白頁); 其前不是段落時才另加 1 點高之段落
            let prev = tops.filter((p) => p.end <= h0.start).pop()
            if (prev && doc.slice(prev.end, h0.start).trim() === '') {
                addEdit(prev, (x) => (/^<w:p\b[^>]*\/>$/.test(x) ? x.replace(/\/>$/, `><w:pPr>${sect1}</w:pPr></w:p>`) : /^<w:p\b[^>]*>\s*<w:pPr>/.test(x) ? x.replace('</w:pPr>', `${sect1}</w:pPr>`) : x.replace(/^(<w:p\b[^>]*>)/, `$1<w:pPr>${sect1}</w:pPr>`)))
            }
            else {
                addPre(h0, `<w:p><w:pPr><w:snapToGrid w:val="0"/><w:spacing w:before="0" w:after="0" w:line="20" w:lineRule="exact"/><w:rPr><w:sz w:val="2"/><w:szCs w:val="2"/></w:rPr>${sect1}</w:pPr></w:p>`)
            }
        }
        //各節之第 1 個標題移除開頭之分頁
        addEdit(h0, dropLeadBreak)
        if (front.length > 0) {
            addEdit(bodyFirst, dropLeadBreak)
        }
    }
    //正文第 1 個標題之前插入三目錄(有前置章節時另以書籤限定目錄只收正文)
    addPre(bodyFirst, block + (bookmark ? `<w:bookmarkStart w:id="${bmId}" w:name="${bookmark}"/>` : ''))

    //套用(由後往前, 位置不受前段改寫影響)
    if (bookmark) {
        let iEnd = iSect >= 0 ? iSect : doc.lastIndexOf('<w:sectPr')
        doc = doc.slice(0, iEnd) + `<w:bookmarkEnd w:id="${bmId}"/>` + doc.slice(iEnd)
    }
    if (pageNumbers) {
        let iS = doc.lastIndexOf(sectFinalOld)
        doc = doc.slice(0, iS) + sectFinalNew + doc.slice(iS + sectFinalOld.length)
    }
    doc = apply(doc)

    //目錄項目之樣式(依樣式名稱; 行距: 第 1 層 1.5 倍、其餘單行; 章粗體、段前 6 點; 下層每層縮排 2 字並懸掛縮排使換行對齊; 右縮排 2 字; 點線引導至靠右之頁碼)
    //  定位點位置取版心寬(頁寬減左右邊界)
    let pgW = Number((doc.match(/<w:pgSz\b[^>]*w:w="(\d+)"/) || [])[1] || 11906)
    let mar = doc.match(/<w:pgMar\b[^>]*\/>/)
    let marL = Number(((mar && mar[0].match(/w:left="(\d+)"/)) || [])[1] || 1800)
    let marR = Number(((mar && mar[0].match(/w:right="(\d+)"/)) || [])[1] || 1800)
    let tabPos = pgW - marL - marR - 10
    //字型沿用正文第 1 個標題之文字段字型(w-html2docx 以 fontFamilies 套用於全文, 故與正文一致), 查無則不指定而沿用模板預設
    let fonts = ''
    for (let r of bodyFirst.xml.match(/<w:r\b[^>]*>[\s\S]*?<\/w:r>/g) || []) {
        if (/<w:t[ >]/.test(r)) {
            fonts = (r.match(/<w:rFonts\b[^>]*\/>/) || [''])[0]
            break
        }
    }
    let tab = `<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="${tabPos}"/></w:tabs>`
    let setStyle = (name, idNew, pPr, rPr, ui) => {
        let id = si.idOf(name)
        if (id && !o.replaceTocStyles) {
            return //沿用模板既有之樣式
        }
        id = id || idNew
        let xml = `<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${esc(name)}"/>${si.defId ? `<w:basedOn w:val="${si.defId}"/><w:next w:val="${si.defId}"/>` : ''}<w:uiPriority w:val="${ui}"/><w:unhideWhenUsed/><w:pPr>${pPr}</w:pPr><w:rPr>${rPr}</w:rPr></w:style>`
        let re = new RegExp(`<w:style\\b[^>]*w:styleId="${escRe(id)}"[^>]*>[\\s\\S]*?</w:style>`)
        sty = re.test(sty) ? sty.replace(re, xml) : sty.replace('</w:styles>', `${xml}</w:styles>`)
    }
    for (let lv = l0; lv <= l1; lv++) {
        let k = lv - l0
        if (k === 0) {
            setStyle(`toc ${lv + 1}`, `WMdToc${lv + 1}`, `${tab}<w:spacing w:before="120" w:line="360" w:lineRule="auto"/><w:ind w:left="0" w:right="480"/>`, `${fonts}<w:b/><w:bCs/><w:noProof/><w:kern w:val="0"/>`, 39)
        }
        else {
            setStyle(`toc ${lv + 1}`, `WMdToc${lv + 1}`, `${tab}<w:ind w:left="${k * 480 + 480}" w:right="480" w:hanging="480"/>`, `${fonts}<w:noProof/><w:kern w:val="0"/>`, 39)
        }
    }
    setStyle('table of figures', 'WMdTocFigs', `${tab}<w:ind w:left="720" w:right="480" w:hanging="720"/>`, `${fonts}<w:noProof/><w:kern w:val="0"/>`, 99)

    //寫出
    let u8Out = writeDocx(z, { 'word/document.xml': doc, 'word/styles.xml': sty })
    let info = {
        skip: '',
        cover: hasCover,
        levels: [l0 + 1, l1 + 1],
        front: front.map((h) => h.text),
        expect: {
            toc: headsIn.map((h) => h.text),
            fig: capsBody.filter((c) => c.kind === 'fig').map((c) => c.text),
            tab: capsBody.filter((c) => c.kind === 'tab').map((c) => c.text),
        },
        labels: o.labels,
        lists,
        bookmark,
        pageNumbers,
        warns,
    }
    return { u8: u8Out, info }
}


/**
 * 核對Word更新後之目錄
 *
 * 逐項取出目錄、圖目錄、表目錄之項目,檢查:項目與預期之標題、圖名、表名逐一相同且順序相同、各項為超連結且其書籤落在同文字之段落、頁碼為依序不遞減之數字、應有之目錄欄位皆存在;分節時另查各節之頁尾與頁碼設定(有封面3節,無封面2節)。
 *
 * @param {Uint8Array} u8 輸入Word更新後之docx內容
 * @param {Object} info 輸入prepDocxToc回傳之info
 * @returns {Object} 回傳{ok,errs,stats},errs為不符之說明字串陣列,stats為{toc,fig,tab,pagesFirst},分別為各目錄之項目數與目錄第1項之頁碼
 */
function checkDocxToc(u8, info) {
    let z = unzipSync(u8, { filter: (f) => f.name === 'word/document.xml' })
    let doc = strFromU8(z['word/document.xml'])
    let errs = []

    //依序走訪段落、欄位(含巢狀)、文字、定位字元、超連結、書籤
    let re = /<w:p\b[^>]*>|<\/w:p>|<w:fldChar\b[^>]*w:fldCharType="(begin|separate|end)"[^>]*\/>|<w:instrText\b[^>]*>([^<]*)<\/w:instrText>|<w:t(?: [^>]*)?>([^<]*)<\/w:t>|<w:tab\/>|<w:hyperlink\b[^>]*w:anchor="([^"]+)"[^>]*>|<w:bookmarkStart\b[^>]*w:name="([^"]+)"[^>]*\/>/g
    let paras = []
    let cur = null
    let stack = []
    let tocs = []
    let bm = {}
    let openToc = () => {
        for (let k = stack.length - 1; k >= 0; k--) {
            if (stack[k].toc && stack[k].sep) {
                return stack[k].toc
            }
        }
        return null
    }
    for (let m of doc.matchAll(re)) {
        let s = m[0]
        if (s.startsWith('<w:p')) {
            cur = { text: '', segs: [''], anchor: null, toc: openToc() }
            paras.push(cur)
        }
        else if (s === '</w:p>') {
            if (cur) {
                //目錄之首段含欄位之開始、末段含欄位之結束, 故於段首與段尾各判斷一次
                cur.toc = cur.toc || openToc()
                if (cur.toc && cur.anchor) {
                    cur.toc.entries.push({ anchor: cur.anchor, text: cur.segs.slice(0, -1).join('').trim(), page: cur.segs[cur.segs.length - 1].trim() })
                }
            }
            cur = null
        }
        else if (m[1] === 'begin') {
            stack.push({ instr: '', sep: false, toc: null })
        }
        else if (m[1] === 'separate') {
            let f = stack[stack.length - 1]
            if (f) {
                f.sep = true
                if (/^\s*TOC\b/.test(f.instr)) {
                    f.toc = { instr: f.instr.trim(), entries: [] }
                    tocs.push(f.toc)
                }
            }
        }
        else if (m[1] === 'end') {
            stack.pop()
        }
        else if (m[2] !== undefined) {
            if (stack.length) {
                stack[stack.length - 1].instr += unesc(m[2])
            }
        }
        else if (m[3] !== undefined) {
            if (cur) {
                cur.text += unesc(m[3])
                cur.segs[cur.segs.length - 1] += unesc(m[3])
            }
        }
        else if (s === '<w:tab/>') {
            if (cur) {
                cur.segs.push('')
            }
        }
        else if (m[4]) {
            if (cur && !cur.anchor) {
                cur.anchor = m[4]
            }
        }
        else if (m[5]) {
            bm[m[5]] = paras.length - 1
        }
    }
    let kindOf = (instr) => {
        let mc = instr.match(/\\c\s+"([^"]+)"/)
        if (!mc) {
            return 'toc'
        }
        return mc[1] === info.labels.fig ? 'fig' : mc[1] === info.labels.tab ? 'tab' : 'unknown'
    }
    let stats = { toc: 0, fig: 0, tab: 0, pagesFirst: '' }
    let seen = []
    for (let t of tocs) {
        let kind = kindOf(t.instr)
        seen.push(kind)
        let exp = info.expect[kind] || []
        stats[kind] = t.entries.length
        if (t.entries.length !== exp.length) {
            errs.push(`${kind} has ${t.entries.length} entries but ${exp.length} are expected`)
        }
        let last = 0
        t.entries.forEach((e, i) => {
            let ip = bm[e.anchor]
            let target = ip === undefined ? null : paras[ip].text.trim()
            if (e.text !== exp[i]) {
                errs.push(`${kind} entry ${i + 1} "${e.text}" differs from the expected "${exp[i]}"`)
            }
            if (target !== e.text) {
                errs.push(`${kind} entry ${i + 1} links to "${target}" instead of the paragraph of the entry`)
            }
            let pg = /^\d+$/.test(e.page) ? Number(e.page) : NaN
            if (Number.isNaN(pg) || pg < last) {
                errs.push(`${kind} entry ${i + 1} has a page number "${e.page}" that is not a number in non-decreasing order`)
            }
            else {
                last = pg
            }
        })
        if (kind === 'toc' && t.entries.length) {
            stats.pagesFirst = t.entries[0].page
        }
    }
    for (let kind of info.lists) {
        if (!seen.includes(kind)) {
            errs.push(`the ${kind} field is missing`)
        }
    }

    //分節: 有封面 3 節(封面、前置章節與目錄、正文), 無封面 2 節(前置章節與目錄、正文)
    if (info.pageNumbers) {
        let hasCover = info.cover !== false
        let nExp = hasCover ? 3 : 2
        let secs = [...doc.matchAll(/<w:sectPr\b[^>]*>([\s\S]*?)<\/w:sectPr>/g)].map((mm) => mm[1])
        if (secs.length !== nExp) {
            errs.push(`the document has ${secs.length} sections but ${nExp} are expected`)
        }
        else {
            let secToc = secs[hasCover ? 1 : 0]
            let secBody = secs[secs.length - 1]
            if (hasCover && /<w:footerReference\b/.test(secs[0])) {
                errs.push('the cover section should have no footer')
            }
            if (!/<w:pgNumType\b[^>]*w:fmt="upperRoman"[^>]*w:start="1"/.test(secToc) && !/<w:pgNumType\b[^>]*w:start="1"[^>]*w:fmt="upperRoman"/.test(secToc)) {
                errs.push('the section of the front headings and the TOC should be numbered in upper Roman numerals from I')
            }
            if (/<w:footerReference\b/.test(secBody) || !/<w:pgNumType\b[^>]*w:start="1"/.test(secBody)) {
                errs.push('the body section should inherit the footer of the previous section and be numbered from 1')
            }
        }
    }
    return { ok: errs.length === 0, errs, stats }
}


export { normTocOpt, prepDocxToc, checkDocxToc }