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 = '
';
+ 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 ? `| ${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 ? `` : "", 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",
+ ''
+ );
+ 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",