fix: a table style's paragraph spacing no longer overrides the cell's own style
Word ranks a table style's w:pPr under the paragraph style, so a style zeroing w:after does not take the space after off a Normal that declares one — probed against soffice, which keeps 8pt there while its w:line=240 still beats docDefaults' 276. Read as an override, every cell paragraph lost its spacing. Spacing in a cell now layers docDefaults ← table style ← the paragraph's own chain (baked in, since a cell carries no style name) ← direct w:pPr. Corpus 934 → 900; the fixture it was measured on 66 → 34, its page count exact for the first time (50 against 50, from 44).
This commit is contained in:
1 parent
3301aba30e
commit
24d686232e
4 files changed
+67
-12
No files matched your search
@@ -69,6 +69,12 @@ layer in Word's ascending precedence, over the table's borders and under the cel
|
||||
`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.
|
||||
|
||||
**A table style's `w:pPr` ranks *below* the paragraph style**, so a cell's spacing layers
|
||||
docDefaults ← the table style ← the cell paragraph's own style chain ← direct `w:pPr`
|
||||
(`paragraphSpacing(id, under)`). The chain has to be baked in because a cell carries no
|
||||
style name; read as an override the way a table style zeroing `w:after` reads, every table
|
||||
loses the space after its cells' paragraphs and a long document comes out pages short.
|
||||
|
||||
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.
|
||||
@@ -834,11 +834,12 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
|
||||
// paragraph style commonly carries justify), so style-level alignment isn't lost.
|
||||
const directJc = fc(ppr, 'jc');
|
||||
const jcVal = directJc ? wVal(directJc) : ctx.styles.paragraphAlign(pStyle ? wVal(pStyle) : null);
|
||||
// Spacing/alignment from the style now live in the style registry, so only DIRECT
|
||||
// w:pPr counts as formatting on the block — except in a cell, where the table style's
|
||||
// w:pPr governs and no registry style reaches the cell's paragraphs.
|
||||
const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null, kind === 'cell' ? ctx.cellSpacing : {});
|
||||
const styleId = styleIdOf(ppr, ctx);
|
||||
// Only DIRECT w:pPr counts as formatting on the block; the style's own lives in the
|
||||
// registry — except in a cell, which carries no style name, so its chain is baked in
|
||||
// over the table style's w:pPr (probed: that ranks *below* the paragraph style).
|
||||
const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null,
|
||||
kind === 'cell' ? ctx.styles.paragraphSpacing(styleId, ctx.cellSpacing) : {});
|
||||
applyContextualSpacing(el, ppr, ctx, styleId, attrs);
|
||||
// Widow-orphan control has no registry home, so the resolved value rides the block.
|
||||
const directWc = fc(ppr, 'widowControl');
|
||||
@@ -1847,12 +1848,12 @@ function bakeCellRuns(nodes: Node[], props: RunProps): void {
|
||||
}
|
||||
|
||||
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.
|
||||
// The table style's own w:pPr/w:spacing reaches its cells' paragraphs (Word's Table Grid
|
||||
// zeroes the space after and the line spacing), under their own style chain. Restored
|
||||
// for a nested table.
|
||||
const outerSpacing = ctx.cellSpacing;
|
||||
const tblStyleEl = fc(fc(tbl, 'tblPr'), 'tblStyle');
|
||||
const tblStyleId = tblStyleEl ? wVal(tblStyleEl) : null;
|
||||
if (tblStyleId) ctx.cellSpacing = ctx.styles.paragraphSpacing(tblStyleId);
|
||||
ctx.cellSpacing = ctx.styles.tableSpacing(tblStyleEl ? wVal(tblStyleEl) : null);
|
||||
try {
|
||||
return buildTable(tbl, ctx);
|
||||
} finally {
|
||||
|
||||
@@ -478,10 +478,16 @@ export class DocxStyles {
|
||||
}
|
||||
|
||||
// Effective paragraph spacing from styles only (direct w:pPr/w:spacing wins in the caller):
|
||||
// docDefaults ← the pStyle's basedOn chain, or the default paragraph style when there is no
|
||||
// w:pStyle. An attribute no layer sets is Word's implied 0 (applied in blockAttrs).
|
||||
paragraphSpacing(pStyleId: string | null | undefined): ParaSpacing {
|
||||
return { ...this.defaultsSpacing, ...this.styleSpacing(pStyleId ?? this.defaultParaStyle) };
|
||||
// docDefaults ← `under` ← the pStyle's basedOn chain, or the default paragraph style when
|
||||
// there is no w:pStyle. An attribute no layer sets is Word's implied 0 (blockAttrs).
|
||||
paragraphSpacing(pStyleId: string | null | undefined, under: ParaSpacing = {}): ParaSpacing {
|
||||
return { ...this.defaultsSpacing, ...under, ...this.styleSpacing(pStyleId ?? this.defaultParaStyle) };
|
||||
}
|
||||
|
||||
// A table style's own w:pPr/w:spacing along its w:basedOn chain. Word ranks it under
|
||||
// the paragraph style, so it goes in as `under` above, never on top.
|
||||
tableSpacing(styleId: string | null | undefined): ParaSpacing {
|
||||
return this.styleSpacing(styleId);
|
||||
}
|
||||
|
||||
// The table style's own w:tblCellMar along the w:basedOn chain (leaf wins). The
|
||||
|
||||
@@ -117,3 +117,45 @@ describe('a Word table style paints its conditional areas', () => {
|
||||
expect(marksOf(again[1][0])).toEqual([{ type: 'bold' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// docDefaults ← the table style's w:pPr ← the paragraph style's chain ← direct w:pPr.
|
||||
// Probed against soffice: a table style zeroing the space after does not take it off a
|
||||
// Normal that declares one, but its line spacing does beat docDefaults'.
|
||||
const SPACING_STYLES = `<?xml version="1.0"?>
|
||||
<w:styles xmlns:w="${W}">
|
||||
<w:docDefaults><w:pPrDefault><w:pPr>
|
||||
<w:spacing w:after="200" w:line="276" w:lineRule="auto"/>
|
||||
</w:pPr></w:pPrDefault></w:docDefaults>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:after="160"/></w:pPr></w:style>
|
||||
<w:style w:type="table" w:styleId="Tight"><w:name w:val="Tight"/>
|
||||
<w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/></w:pPr></w:style>
|
||||
</w:styles>`;
|
||||
|
||||
const spacedTable = (cellPPr = '') =>
|
||||
zipSync({
|
||||
'word/document.xml': strToU8(`<?xml version="1.0"?><w:document xmlns:w="${W}"><w:body>` +
|
||||
`<w:tbl><w:tblPr><w:tblStyle w:val="Tight"/></w:tblPr><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>` +
|
||||
`<w:tr><w:tc><w:p>${cellPPr}<w:r><w:t>cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
|
||||
`</w:body></w:document>`),
|
||||
'word/styles.xml': strToU8(SPACING_STYLES),
|
||||
});
|
||||
|
||||
describe('a table style’s w:pPr ranks under the paragraph style', () => {
|
||||
const attrsOf = (bytes: Uint8Array) => cellsOf(bytes)[0][0].content[0].attrs;
|
||||
|
||||
it('keeps the paragraph style’s space after and takes the table style’s line', () => {
|
||||
expect(attrsOf(spacedTable())).toMatchObject({ spaceAfter: 8, lineHeight: '1' });
|
||||
});
|
||||
|
||||
it('lets direct w:pPr beat both', () => {
|
||||
const direct = spacedTable('<w:pPr><w:spacing w:after="480" w:line="480" w:lineRule="auto"/></w:pPr>');
|
||||
expect(attrsOf(direct)).toMatchObject({ spaceAfter: 24, lineHeight: '2' });
|
||||
});
|
||||
|
||||
it('round-trips the baked values', async () => {
|
||||
const doc = { type: 'doc', content: importDocx(spacedTable()).content.content };
|
||||
const bytes = await buildDocx(doc as N, { top: 2, bottom: 2, left: 2, right: 2 });
|
||||
expect(attrsOf(bytes)).toMatchObject({ spaceAfter: 8, lineHeight: '1' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user