diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index bb0258d..4072680 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 } })); diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index ec5ba36..0e49431 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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))) { diff --git a/tests/unit/docx-table-style.test.ts b/tests/unit/docx-table-style.test.ts index e01a030..0b4eabb 100644 --- a/tests/unit/docx-table-style.test.ts +++ b/tests/unit/docx-table-style.test.ts @@ -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(); }); });