From 15f7e509767c2618bcddffecd5da7cd472e1ca2a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 7 Jul 2026 18:53:58 +0200 Subject: [PATCH] fix: import of columns --- src/lib/import/docx.ts | 2 +- src/lib/import/odt.ts | 21 +++++++++++---- src/lib/import/styleResolver.ts | 19 ++++++++++--- tests/docx-roundtrip.test.ts | 2 +- tests/roundtrip.test.ts | 48 ++++++++++++++++++++++++++++++++- 5 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 95f1253..006114a 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -916,7 +916,7 @@ function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, o if (COLUMNS_ALLOWED.has(block.type)) { run.push(block); } else { - ctx.warnings.add('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns'); + ctx.warnings.add('Tables and text boxes inside a multi-column layout were moved out of the columns'); flush(); out.push(block); } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 478b45f..4afc079 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -286,9 +286,18 @@ export function importOdt(bytes: Uint8Array): OdtImportResult { if (!body) throw new Error('Not a text document (no office:text body).'); const ctx: Ctx = { resolver, warnings, files, imageCache: new Map(), pendingBlocks: [] }; - const blocks = convertBlocks(Array.from(body.children), ctx, 'body'); + let blocks = convertBlocks(Array.from(body.children), ctx, 'body'); if (blocks.length === 0) blocks.push({ type: 'paragraph' }); + // Whole-document columns declared on the page layout (Word-style) instead of a + // text:section: wrap the body's wrappable runs the way a section would be. + const pageCols = resolver.pageColumns(); + if (pageCols) { + const wrapped: Node[] = []; + pushColumnRuns(blocks, pageCols, wrapped, ctx); + blocks = wrapped; + } + const hf = resolver.masterPageHF(); if (hf.hasVariants) { warnings.add('Per-page header/footer variants (first/even pages) are not supported — the default one was used'); @@ -372,9 +381,9 @@ function parseXml(xml: string): Document { // Block types a columns section (columns.ts) can contain. const COLUMNS_ALLOWED = new Set(['paragraph', 'heading', 'bulletList', 'orderedList']); -// Wrap a multi-column 's converted blocks into columns nodes: maximal -// runs of allowed types become one node each; anything else (tables, text boxes, -// nested sections) is emitted between the runs at top level with a warning. +// Wrap a multi-column region's converted blocks into columns nodes: maximal runs of +// allowed types become one node each; anything else is emitted between the runs at +// top level — with a warning, except columns nodes (they keep their own layout). function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, out: Node[], ctx: Ctx): void { let count = cols.count; if (count > 3) { @@ -390,7 +399,9 @@ function pushColumnRuns(inner: Node[], cols: { count: number; gapCm: number }, o if (COLUMNS_ALLOWED.has(block.type)) { run.push(block); } else { - ctx.warnings.add('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns'); + if (block.type !== 'columns') { + ctx.warnings.add('Tables and text boxes inside a multi-column layout were moved out of the columns'); + } flush(); out.push(block); } diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index bd19c29..ce79cff 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -272,12 +272,23 @@ export class StyleResolver { } // A section style's column layout: { count, gapCm } when it declares more than one - // column, else null. The gap falls back to the per-column indent form (first - // column's fo:end-indent + second's fo:start-indent) LibreOffice sometimes writes. + // column, else null. sectionColumns(name: string | null): { count: number; gapCm: number } | null { const el = name ? this.sectionStyleEls.get(name) : null; - if (!el) return null; - const props = el.getElementsByTagNameNS(NS.style, 'section-properties')[0]; + const props = el?.getElementsByTagNameNS(NS.style, 'section-properties')[0]; + return this.columnsFrom(props ?? null); + } + + // Column layout of the master page's page layout — Word-produced files put + // whole-document columns here instead of in a text:section. + pageColumns(): { count: number; gapCm: number } | null { + return this.columnsFrom(this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null); + } + + // under a properties element: { count, gapCm } when it declares more + // than one column, else null. The gap falls back to LibreOffice's per-column indent + // form (first column's fo:end-indent + second's fo:start-indent). + private columnsFrom(props: Element | null): { count: number; gapCm: number } | null { const cols = props?.getElementsByTagNameNS(NS.style, 'columns')[0]; if (!cols) return null; const count = parseInt(cols.getAttributeNS(NS.fo, 'column-count') ?? '', 10); diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 5f0c6c8..86b3701 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -658,6 +658,6 @@ describe('DOCX import of foreign multi-column sections', () => { const result = importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) })); const doc = result.content as N; expect(doc.content!.map((n) => n.type)).toEqual(['columns', 'table', 'columns']); - expect(result.warnings).toContain('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns'); + expect(result.warnings).toContain('Tables and text boxes inside a multi-column layout were moved out of the columns'); }); }); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 2887105..1a5cd02 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -550,6 +550,52 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => { check('foreign: warnings reported', expectWarn.every(w => f.warnings.includes(w)), f.warnings); check('foreign: no hyperlink warning (now round-tripped)', !f.warnings.includes('Hyperlinks were converted to plain text'), f.warnings); }); + + it('wraps the body in columns when the page layout declares them (Word-style)', () => { + const stylesXml = ` + + + + + + + + + + + +`; + + const contentXml = ` + + + + + + + + first + cell + second + in section + +`; + + const foreign = zipSync({ + mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }], + 'content.xml': [strToU8(contentXml), { level: 6 }], + 'styles.xml': [strToU8(stylesXml), { level: 6 }], + } as any); + + const f = importOdt(foreign); + const c = f.content.content!; + + check('page-cols: shape columns/table/columns', c.map((n: N) => n.type).join(',') === 'columns,table,columns', c.map((n: N) => n.type)); + check('page-cols: count 3, gap 0.1965in → 0.5cm', c[0].attrs?.count === 3 && c[0].attrs?.gapCm === 0.5, c[0].attrs); + check('page-cols: 1-col section content joins the run', + c[2].content!.map((n: N) => n.content?.[0]?.text).join(',') === 'second,in section', c[2].content); + check('page-cols: table move-out warned', f.warnings.some(w => w.includes('moved out of the columns')), f.warnings); + }); }); describe('Leg 3: header/footer → buildOdt → importOdt', () => { @@ -915,7 +961,7 @@ describe('Leg 9: foreign multi-column sections → importOdt', () => { c[s2idx + 2]?.type === 'columns' && c[s2idx + 2]?.content?.[0]?.content?.[0]?.text === 'after table', c.slice(s2idx, s2idx + 3).map((n: N) => n.type)); check('S2: move-out warning reported', - f.warnings.includes('Tables, text boxes and nested sections inside a multi-column section were moved out of the columns'), f.warnings); + f.warnings.includes('Tables and text boxes inside a multi-column layout were moved out of the columns'), f.warnings); const s3 = cols[cols.length - 1]; check('S3: count clamped to 3', s3?.attrs?.count === 3 && s3?.attrs?.gapCm === 0.3, s3?.attrs);