From 5def87e738b41adf2168910d0c7cb2e13aa29fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Thu, 4 Jun 2026 21:14:53 +0300 Subject: [PATCH] Preserve DOCX table column spans --- src/io/wordOffice.test.ts | 16 ++++++++++++++ src/io/wordOffice.ts | 37 +++++++++++++++++++++++++++------ src/io/wordOfficeImport.test.ts | 19 +++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/io/wordOffice.test.ts b/src/io/wordOffice.test.ts index 3533205..6a337e8 100644 --- a/src/io/wordOffice.test.ts +++ b/src/io/wordOffice.test.ts @@ -349,6 +349,22 @@ describe("wordOffice helpers", () => { ]); }); + it("preserves HTML table colspan as DOCX gridSpan", async () => { + const html = '
MergedTail
'; + const blocks = htmlToWordBlocks(html); + const blob = await exportDocxBlob("Table colspan.docx", html); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const documentXml = (await zip.file("word/document.xml")?.async("string")) ?? ""; + + expect(blocks).toEqual([ + { + type: "table", + rows: [[{ text: "Merged", runs: [{ text: "Merged" }], colSpan: 2 }, "Tail"]] + } + ]); + expect(documentXml).toMatch(/]*w:val="2"[^>]*\/>/); + }); + it("сохраняет пустые ячейки и строки таблицы для DOCX", async () => { const html = ` diff --git a/src/io/wordOffice.ts b/src/io/wordOffice.ts index 358a7c0..77ab939 100644 --- a/src/io/wordOffice.ts +++ b/src/io/wordOffice.ts @@ -25,7 +25,7 @@ export interface WordListItem { runs: WordInlineRun[]; } -export type WordTableCell = string | { text: string; runs: WordInlineRun[] }; +export type WordTableCell = string | { text: string; runs: WordInlineRun[]; colSpan?: number }; export interface WordImageBlock { type: "image"; @@ -406,9 +406,7 @@ function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) { new docx.TableRow({ children: row.map( (cell) => - new docx.TableCell({ - children: wordTableCellToDocxParagraphs(cell, docx) - }) + new docx.TableCell(wordTableCellToDocxOptions(cell, docx)) ) }) ) @@ -416,6 +414,14 @@ function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) { ]; } +function wordTableCellToDocxOptions(cell: WordTableCell, docx: DocxModule) { + const colSpan = typeof cell === "string" ? undefined : cell.colSpan; + return { + children: wordTableCellToDocxParagraphs(cell, docx), + ...(colSpan ? { columnSpan: colSpan } : {}) + }; +} + function wordTableCellToDocxParagraphs(cell: WordTableCell, docx: DocxModule) { const runs = typeof cell === "string" ? [{ text: cell }] : cell.runs; return [ @@ -662,7 +668,8 @@ function tableCellValue(cell: Element): WordTableCell { const runs = elementInlineRuns(cell); const text = inlineRunsText(runs); const hasFormattedRuns = runs.some((run) => Object.keys(cleanInlineFormat(run)).length > 0); - return hasFormattedRuns ? { text, runs } : text; + const colSpan = normalizedTableSpan(cell.getAttribute("colspan")); + return hasFormattedRuns || colSpan ? { text, runs, ...(colSpan ? { colSpan } : {}) } : text; } function paragraphElementToBlocks(element: Element, options: { quote?: boolean } = {}): WordBlock[] { @@ -1122,7 +1129,9 @@ function orderedDocxTableToHtml( }) .filter(Boolean); const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell)); - return content ? `` : ""; + const colSpan = orderedDocxTableCellColspan(cell); + const attributes = colSpan ? ` colspan="${colSpan}"` : ""; + return content ? `${content}` : ``; }) .join(""); @@ -1133,6 +1142,12 @@ function orderedDocxTableToHtml( return { html: rows.length > 0 ? `
${content}
${rows.join("")}
` : "", hasImage, hasLineBreak, hasPageBreak, hasTab }; } +function orderedDocxTableCellColspan(cell: unknown) { + const properties = firstOrderedElement(orderedElementChildren(cell), "w:tcPr"); + const gridSpan = firstOrderedElement(orderedElementChildren(properties), "w:gridSpan"); + return normalizedTableSpan(docxAttribute(orderedElementAttributes(gridSpan), "w:val") || docxAttribute(orderedElementAttributes(gridSpan), "val")); +} + function orderedDocxCellText(cell: unknown) { return orderedDirectElements(cell, "w:p") .flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {})) @@ -1998,6 +2013,16 @@ function numericAttribute(element: Element, name: string) { return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined; } +function normalizedTableSpan(value?: string | null) { + const raw = value?.trim() ?? ""; + if (!/^\d+$/.test(raw)) { + return undefined; + } + + const span = Number.parseInt(raw, 10); + return Number.isSafeInteger(span) && span > 1 ? Math.min(span, 63) : undefined; +} + function cssPixelValue(style: string, property: "width" | "height") { const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style); if (!match) { diff --git a/src/io/wordOfficeImport.test.ts b/src/io/wordOfficeImport.test.ts index 14d3878..c27ba7a 100644 --- a/src/io/wordOfficeImport.test.ts +++ b/src/io/wordOfficeImport.test.ts @@ -312,6 +312,25 @@ describe("wordOffice DOCX import", () => { ]); }); + it("imports DOCX horizontal merged table cells as HTML colspan", async () => { + const blob = await exportDocxBlob( + "Table colspan.docx", + '
MergedTail
' + ); + const file = new File([blob], "Table colspan.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + const imported = await importDocxFile(file); + + expect(imported.html).toContain('

Merged

'); + expect(htmlToWordBlocks(imported.html)).toEqual([ + { + type: "table", + rows: [[{ text: "Merged", runs: [{ text: "Merged" }], colSpan: 2 }, "Tail"]] + } + ]); + }); + it("imports DOCX bullet and numbered lists from OOXML numbering", async () => { const blob = await exportDocxBlob( "Lists.docx",