fix: a vertically merged cell keeps the edges of the rows it covers

A cell's grid box claimed one row, so a w:vMerge reaching the table's last
row sat inside the banded region instead of on its bottom edge — and a band
declaring insideH nil then dropped the line. The merge extent is counted
before the covered rows are read.

Measured against LibreOffice: the bottom rule runs the full width under a
merged first column, which the editor now draws too.
This commit is contained in:
Steffen Becker committed 2026-08-13 08:09:12 +00:00
1 parent 04d1fed5f0
commit 9d494b2966
3 files changed
+45 -2

No files matched your search

+4 -1
View File
@@ -59,7 +59,10 @@ which is where an assigned style's regions otherwise come from. An area covers a
its `top`/`bottom`/`left`/`right` apply where the cell sits on that box's edge, its
`insideH`/`insideV` anywhere within. A **band's box is the whole banded region**, not the
one row it shades — that is what makes `insideH` the line *between* two band rows, which
is how a style with no inner rules (Word's Medium Shading) comes out borderless. Areas
is how a style with no inner rules (Word's Medium Shading) comes out borderless. A cell's
own box spans the rows its `w:vMerge` covers — counted ahead of the covered rows
(`vMergeRows`) — or a merge reaching the last row loses its bottom edge to that same rule.
Areas
layer in Word's ascending precedence, over the table's borders and under the cell's own
`w:tcPr`; banding counts from the first *body* row, so the row under a header row is band 1.
`w:tblLook` decides which areas apply at all — named flags where the file has them, the
+24 -1
View File
@@ -1704,6 +1704,28 @@ function condAreasFor(
return out;
}
// How many rows a vertically merged cell covers. Its box has to say so before the covered
// rows are read, or a merge reaching the table's last row reads as an inside line there —
// which a style whose band declares insideH nil then drops.
function vMergeRows(trs: Element[], from: number, col: number): number {
let n = 1;
for (let ri = from + 1; ri < trs.length; ri++) {
let c = 0;
let covered = false;
for (const tc of fcAll(trs[ri], 'tc')) {
if (c >= col) {
const el = fc(fc(tc, 'tcPr'), 'vMerge');
covered = c === col && !!el && (wVal(el) ?? 'continue') !== 'restart';
break;
}
c += intAttr(fc(fc(tc, 'tcPr'), 'gridSpan'), W, 'val') ?? 1;
}
if (!covered) break;
n++;
}
return n;
}
// Which side of an area a cell's edge is: its own outer side where the cell sits on the
// area's edge, the area's inside line otherwise.
function areaSide(area: GridBox, cell: GridBox, side: 'top' | 'bottom' | 'left' | 'right'): string {
@@ -1825,7 +1847,8 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
col += colspan;
continue; // covered cell — dropped, span folded into its origin
}
const box: GridBox = { row: ri, col, rowEnd: ri + 1, colEnd: col + colspan };
const rowEnd = ri + (vMerge === 'restart' ? vMergeRows(trs, ri, col) : 1);
const box: GridBox = { row: ri, col, rowEnd, colEnd: col + colspan };
const areas = conds.size ? condAreasFor(conds, flags, band, box, trs.length, gridCols) : [];
const paint = condPaint(areas);
const fill = fc(tcPr, 'shd')?.getAttributeNS(W, 'fill');
+17
View File
@@ -40,6 +40,17 @@ const table = (look = '<w:tblLook w:firstRow="1" w:firstColumn="1" w:noHBand="0"
`<w:tr>${[0, 1].map((c) => `<w:tc><w:p><w:r><w:t>r${r}c${c}</w:t></w:r></w:p></w:tc>`).join('')}</w:tr>`,
).join('') + '</w:tbl>';
// The same grid with its first column merged from the first body row down to the last.
const mergedTable = () =>
`<w:tbl><w:tblPr><w:tblStyle w:val="Banded"/><w:tblLook w:firstRow="1" w:firstColumn="1" w:noHBand="0" w:noVBand="1"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>` +
[0, 1, 2, 3].map((r) => {
const merge = r === 1 ? '<w:vMerge w:val="restart"/>' : '<w:vMerge/>';
const first = r === 0 ? '<w:tc><w:p><w:r><w:t>h</w:t></w:r></w:p></w:tc>'
: `<w:tc><w:tcPr>${merge}</w:tcPr><w:p><w:r><w:t>m</w:t></w:r></w:p></w:tc>`;
return `<w:tr>${first}<w:tc><w:p><w:r><w:t>r${r}</w:t></w:r></w:p></w:tc></w:tr>`;
}).join('') + '</w:tbl>';
const docxWith = (body: string) =>
zipSync({
'word/document.xml': strToU8(`<?xml version="1.0"?><w:document xmlns:w="${W}"><w:body>${body}</w:body></w:document>`),
@@ -77,6 +88,12 @@ describe('a Word table style paints its conditional areas', () => {
expect(rows[3][0].attrs.borderBottom).toBe('1pt solid #4F81BD'); // the table's own edge
});
it('gives a merged cell the edges of every row it covers', () => {
const merged = cellsOf(docxWith(mergedTable()));
expect(merged[1][0].attrs.rowspan).toBe(3);
expect(merged[1][0].attrs.borderBottom).toBe('1pt solid #4F81BD'); // the table's own edge
});
it('paints nothing the table’s look switches off', () => {
const off = cellsOf(docxWith(table('<w:tblLook w:firstRow="0" w:firstColumn="0" w:noHBand="1" w:noVBand="1"/>')));
expect(off[0][0].attrs.backgroundColor).toBeUndefined();