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 }