fix: DOCX cell spacing accretion and nested-list kind flips

Found by the cross-format fuzz leg. importDocx baked the resolved cell
chain in as direct formatting even where it equals what an unset attr
already renders (0/0 spacing; single line height over a single default) —
every re-imported own file grew spacing attrs on every cell paragraph.
And a numbering level kept the first list kind it saw, so a nested bullet
list beside a nested ordered one came back ordered; a kind conflict now
forks its own numbering reference, as the ODT side mints a list style.
This commit is contained in:
Steffen Becker committed 2026-08-15 22:58:29 +02:00
1 parent d78181fdd9
commit 76856f4aff
3 files changed
+26 -2

No files matched your search

+11 -1
View File
@@ -238,6 +238,11 @@ class Numbering {
return reference;
}
levelKindAt(reference: string, depth: number): 'ordered' | 'bullet' | null {
const l = this.map.get(reference)!.find((lv) => lv.level === depth);
return l ? (l.format === LevelFormat.BULLET ? 'bullet' : 'ordered') : null;
}
ensureLevel(reference: string, depth: number, node: TiptapNode, extraIndentCm: number, cycle: OrderedCycle): void {
// A multilevel top list turns the whole reference into a "%1.%2." chain; its
// attr-less nested lists inherit it (the top registers first — pre-order walk).
@@ -1475,7 +1480,12 @@ function listToParagraphs(
let numberedFirst = false;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
listToParagraphs(child, depth + 1, reference, indentCm, num, out, cChild);
// A level keeps the first kind it saw, so a sibling nested list of the other
// kind forks its own reference (as the ODT side mints its own list style).
const seen = num.levelKindAt(reference, depth + 1);
const ref = seen && seen !== (child.type === 'orderedList' ? 'ordered' : 'bullet')
? num.newReference() : reference;
listToParagraphs(child, depth + 1, ref, indentCm, num, out, cChild);
} else if (child.type === 'paragraph' || child.type === 'heading') {
if (!numberedFirst) {
out.push(paragraphToDocx(child, { numbering: { reference, level: depth } }));
+10
View File
@@ -940,6 +940,16 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null,
kind === 'cell' ? ctx.styles.paragraphSpacing(styleId, ctx.cellSpacing) : {});
applyContextualSpacing(el, ppr, ctx, styleId, attrs);
// The baked-in cell chain must not accrete no-op direct formatting: in a cell,
// unset spacing renders 0/0 (no word processor passes the default style's spacing
// into a cell), and single line height is only kept over a non-single default.
if (kind === 'cell') {
if (attrs.spaceBefore === 0) delete attrs.spaceBefore;
if (attrs.spaceAfter === 0) delete attrs.spaceAfter;
const def = ctx.styles.paragraphSpacing(null);
const defSingle = (def.line ?? 240) === 240 && (!def.lineRule || def.lineRule === 'auto');
if (attrs.lineHeight === '1' && defSingle) delete attrs.lineHeight;
}
// Widow-orphan control has no registry home, so the resolved value rides the block.
const directWc = fc(ppr, 'widowControl');
if (!(directWc ? onOff(directWc) : ctx.styles.paragraphWidowControl(styleId))) {
+5 -1
View File
@@ -156,6 +156,10 @@ describe('a table style’s w:pPr ranks under the paragraph style', () => {
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' });
// Our own export's default line height is single again, so the re-import drops
// the baked '1' as default-equal; the non-default space after stays.
const attrs = attrsOf(bytes);
expect(attrs).toMatchObject({ spaceAfter: 8 });
expect(attrs.lineHeight).toBeUndefined();
});
});