docxKeep.mjs

import get from 'lodash-es/get.js'
import { unzipSync, strFromU8 } from 'fflate'
import isobj from 'wsemi/src/isobj.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import { escRe, textOf, pPrOf, scanParas, createEdits, writeDocx } from './docxXml.mjs'


//圖名、表名之預設標籤(中文報告)
let labelsDef = { fig: '圖', tab: '表' }


/**
 * 整理圖名表名之標籤
 *
 * fig與tab須為不含空白、引號與反斜線之非空字串(添加目錄時其亦為SEQ欄位之識別名稱,並置於TOC欄位之\c參數內),否則該項退回預設;兩者相同時無法區分圖名與表名,皆退回預設。
 *
 * @param {Object} [labels={}] 輸入標籤物件{fig,tab},預設{}
 * @returns {Object} 回傳標籤物件{fig,tab}
 */
function normLabels(labels = {}) {
    let isLabel = (v) => isestr(v) && !/[\s"\\]/.test(v)
    let r = {
        fig: get(labels, 'fig', ''),
        tab: get(labels, 'tab', ''),
    }
    for (let k of ['fig', 'tab']) {
        if (!isLabel(r[k])) {
            r[k] = labelsDef[k]
        }
    }
    if (r.fig === r.tab) {
        r = { ...labelsDef }
    }
    return r
}


/**
 * 判斷段落是否含圖片或內嵌物件
 *
 * @param {Object} p 輸入段落(scanParas之元素)
 * @returns {Boolean} 回傳是否含圖片或內嵌物件
 */
function hasObj(p) {
    return /<w:drawing\b|<w:pict\b|<w:object\b/.test(p.xml)
}


/**
 * 判斷段落之前是否分頁
 *
 * 設段落前分頁(未設為關閉)或含分頁、分欄符號者為是。
 *
 * @param {Object} p 輸入段落(scanParas之元素)
 * @returns {Boolean} 回傳段落之前是否分頁
 */
function isBrkBefore(p) {
    return /<w:br\b[^>]*w:type="(?:page|column)"|<w:pageBreakBefore\b(?![^>]*w:val="(?:0|false|off)")/.test(p.xml)
}


/**
 * 判斷段落之後是否分頁或分節
 *
 * 含分節設定或分頁、分欄符號者為是。
 *
 * @param {Object} p 輸入段落(scanParas之元素)
 * @returns {Boolean} 回傳段落之後是否分頁或分節
 */
function isBrkAfter(p) {
    return /<w:br\b[^>]*w:type="(?:page|column)"|<w:sectPr\b/.test(p.xml)
}


/**
 * 判斷段落是否為可越過之空段落
 *
 * 不在表格內、無文字、無圖片與物件、其前其後皆不分頁或分節者為空段落(如w-md2html之換行標記轉檔後之段落),判斷圖名表名與其圖表是否相鄰時可越過。
 *
 * @param {Object} p 輸入段落(scanParas之元素)
 * @returns {Boolean} 回傳是否為可越過之空段落
 */
function isBlank(p) {
    return !p.inTbl && textOf(p.xml).trim() === '' && !hasObj(p) && !isBrkBefore(p) && !isBrkAfter(p)
}


/**
 * 判斷段落是否為圖片段落
 *
 * 含圖片或內嵌物件而無文字者為圖片段落;水平線(如md之---經Word匯入之VML橫線,帶o:hr)不算。
 *
 * @param {Object} p 輸入段落(scanParas之元素)
 * @returns {Boolean} 回傳是否為圖片段落
 */
function isPicPara(p) {
    return hasObj(p) && textOf(p.xml).trim() === '' && !/\bo:hr="t"/.test(p.xml)
}


/**
 * 找出圖名與表名
 *
 * 圖名、表名為段首「標籤+編號+空白」之段落(標籤與編號間可有空白);段首為標籤與編號而其後非空白者(如「圖1:名稱」)不視為圖名表名,另行列出供提示。
 *
 * @param {Array} tops 輸入候選段落陣列(scanParas之元素,呼叫端須先排除表格內等不適用之段落)
 * @param {Object} labels 輸入標籤物件{fig,tab}
 * @returns {Object} 回傳{caps,capsLike},caps之元素為段落另加{kind,num,s,e,text}(kind為'fig'或'tab',s與e為編號於段落文字之起訖位置),capsLike為疑似圖名表名之段落文字陣列
 */
function findCaptions(tops, labels) {
    let reCap = new RegExp(`^(${escRe(labels.fig)}|${escRe(labels.tab)})(\\s*)(\\d+)\\s`)
    let reCapLike = new RegExp(`^(?:${escRe(labels.fig)}|${escRe(labels.tab)})\\s*\\d+`)
    let caps = []
    let capsLike = []
    for (let p of tops) {
        let t = textOf(p.xml)
        let m = t.match(reCap)
        if (m) {
            let kind = m[1] === labels.fig ? 'fig' : 'tab'
            let s = m[1].length + m[2].length
            caps.push({ ...p, kind, num: Number(m[3]), s, e: s + m[3].length, text: t.trim() })
        }
        else if (reCapLike.test(t)) {
            capsLike.push(t.trim())
        }
    }
    return { caps, capsLike }
}


/**
 * 產生疑似圖名表名之提示訊息
 *
 * @param {Array} capsLike 輸入findCaptions之capsLike
 * @returns {String} 回傳提示訊息字串(含筆數與前3例)
 */
function warnCapsLike(capsLike) {
    return `${capsLike.length} paragraph(s) start with a figure or table label and a number that is not followed by a space, so they are not treated as captions: ${capsLike.slice(0, 3).map((t) => t.slice(0, 30)).join('; ')}`
}


/**
 * 段落設為段落內不分頁(w:keepLines)
 *
 * 已有者原樣回傳;新增時依pPr子元素順序置於pStyle、keepNext之後。
 *
 * @param {String} p 輸入段落XML字串
 * @returns {String} 回傳段落XML字串
 */
function addKeepLines(p) {
    let pPr = pPrOf(p)
    if (/<w:keepLines\/>|<w:keepLines w:val="(?:1|true|on)"\/>/.test(pPr)) {
        return p
    }
    if (/<w:keepLines\b/.test(pPr)) {
        return p.replace(/<w:keepLines\b[^>]*\/>/, '<w:keepLines/>')
    }
    if (pPr) {
        return p.replace(/^(<w:p\b[^>]*>\s*<w:pPr>(?:<w:pStyle\b[^>]*\/>)?(?:<w:keepNext\b[^>]*\/>)?)/, '$1<w:keepLines/>')
    }
    return p.replace(/^(<w:p\b[^>]*>)/, '$1<w:pPr><w:keepLines/></w:pPr>')
}


/**
 * 段落設為與下段同頁(w:keepNext)
 *
 * 已有者原樣回傳;新增時依pPr子元素順序置於pStyle之後。
 *
 * @param {String} p 輸入段落XML字串
 * @returns {String} 回傳段落XML字串
 */
function addKeepNext(p) {
    let pPr = pPrOf(p)
    if (/<w:keepNext\/>|<w:keepNext w:val="(?:1|true|on)"\/>/.test(pPr)) {
        return p
    }
    if (/<w:keepNext\b/.test(pPr)) {
        return p.replace(/<w:keepNext\b[^>]*\/>/, '<w:keepNext/>')
    }
    if (pPr) {
        return p.replace(/^(<w:p\b[^>]*>\s*<w:pPr>(?:<w:pStyle\b[^>]*\/>)?)/, '$1<w:keepNext/>')
    }
    return p.replace(/^(<w:p\b[^>]*>)/, '$1<w:pPr><w:keepNext/></w:pPr>')
}


/**
 * 找出須設與下段同頁之段落,使圖名、表名與其圖、表同頁
 *
 * 圖名表名之候選:下段為表格或圖片段落(見isPicPara)者,圖名表名在上,設圖名表名與下段同頁;上段為圖片段落者,圖名表名在下,設該圖片段落與下段同頁;上段為表格者,圖名表名在表格下方,不設(表格整體同頁須設其每一列,不處理),僅供判斷歧義與歸屬。上下段須相鄰:兩者之間只有空白或書籤,並可越過空段落(見isBlank),越過之空段落一併設與下段同頁;兩者之間分頁或分節(圖名表名或其圖表段落之前或之後,見isBrkBefore、isBrkAfter)即不相鄰。表格內之段落不作圖片段落。
 *
 * 只有一方有候選者與該方同頁。上下皆有候選者(如連續之圖:圖、圖名、圖、圖名),依同類(圖名或表名)只有一方有候選者之多數位置取向:在上者多取下段,在下者多取上段,平手時圖名取上段、表名取下段(圖名在圖下方、表名在表上方之慣例)。一張圖表只歸屬一方:取向之一方已歸屬其他圖名表名而另一方未歸屬時改取另一方,兩方皆已歸屬時不設;取下段者自文件末往前、取上段者自文件首往後依序歸屬,使歸屬自與取向相反之圖名表名處延續。兩個只有一方有候選之圖名表名夾同一張圖時兩者皆設(無從判斷屬誰)。
 *
 * @param {String} doc 輸入document.xml字串
 * @param {Array} paras 輸入scanParas之結果
 * @param {Array} caps 輸入findCaptions之caps
 * @param {Function} [isSkip] 輸入不參與判斷之段落之判斷函數(如文字方塊內或含文字方塊之段落),遇之即視為不相鄰,預設皆參與
 * @returns {Array} 回傳須設與下段同頁之段落陣列(可能重複)
 */
function planKeepNext(doc, paras, caps, isSkip = () => false) {
    let reMarkLead = /^(?:\s|<w:bookmark(?:Start|End)\b[^>]*\/>)+/
    let reMarkTail = /(?:\s|<w:bookmark(?:Start|End)\b[^>]*\/>)+$/
    let between = (a, b) => doc.slice(a, b).replace(/<w:bookmark(?:Start|End)\b[^>]*\/>/g, '').trim()
    let iOf = new Map(paras.map((p, i) => [p.start, i]))

    //tblOfEnd: 頂層表格之結尾位置對應其起點, 表格以起點識別(上下兩方向取得之同一表格須為同一識別)
    let tblOfEnd = new Map()
    let depth = 0
    let s0 = 0
    for (let m of doc.matchAll(/<w:tbl\b[^>]*>|<\/w:tbl>/g)) {
        if (m[0] === '</w:tbl>') {
            depth--
            if (depth === 0) {
                tblOfEnd.set(m.index + m[0].length, s0)
            }
        }
        else {
            if (depth === 0) {
                s0 = m.index
            }
            depth++
        }
    }

    //tblAfter, tblBefore: 位置之後、之前越過空白與書籤為表格時回傳該表格之起點, 否則回傳-1
    let tblAfter = (pos) => {
        let rest = doc.slice(pos)
        let n = (rest.match(reMarkLead) || [''])[0].length
        return /^<w:tbl\b/.test(rest.slice(n)) ? pos + n : -1
    }
    let tblBefore = (pos) => {
        let a = Math.max(0, pos - 4096)
        let head = doc.slice(a, pos).replace(reMarkTail, '')
        let e = a + head.length
        return head.endsWith('</w:tbl>') && tblOfEnd.has(e) ? tblOfEnd.get(e) : -1
    }

    //below: 下段越過空段落後為表格或圖片段落時, 回傳{obj,keeps}(obj為該表格或圖片段落之識別, keeps為須設與下段同頁之圖名表名與所越過之空段落), 否則回傳null
    let below = (c) => {
        if (isBrkAfter(c)) {
            return null
        }
        let blanks = []
        let cur = c
        for (let j = iOf.get(c.start) + 1; j <= paras.length; j++) {
            let t = tblAfter(cur.end)
            if (t >= 0) {
                return { obj: `t${t}`, keeps: [c, ...blanks] }
            }
            let nx = paras[j]
            if (!nx || nx.inTbl || isSkip(nx) || between(cur.end, nx.start) !== '' || isBrkBefore(nx)) {
                return null
            }
            if (isPicPara(nx)) {
                return { obj: `p${nx.start}`, keeps: [c, ...blanks] }
            }
            if (!isBlank(nx)) {
                return null
            }
            blanks.push(nx)
            cur = nx
        }
        return null
    }

    //above: 上段越過空段落後為圖片段落或表格時, 回傳{obj,keeps}(圖片段落之keeps為須設與下段同頁之圖片段落與所越過之空段落, 表格之keeps為null即不設), 否則回傳null
    let above = (c) => {
        if (isBrkBefore(c)) {
            return null
        }
        let blanks = []
        let cur = c
        for (let j = iOf.get(c.start) - 1; j >= -1; j--) {
            let t = tblBefore(cur.start)
            if (t >= 0) {
                return { obj: `t${t}`, keeps: null }
            }
            let pv = paras[j]
            if (!pv || pv.inTbl || isSkip(pv) || between(pv.end, cur.start) !== '' || isBrkAfter(pv)) {
                return null
            }
            if (isPicPara(pv)) {
                return { obj: `p${pv.start}`, keeps: [pv, ...blanks] }
            }
            if (!isBlank(pv)) {
                return null
            }
            blanks.push(pv)
            cur = pv
        }
        return null
    }

    let cands = caps.map((c) => ({ c, dn: below(c), up: above(c) }))

    //各類之取向: 依同類只有一方有候選者之多數位置, 平手時圖名取上段、表名取下段
    //why: 一律先取下段會使連續之圖之圖名皆與下一張圖同頁, 圖放不下時圖名隨下一張圖移至次頁(2026-10-10 Word 16 實測整頁高長圖 4 張皆分離)
    let prefer = {}
    for (let kind of ['fig', 'tab']) {
        let vs = cands.filter((v) => v.c.kind === kind)
        let nAbove = vs.filter((v) => v.dn !== null && v.up === null).length
        let nBelow = vs.filter((v) => v.dn === null && v.up !== null).length
        prefer[kind] = nAbove > nBelow ? 'dn' : (nAbove < nBelow ? 'up' : (kind === 'fig' ? 'up' : 'dn'))
    }

    //先歸屬只有一方有候選者, 再歸屬上下皆有候選者(取上段者依文件順序, 取下段者依文件逆序)
    let out = []
    let claimed = new Set()
    let bind = (x) => {
        if (x.keeps !== null) {
            out.push(...x.keeps)
        }
        claimed.add(x.obj)
    }
    let boths = []
    for (let v of cands) {
        if (v.dn !== null && v.up !== null) {
            boths.push(v)
        }
        else if (v.dn !== null || v.up !== null) {
            bind(v.dn !== null ? v.dn : v.up)
        }
    }
    let byUp = boths.filter((v) => prefer[v.c.kind] === 'up')
    let byDn = boths.filter((v) => prefer[v.c.kind] === 'dn').reverse()
    for (let v of [...byUp, ...byDn]) {
        let a = prefer[v.c.kind]
        let b = a === 'up' ? 'dn' : 'up'
        if (!claimed.has(v[a].obj)) {
            bind(v[a])
        }
        else if (!claimed.has(v[b].obj)) {
            bind(v[b])
        }
    }
    return out
}


/**
 * 準備圖名、表名與其圖、表同頁之docx內容(純結構處理, 不需Word)
 *
 * 圖名表名設段落內不分頁(keepLines),並依planKeepNext設與下段同頁(keepNext),不依附目錄:編號不連續、分節不只1個、無標題、已有目錄欄位皆照常處理。文字方塊內之段落為巢狀段落,正則切段會截斷含文字方塊之段落,故文字方塊內與含文字方塊之段落不作圖名表名亦不作圖片段落,並列入warns。已有之設定不重複添加;無需改動時回傳原內容。
 *
 * @param {Uint8Array} u8 輸入docx檔之內容
 * @param {Object|Boolean} [opt={}] 輸入設定物件,true或非物件時皆用預設,預設{}
 * @param {Object} [opt.labels={fig:'圖',tab:'表'}] 輸入圖名、表名之標籤,見normLabels
 * @returns {Object} 回傳{u8,info},info為{figs,tabs,keepNext,keepLines,changed,warns}:figs、tabs為圖名與表名數,keepNext、keepLines為新增該設定之段落數,changed為內容是否改動(false時u8為原內容)
 */
function prepDocxKeep(u8, opt = {}) {
    if (!isobj(opt)) {
        opt = {}
    }
    let labels = normLabels(get(opt, 'labels', {}))
    let z = unzipSync(u8)
    if (!z['word/document.xml']) {
        throw new Error('the docx lacks word/document.xml')
    }
    let doc = strFromU8(z['word/document.xml'])
    let warns = []

    //文字方塊: 框內為巢狀段落, 正則切段會截斷含框之段落, 故框內與含框之段落皆不參與判斷
    let rangesTxbx = [...doc.matchAll(/<w:txbxContent\b[^>]*>[\s\S]*?<\/w:txbxContent>/g)].map((m) => [m.index, m.index + m[0].length])
    let isSkip = (p) => /<w:txbxContent\b/.test(p.xml) || rangesTxbx.some(([a, b]) => p.start >= a && p.start < b)
    if (rangesTxbx.length > 0) {
        warns.push('the document has text boxes, the paragraphs in or holding text boxes are not treated as captions or figures')
    }

    //圖名與表名
    let paras = scanParas(doc)
    let tops = paras.filter((p) => !p.inTbl && !isSkip(p))
    let { caps, capsLike } = findCaptions(tops, labels)
    if (capsLike.length > 0) {
        warns.push(warnCapsLike(capsLike))
    }

    //改寫, 並計數實際新增設定之段落(已有者不計)
    let nLines = 0
    let nNext = 0
    let fnLines = (x) => {
        let y = addKeepLines(x)
        if (y !== x) {
            nLines++
        }
        return y
    }
    let fnNext = (x) => {
        let y = addKeepNext(x)
        if (y !== x) {
            nNext++
        }
        return y
    }
    let ed = createEdits()
    for (let c of caps) {
        ed.addEdit(c, fnLines)
    }
    for (let p of planKeepNext(doc, paras, caps, isSkip)) {
        ed.addEdit(p, fnNext)
    }
    let docNew = ed.apply(doc)

    let info = {
        figs: caps.filter((c) => c.kind === 'fig').length,
        tabs: caps.filter((c) => c.kind === 'tab').length,
        keepNext: nNext,
        keepLines: nLines,
        changed: docNew !== doc,
        warns,
    }
    if (!info.changed) {
        return { u8, info }
    }
    return { u8: writeDocx(z, { 'word/document.xml': docNew }), info }
}


export { labelsDef, normLabels, hasObj, isBrkBefore, isBrkAfter, isBlank, isPicPara, findCaptions, warnCapsLike, addKeepLines, addKeepNext, planKeepNext, prepDocxKeep }