From dca3c21e2cb99f836d7a67bf2d01d21a1368c958 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 10 Sep 2026 23:31:49 +0200 Subject: [PATCH] fix: a header/footer table's text and rule line reach the zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .docx zone holding a table lost everything in it: hfParagraphs walked only the direct w:p children, so the one-cell table every Word template draws its rule line with left the zone empty. Its cells' paragraphs now become lines of the zone, as they already do on the ODF side, and the cell's own borders and shading (its w:tcBorders over the table's w:tblBorders) become the collapsed paragraph's box — which is what draws the rule. Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G --- src/lib/import/docx.ts | 49 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 02506be..bf80be1 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -3281,7 +3281,7 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc { let textAlign: string | null = null; let stops: string | null = null; const boxMaps: Record[] = []; - for (const p of hfParagraphs(root)) { + for (const p of hfParagraphs(root, hfCtx)) { const ppr = fc(p, 'pPr'); // The zone is one paragraph, so the first line's stops are the zone's. Word puts a // header's centre/right pair on the Header style rather than the paragraph. @@ -3292,7 +3292,7 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc { const ta = (fc(ppr, 'jc') ? wVal(fc(ppr, 'jc')!) : null) ?? ''; textAlign = ta === 'center' || ta === 'both' ? (ta === 'both' ? 'justify' : 'center') : ta === 'right' || ta === 'end' ? 'right' : ''; } - boxMaps.push(readParaBox(ppr)); + boxMaps.push({ ...hfCellBox(p), ...readParaBox(ppr) }); const baseRun = hfCtx.styles.paragraphRun(fc(ppr, 'pStyle') ? wVal(fc(ppr, 'pStyle')!) : null); // The zone carries no styleName and no style CSS reaches it, so the yardstick is the // editor's own defaults — what the Header/Footer style provides has to become marks @@ -3322,20 +3322,59 @@ function convertHfPart(relId: string | null, ctx: Ctx): HfDoc { } // A zone's paragraphs in document order, unwrapping the content controls Word puts -// around an inserted page number — its w:p is not a child of w:hdr/w:ftr. -function hfParagraphs(el: Element): Element[] { +// around an inserted page number — its w:p is not a child of w:hdr/w:ftr. A table is +// beyond the one-paragraph model: its cells' paragraphs become lines of the zone, and +// the rule line it draws rides the zone's box (hfCellBox). +function hfParagraphs(el: Element, ctx?: Ctx): Element[] { const out: Element[] = []; for (const c of Array.from(el.children)) { if (c.namespaceURI !== W) continue; if (c.localName === 'p') out.push(c); else if (c.localName === 'sdt') { const content = fc(c, 'sdtContent'); - if (content) out.push(...hfParagraphs(content)); + if (content) out.push(...hfParagraphs(content, ctx)); + } else if (c.localName === 'tbl') { + ctx?.warnings.add('Lists/tables in headers or footers were flattened to text'); + for (const tr of Array.from(c.children).filter((r) => r.namespaceURI === W && r.localName === 'tr')) { + for (const tc of Array.from(tr.children).filter((t) => t.namespaceURI === W && t.localName === 'tc')) { + out.push(...hfParagraphs(tc, ctx)); + } + } } } return out; } +// The box a zone paragraph inherits from the table cell around it: the rule line a +// header draws is a one-cell table's border in every Word template. The cell's own +// w:tcBorders win over the table's, as they do in a body table. +function hfCellBox(p: Element): Record { + let tc: Element | null = null; + for (let e = p.parentElement; e; e = e.parentElement) { + if (e.namespaceURI === W && e.localName === 'tc') { tc = e; break; } + } + if (!tc) return {}; + const tcPr = fc(tc, 'tcPr'); + const layers = [fc(tcPr, 'tcBorders'), fc(fc(tblOf(tc), 'tblPr'), 'tblBorders')]; + const out: Record = {}; + for (const [wSide, attr] of PARA_BORDER_SIDES) { + let v: string | null | undefined; + for (const el of layers) { v = docxBorderAttr(fc(el, wSide)); if (v !== undefined) break; } + // null = the 0.5pt black a cell defaults to; a paragraph box has no default to mean. + if (v && v !== 'none') out[attr] = v; else if (v === null) out[attr] = '0.5pt solid #000000'; + } + const fill = hexColor(fc(tcPr, 'shd')?.getAttributeNS(W, 'fill') ?? null); + if (fill) out.backgroundColor = fill; + return out; +} + +function tblOf(el: Element): Element | null { + for (let e = el.parentElement; e; e = e.parentElement) { + if (e.namespaceURI === W && e.localName === 'tbl') return e; + } + return null; +} + // Collapse several source paragraphs' box props into one (mirror of odt.ts mergeHfBox). function mergeHfBox(maps: Record[]): Record { const out: Record = {};