fix: import of columns

This commit is contained in:
Steffen Becker committed 2026-07-08 19:17:42 +00:00
1 parent e6fbf9703c
commit 15f7e50976
5 files changed
+80 -12

No files matched your search

+1 -1
View File
@@ -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);
}
+16 -5
View File
@@ -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 <text:section>'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);
}
+15 -4
View File
@@ -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);
}
// <style:columns> 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);
+1 -1
View File
@@ -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');
});
});
+47 -1
View File
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0">
<office:automatic-styles>
<style:page-layout style:name="pm1">
<style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in">
<style:columns fo:column-count="3" fo:column-gap="0.1965in"/>
</style:page-layout-properties>
</style:page-layout>
</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
</office:master-styles>
</office:document-styles>`;
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0">
<office:automatic-styles>
<style:style style:name="S1" style:family="section">
<style:section-properties><style:columns fo:column-count="1" fo:column-gap="0.5in"/></style:section-properties>
</style:style>
</office:automatic-styles>
<office:body><office:text>
<text:p>first</text:p>
<table:table><table:table-column/><table:table-row><table:table-cell><text:p>cell</text:p></table:table-cell></table:table-row></table:table>
<text:p>second</text:p>
<text:section text:name="Sect1" text:style-name="S1"><text:p>in section</text:p></text:section>
</office:text></office:body>
</office:document-content>`;
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);