feat: a Word table style paints the areas its file declares
w:tblStylePr — header row, banded rows, first/last column — was never read, so a table styled in Word arrived as a bare grid. Import bakes each area into the cells now (fill, borders, run marks): the file's own style has no registry entry to re-derive regions from, and the bake is what a re-export carries. A band's box is the whole banded region, so its insideH is the line between two band rows; banding counts from the first body row, as in Word and LibreOffice. Corpus 803 -> 795, page counts unchanged.
This commit is contained in:
1 parent
29667a6f0d
commit
9d6aedb6ef
6 files changed
+293
-36
No files matched your search
@@ -39,6 +39,7 @@
|
||||
- Word's Table Style Options as six checkboxes beside the gallery (header row, total row, first/last column, banded rows/columns): an area is painted only when the style defines it *and* the table opts in, and turning the header row off shifts the banding by one row. The toolbar's header row / first column buttons drive the same flags, so both surfaces always agree
|
||||
- Table styles are edited in the same style manager (third tab: area picker for fill and font, outer / row line / column line controls, new / rename / remove), also reachable from the insert-table dropdown
|
||||
- Round-trip: the style name travels as an ODF `style:family="table"` (`style:parent-style-name`) and DOCX `w:tblStyle`, the options as ODF's `table:use-*-styles` and Word's `w:tblLook`. Fills and borders are materialized into the cells and the region's font is baked onto the runs, so the table looks right in Word/LibreOffice even though ODF has no banding concept — the style definitions themselves live in the app, as LibreOffice's AutoFormats do
|
||||
- A Word file's own table style is read the other way round: its conditional areas (`w:tblStylePr` — header row, banded rows, first/last column) are baked into the cells on import, shading, borders and bold/colour alike, so a document styled with Word's Light Grid or Medium Shading opens looking like it does in Word and LibreOffice
|
||||
- A new document starts from the built-in styles, like Word's/LibreOffice's default template
|
||||
|
||||
**Paragraphs & lists**
|
||||
|
||||
@@ -81,6 +81,11 @@ We deliberately keep TipTap's `resizable: false` (so its own columnResizing plug
|
||||
`refreshTableStyles`, and `App.svelte` keeps `sheet.table` across an import because the file
|
||||
never carried it. Baked bold is only suppressed for `HEADER_SHADE` cells (the existing
|
||||
`boldByDefault` path) — a foreign style's bold survives as direct formatting.
|
||||
A **Word** file's own table style does carry its areas (`w:tblStylePr`), and the registry has
|
||||
no entry to re-derive them from, so the DOCX importer bakes those into the cells instead —
|
||||
fill, borders and run marks alike (`src/lib/import/CLAUDE.md` for the area rules). What is
|
||||
re-exported is that bake, which is why the round trip is stable: our `w:tblStyle` is
|
||||
name-only, so a reimport finds no areas to apply over it.
|
||||
|
||||
- **Table-style gallery**: `TableStylePicker.svelte` in the floating `TableToolbar` — tiles drawn
|
||||
by `previewCellCss` (the same resolver), so a preview can't disagree with what applying does,
|
||||
|
||||
@@ -46,6 +46,19 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
|
||||
drawn**: Word's Normal Table has no border, and the gridlines it shows on screen are not
|
||||
printed. ODF says the same for an undeclared `fo:border`, so both importers agree.
|
||||
|
||||
**A table style's conditional areas** (`w:tblStylePr`: header row, banded rows, first
|
||||
column, …) are **baked into the cells** — fill and borders as attrs, the area's `w:rPr` as
|
||||
real marks on its runs — because the file's own style is not in the editor's registry,
|
||||
which is where an assigned style's regions otherwise come from. An area covers a grid box:
|
||||
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
|
||||
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
|
||||
older `w:val` bitmask otherwise, and no element at all means none.
|
||||
|
||||
Body text with no resolved font falls back to the *document's own theme minor font*
|
||||
(`docx.ts` `runMarks`), not the editor default — Word's implicit body default. Headings don't
|
||||
(they keep the editor heading default); DOCX page margins default to Word's 2.54cm.
|
||||
+137
-36
@@ -1622,6 +1622,102 @@ function docxBorderAttr(el: Element | null): string | null | undefined {
|
||||
return `${w}pt solid ${c}`;
|
||||
}
|
||||
|
||||
// A conditional table-style area (w:tblStylePr) and the grid box it covers. A band's box
|
||||
// is the whole banded region, not the single band: that is what makes its insideH the
|
||||
// line *between* two band rows, which is how Word and LibreOffice draw one.
|
||||
type GridBox = { row: number; col: number; rowEnd: number; colEnd: number };
|
||||
type CondArea = { el: Element; box: GridBox };
|
||||
type LookFlags = { firstRow: boolean; lastRow: boolean; firstCol: boolean; lastCol: boolean; hBand: boolean; vBand: boolean };
|
||||
|
||||
// w:tblLook: Word 2010+ writes the named attributes, older files only the w:val bitmask.
|
||||
// No element at all means no conditional area applies.
|
||||
function docxLookFlags(tblPr: Element | null): LookFlags {
|
||||
const el = fc(tblPr, 'tblLook');
|
||||
const bits = parseInt(el?.getAttributeNS(W, 'val') ?? '', 16);
|
||||
const on = (name: string, bit: number) => {
|
||||
const v = el?.getAttributeNS(W, name);
|
||||
if (v != null) return v === '1' || v === 'true';
|
||||
return Number.isFinite(bits) && (bits & bit) !== 0;
|
||||
};
|
||||
if (!el) return { firstRow: false, lastRow: false, firstCol: false, lastCol: false, hBand: false, vBand: false };
|
||||
return {
|
||||
firstRow: on('firstRow', 0x020), lastRow: on('lastRow', 0x040),
|
||||
firstCol: on('firstColumn', 0x080), lastCol: on('lastColumn', 0x100),
|
||||
hBand: !on('noHBand', 0x200), vBand: !on('noVBand', 0x400),
|
||||
};
|
||||
}
|
||||
|
||||
// The areas covering one cell, lowest precedence first. Banding counts from the first body
|
||||
// row/column, so the row under a header row is the first band (as in Word and LibreOffice).
|
||||
function condAreasFor(
|
||||
conds: Map<string, Element>, flags: LookFlags, band: { row: number; col: number },
|
||||
cell: GridBox, rows: number, cols: number,
|
||||
): CondArea[] {
|
||||
const out: CondArea[] = [];
|
||||
const push = (type: string, box: GridBox) => {
|
||||
const el = conds.get(type);
|
||||
if (el) out.push({ el, box });
|
||||
};
|
||||
const r0 = flags.firstRow ? 1 : 0, r1 = flags.lastRow ? rows - 2 : rows - 1;
|
||||
const c0 = flags.firstCol ? 1 : 0, c1 = flags.lastCol ? cols - 2 : cols - 1;
|
||||
if (flags.vBand && cell.col >= c0 && cell.col <= c1) {
|
||||
const even = Math.floor((cell.col - c0) / band.col) % 2 === 0;
|
||||
push(even ? 'band1Vert' : 'band2Vert', { row: 0, rowEnd: rows, col: c0, colEnd: c1 + 1 });
|
||||
}
|
||||
if (flags.hBand && cell.row >= r0 && cell.row <= r1) {
|
||||
const even = Math.floor((cell.row - r0) / band.row) % 2 === 0;
|
||||
push(even ? 'band1Horz' : 'band2Horz', { row: r0, rowEnd: r1 + 1, col: 0, colEnd: cols });
|
||||
}
|
||||
if (flags.lastCol && cell.colEnd === cols) push('lastCol', { row: 0, rowEnd: rows, col: cols - 1, colEnd: cols });
|
||||
if (flags.firstCol && cell.col === 0) push('firstCol', { row: 0, rowEnd: rows, col: 0, colEnd: 1 });
|
||||
if (flags.lastRow && cell.rowEnd === rows) push('lastRow', { row: rows - 1, rowEnd: rows, col: 0, colEnd: cols });
|
||||
if (flags.firstRow && cell.row === 0) push('firstRow', { row: 0, rowEnd: 1, col: 0, colEnd: cols });
|
||||
return out;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (side === 'top') return cell.row <= area.row ? 'top' : 'insideH';
|
||||
if (side === 'bottom') return cell.rowEnd >= area.rowEnd ? 'bottom' : 'insideH';
|
||||
if (side === 'left') return cell.col <= area.col ? 'left' : 'insideV';
|
||||
return cell.colEnd >= area.colEnd ? 'right' : 'insideV';
|
||||
}
|
||||
|
||||
// A cell's shading and run properties from the table style, layered over its areas. The
|
||||
// runs are baked below: a file's own table style has no entry in the editor's registry.
|
||||
function condPaint(areas: CondArea[]): { fill?: string; run: RunProps } {
|
||||
let fill: string | undefined;
|
||||
let run: RunProps = {};
|
||||
for (const a of areas) {
|
||||
const shd = fc(fc(a.el, 'tcPr'), 'shd');
|
||||
if (shd) fill = hexColor(shd.getAttributeNS(W, 'fill'));
|
||||
run = mergeRunProps(run, parseRunProps(fc(a.el, 'rPr')));
|
||||
}
|
||||
return { fill, run };
|
||||
}
|
||||
|
||||
// A conditional area's bold/italic/colour as real marks on the cell's text, skipping what
|
||||
// a run already declares — the file's own formatting outranks its table style.
|
||||
function bakeCellRuns(nodes: Node[], props: RunProps): void {
|
||||
const color = hexColor(props.color);
|
||||
if (!props.bold && !props.italic && !color) return;
|
||||
for (const n of nodes) {
|
||||
if (n.content) bakeCellRuns(n.content, props);
|
||||
if (n.type !== 'text') continue;
|
||||
const marks = n.marks ?? [];
|
||||
const has = (t: string) => marks.some((m) => m.type === t);
|
||||
if (props.bold && !has('bold')) marks.push({ type: 'bold' });
|
||||
if (props.italic && !has('italic')) marks.push({ type: 'italic' });
|
||||
if (color) {
|
||||
const ts = marks.find((m) => m.type === 'textStyle');
|
||||
if (!ts) marks.push({ type: 'textStyle', attrs: { color } });
|
||||
else if (!ts.attrs?.color) ts.attrs = { ...ts.attrs, color };
|
||||
}
|
||||
if (marks.length) n.marks = marks;
|
||||
}
|
||||
}
|
||||
|
||||
function convertTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
// The table style's own w:pPr/w:spacing governs its cells' paragraphs (Word's Table
|
||||
// Grid zeroes the space after and the line spacing). Restored for a nested table.
|
||||
@@ -1663,11 +1759,6 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
const weights = grid ? fcAll(grid, 'gridCol').map((g) => Math.max(1, intAttr(g, W, 'w') ?? 1)) : null;
|
||||
const useWeights = weights && weights.length ? weights : null;
|
||||
|
||||
// Table-level border defaults; per-cell w:tcBorders override per side. Edge cells
|
||||
// fall back to the outer sides, interior cells to insideH/insideV.
|
||||
|
||||
// From w:tblStyle only the name comes back — the look rides on the cell attrs, and the
|
||||
// editor re-derives the regions from its own registry (refreshTableStyles).
|
||||
const styleEl = fc(fc(tbl, 'tblPr'), 'tblStyle');
|
||||
const styleId = styleEl ? wVal(styleEl) : null;
|
||||
const pad = docxCellPadding(tbl, styleId, ctx);
|
||||
@@ -1676,22 +1767,20 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
// The table's own w:tblBorders, then its style's; a side nobody declares is not drawn
|
||||
// (Word's Normal Table has no border — its on-screen gridlines are not printed).
|
||||
const tblBorders = fc(fc(tbl, 'tblPr'), 'tblBorders');
|
||||
const tblSide = (name: string) => {
|
||||
for (const el of [tblBorders, ...ctx.styles.tableBorders(styleId)]) {
|
||||
const v = docxBorderAttr(fc(el, name));
|
||||
if (v !== undefined) return v;
|
||||
}
|
||||
return 'none';
|
||||
};
|
||||
const tblDef = {
|
||||
top: tblSide('top'), bottom: tblSide('bottom'), left: tblSide('left'),
|
||||
right: tblSide('right'), insideH: tblSide('insideH'), insideV: tblSide('insideV'),
|
||||
};
|
||||
const gridWidth = useWeights?.length ?? null;
|
||||
const tblBorderEls = [tblBorders, ...ctx.styles.tableBorders(styleId)];
|
||||
|
||||
const rows: Node[] = [];
|
||||
const pending: (Node | null)[] = []; // origin cell per grid column, for vMerge spans
|
||||
const trs = fcAll(tbl, 'tr');
|
||||
|
||||
// The table style's conditional areas paint what only the style declares, so they are
|
||||
// baked into the cells: a foreign style is not in the editor's registry, which is where
|
||||
// an assigned table style's regions otherwise come from (refreshTableStyles).
|
||||
const conds = ctx.styles.tableConditional(styleId);
|
||||
const flags = docxLookFlags(fc(tbl, 'tblPr'));
|
||||
const band = ctx.styles.tableBandSize(styleId);
|
||||
const gridCols = useWeights?.length
|
||||
?? Math.max(1, ...trs.map((tr) => fcAll(tr, 'tc').reduce((n, tc) => n + (intAttr(fc(fc(tc, 'tcPr'), 'gridSpan'), W, 'val') ?? 1), 0)));
|
||||
for (let ri = 0; ri < trs.length; ri++) {
|
||||
const tr = trs[ri];
|
||||
const cells: Node[] = [];
|
||||
@@ -1707,9 +1796,13 @@ 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 areas = conds.size ? condAreasFor(conds, flags, band, box, trs.length, gridCols) : [];
|
||||
const paint = condPaint(areas);
|
||||
const fill = fc(tcPr, 'shd')?.getAttributeNS(W, 'fill');
|
||||
const bg = fill ? hexColor(fill) : undefined;
|
||||
const bg = fill ? hexColor(fill) : paint.fill;
|
||||
const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell', bg === HEADER_SHADE);
|
||||
bakeCellRuns(blocks, paint.run);
|
||||
const attrs: Record<string, unknown> = { colspan, rowspan: 1 };
|
||||
if (bg) attrs.backgroundColor = bg;
|
||||
// w:vAlign — Word's "center" is the editor's "middle"; "top"/"both" stay the default.
|
||||
@@ -1720,17 +1813,30 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
// The cell's own margins (w:tcMar), kept only where they differ from the table's.
|
||||
const ownPad = cellPaddingAttr(cellMarginsCm([fc(tcPr, 'tcMar')]), padBase);
|
||||
if (ownPad) attrs.cellPadding = ownPad;
|
||||
// Per side: the table's borders, then each conditional area over them, then the
|
||||
// cell's own w:tcBorders. An edge of the box a layer covers takes that layer's
|
||||
// outer side, anything inside it the layer's insideH/insideV.
|
||||
const tcBorders = fc(tcPr, 'tcBorders');
|
||||
const isRight = gridWidth != null && col + colspan >= gridWidth;
|
||||
const resolve = (name: string, tblVal: string | null | undefined) => {
|
||||
const own = docxBorderAttr(fc(tcBorders, name));
|
||||
return own !== undefined ? own : tblVal;
|
||||
const table: GridBox = { row: 0, col: 0, rowEnd: trs.length, colEnd: gridCols };
|
||||
const layers: { els: (Element | null)[]; box: GridBox }[] = [
|
||||
{ els: tblBorderEls, box: table },
|
||||
...areas.map((a) => ({ els: [fc(fc(a.el, 'tcPr'), 'tcBorders')], box: a.box })),
|
||||
{ els: [tcBorders], box },
|
||||
];
|
||||
const resolve = (side: 'top' | 'bottom' | 'left' | 'right') => {
|
||||
let v: string | null | undefined;
|
||||
for (const layer of layers) {
|
||||
const name = areaSide(layer.box, box, side);
|
||||
for (const el of layer.els) {
|
||||
const got = docxBorderAttr(fc(el, name));
|
||||
if (got !== undefined) { v = got; break; }
|
||||
}
|
||||
}
|
||||
return v === undefined ? 'none' : v;
|
||||
};
|
||||
const sides: [string, string | null | undefined][] = [
|
||||
['borderTop', resolve('top', ri === 0 ? tblDef.top : tblDef.insideH)],
|
||||
['borderBottom', resolve('bottom', ri === trs.length - 1 ? tblDef.bottom : tblDef.insideH)],
|
||||
['borderLeft', resolve('left', col === 0 ? tblDef.left : tblDef.insideV)],
|
||||
['borderRight', resolve('right', isRight ? tblDef.right : tblDef.insideV)],
|
||||
const sides: [string, string | null][] = [
|
||||
['borderTop', resolve('top')], ['borderBottom', resolve('bottom')],
|
||||
['borderLeft', resolve('left')], ['borderRight', resolve('right')],
|
||||
];
|
||||
// undefined (nothing declared) and null (= editor default) both leave the attr off.
|
||||
for (const [attr, v] of sides) if (typeof v === 'string') attrs[attr] = v;
|
||||
@@ -1761,16 +1867,11 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
// Word's w:tblLook → the editor's tableLook attr (its band flags are inverted).
|
||||
// null when the file declares none, so the default look applies.
|
||||
function docxTableLook(tblPr: Element | null): string | null {
|
||||
const el = fc(tblPr, 'tblLook');
|
||||
if (!el) return null;
|
||||
const on = (name: string) => {
|
||||
const v = el.getAttributeNS(W, name);
|
||||
return v === '1' || v === 'true';
|
||||
};
|
||||
if (!fc(tblPr, 'tblLook')) return null;
|
||||
const f = docxLookFlags(tblPr);
|
||||
return tableLookAttr({
|
||||
headerRow: on('firstRow'), lastRow: on('lastRow'),
|
||||
firstColumn: on('firstColumn'), lastColumn: on('lastColumn'),
|
||||
bandedRow: !on('noHBand'), bandedColumn: !on('noVBand'),
|
||||
headerRow: f.firstRow, lastRow: f.lastRow, firstColumn: f.firstCol,
|
||||
lastColumn: f.lastCol, bandedRow: f.hBand, bandedColumn: f.vBand,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,8 @@ export class DocxStyles {
|
||||
private ownTabs = new Map<string, TabStop[]>(); // style's own w:pPr/w:tabs
|
||||
private ownCellMar = new Map<string, Element>(); // table style's own w:tblPr/w:tblCellMar
|
||||
private ownTblBorders = new Map<string, Element>(); // table style's own w:tblPr/w:tblBorders
|
||||
private ownCond = new Map<string, Map<string, Element>>(); // table styleId → w:type → w:tblStylePr
|
||||
private ownBandSize = new Map<string, { row: number; col: number }>(); // w:tblStyle*BandSize
|
||||
private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl
|
||||
private numToAbstract = new Map<string, string>();
|
||||
private abstractLevels = new Map<string, Map<number, LevelDef>>();
|
||||
@@ -260,6 +262,19 @@ export class DocxStyles {
|
||||
if (cellMar) this.ownCellMar.set(id, cellMar);
|
||||
const tblBorders = tblPr && firstChild(tblPr, 'tblBorders');
|
||||
if (tblBorders) this.ownTblBorders.set(id, tblBorders);
|
||||
const conds = Array.from(style.children).filter((c) => c.namespaceURI === W && c.localName === 'tblStylePr');
|
||||
if (conds.length) {
|
||||
const m = new Map<string, Element>();
|
||||
for (const c of conds) { const t = c.getAttributeNS(W, 'type'); if (t) m.set(t, c); }
|
||||
this.ownCond.set(id, m);
|
||||
}
|
||||
const bandSize = (name: string) => {
|
||||
const el = tblPr && firstChild(tblPr, name);
|
||||
const n = parseInt((el && wVal(el)) ?? '', 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
};
|
||||
const rowBand = bandSize('tblStyleRowBandSize'), colBand = bandSize('tblStyleColBandSize');
|
||||
if (rowBand || colBand) this.ownBandSize.set(id, { row: rowBand, col: colBand });
|
||||
const kind = style.getAttributeNS(W, 'type');
|
||||
if (kind === 'paragraph' || kind === 'character' || kind === 'table') {
|
||||
const nameEl = firstChild(style, 'name');
|
||||
@@ -481,6 +496,26 @@ export class DocxStyles {
|
||||
return own ? [own, ...rest] : rest;
|
||||
}
|
||||
|
||||
// The table style's conditional areas (w:tblStylePr) along the w:basedOn chain: Word's
|
||||
// "first row", "banded row 1", … each carrying the w:tcPr and w:rPr it paints. Leaf wins
|
||||
// per area, so a derived style replaces the whole area rather than merging into it.
|
||||
tableConditional(styleId: string | null | undefined, seen = new Set<string>()): Map<string, Element> {
|
||||
if (!styleId || seen.has(styleId)) return new Map();
|
||||
seen.add(styleId);
|
||||
const base = this.tableConditional(this.basedOn.get(styleId) ?? null, seen);
|
||||
for (const [type, el] of this.ownCond.get(styleId) ?? []) base.set(type, el);
|
||||
return base;
|
||||
}
|
||||
|
||||
// How many rows/columns one band spans (w:tblStyleRowBandSize), 1 where none is declared.
|
||||
tableBandSize(styleId: string | null | undefined, seen = new Set<string>()): { row: number; col: number } {
|
||||
if (!styleId || seen.has(styleId)) return { row: 1, col: 1 };
|
||||
seen.add(styleId);
|
||||
const base = this.tableBandSize(this.basedOn.get(styleId) ?? null, seen);
|
||||
const own = this.ownBandSize.get(styleId);
|
||||
return { row: own?.row || base.row, col: own?.col || base.col };
|
||||
}
|
||||
|
||||
level(numId: number, ilvl: number): LevelDef {
|
||||
const abs = this.numToAbstract.get(String(numId));
|
||||
if (!abs) return {};
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Word's table styles paint their conditional areas (w:tblStylePr) — header row, banded
|
||||
// rows, first column. The definition is not in the editor's registry, so import bakes it
|
||||
// into the cells; a re-export then carries the bake, not the areas.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
|
||||
type N = any;
|
||||
|
||||
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
const BLUE = 'single" w:sz="8" w:color="4F81BD';
|
||||
|
||||
const STYLES = `<?xml version="1.0"?>
|
||||
<w:styles xmlns:w="${W}">
|
||||
<w:style w:type="table" w:default="1" w:styleId="NormalTable"><w:name w:val="Normal Table"/></w:style>
|
||||
<w:style w:type="table" w:styleId="Banded"><w:name w:val="Banded Accent"/><w:basedOn w:val="NormalTable"/>
|
||||
<w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblBorders>
|
||||
<w:top w:val="${BLUE}"/><w:left w:val="${BLUE}"/><w:bottom w:val="${BLUE}"/>
|
||||
<w:right w:val="${BLUE}"/><w:insideH w:val="${BLUE}"/><w:insideV w:val="${BLUE}"/>
|
||||
</w:tblBorders></w:tblPr>
|
||||
<w:tblStylePr w:type="firstRow">
|
||||
<w:rPr><w:b/><w:color w:val="FFFFFF"/></w:rPr>
|
||||
<w:tcPr><w:tcBorders><w:bottom w:val="single" w:sz="18" w:color="4F81BD"/><w:insideV w:val="nil"/></w:tcBorders>
|
||||
<w:shd w:val="clear" w:color="auto" w:fill="4F81BD"/></w:tcPr>
|
||||
</w:tblStylePr>
|
||||
<w:tblStylePr w:type="band1Horz">
|
||||
<w:tcPr><w:tcBorders><w:insideH w:val="nil"/></w:tcBorders>
|
||||
<w:shd w:val="clear" w:color="auto" w:fill="D3DFEE"/></w:tcPr>
|
||||
</w:tblStylePr>
|
||||
<w:tblStylePr w:type="firstCol"><w:rPr><w:b/></w:rPr></w:tblStylePr>
|
||||
</w:style>
|
||||
</w:styles>`;
|
||||
|
||||
// 4 rows × 2 columns under Word's default look: header row, first column, row bands.
|
||||
const table = (look = '<w:tblLook w:firstRow="1" w:firstColumn="1" w:noHBand="0" w:noVBand="1"/>') =>
|
||||
`<w:tbl><w:tblPr><w:tblStyle w:val="Banded"/>${look}</w:tblPr>
|
||||
<w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid>` +
|
||||
[0, 1, 2, 3].map((r) =>
|
||||
`<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>';
|
||||
|
||||
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>`),
|
||||
'word/styles.xml': strToU8(STYLES),
|
||||
});
|
||||
|
||||
const cellsOf = (bytes: Uint8Array): N[][] => {
|
||||
const t = (importDocx(bytes).content.content as N[]).find((n: N) => n.type === 'table')!;
|
||||
return t.content.map((row: N) => row.content);
|
||||
};
|
||||
const marksOf = (cell: N): N[] => cell.content[0].content[0].marks ?? [];
|
||||
|
||||
describe('a Word table style paints its conditional areas', () => {
|
||||
const rows = cellsOf(docxWith(table()));
|
||||
|
||||
it('shades the header row and bands from the first body row', () => {
|
||||
expect(rows[0][0].attrs.backgroundColor).toBe('#4F81BD');
|
||||
expect(rows[1][0].attrs.backgroundColor).toBe('#D3DFEE');
|
||||
expect(rows[2][0].attrs.backgroundColor).toBeUndefined();
|
||||
expect(rows[3][0].attrs.backgroundColor).toBe('#D3DFEE');
|
||||
});
|
||||
|
||||
it('bakes the area’s run properties onto the cell’s text', () => {
|
||||
expect(marksOf(rows[0][1])).toEqual([{ type: 'bold' }, { type: 'textStyle', attrs: { color: '#FFFFFF' } }]);
|
||||
expect(marksOf(rows[1][0])).toEqual([{ type: 'bold' }]); // first column
|
||||
expect(marksOf(rows[1][1])).toEqual([]);
|
||||
});
|
||||
|
||||
it('takes an area’s outer side at its edge and its inside line within it', () => {
|
||||
expect(rows[0][0].attrs.borderBottom).toBe('2.25pt solid #4F81BD'); // rule under the header
|
||||
expect(rows[0][0].attrs.borderRight).toBe('none'); // firstRow insideV nil
|
||||
// Only band1 declares insideH, and only a boundary inside the banded region takes it.
|
||||
expect(rows[3][0].attrs.borderTop).toBe('none');
|
||||
expect(rows[2][0].attrs.borderTop).toBe('1pt solid #4F81BD');
|
||||
expect(rows[3][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();
|
||||
expect(off[1][0].attrs.backgroundColor).toBeUndefined();
|
||||
expect(marksOf(off[1][0])).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads the older w:val bitmask where the named flags are absent', () => {
|
||||
const hex = cellsOf(docxWith(table('<w:tblLook w:val="04A0"/>')));
|
||||
expect(hex[0][0].attrs.backgroundColor).toBe('#4F81BD');
|
||||
expect(hex[1][0].attrs.backgroundColor).toBe('#D3DFEE');
|
||||
});
|
||||
|
||||
it('carries the bake back out, since the exported style has no areas', async () => {
|
||||
const doc = { type: 'doc', content: [{ type: 'table', content: importDocx(docxWith(table())).content.content[0].content }] };
|
||||
const bytes = await buildDocx(doc as N, { top: 2, bottom: 2, left: 2, right: 2 });
|
||||
expect(strFromU8(unzipSync(bytes)['word/document.xml'])).not.toContain('tblStylePr');
|
||||
const again = cellsOf(bytes);
|
||||
expect(again[0][0].attrs.backgroundColor).toBe('#4F81BD');
|
||||
expect(again[2][0].attrs.backgroundColor).toBeUndefined();
|
||||
expect(marksOf(again[1][0])).toEqual([{ type: 'bold' }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user