Preserve DOCX table column spans

This commit is contained in:
Курнат Андрей
2026-06-04 21:14:53 +03:00
parent 74db17e348
commit 5def87e738
3 changed files with 66 additions and 6 deletions
+16
View File
@@ -349,6 +349,22 @@ describe("wordOffice helpers", () => {
]);
});
it("preserves HTML table colspan as DOCX gridSpan", async () => {
const html = '<table><tr><td colspan="2">Merged</td><td>Tail</td></tr></table>';
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:gridSpan\b[^>]*w:val="2"[^>]*\/>/);
});
it("сохраняет пустые ячейки и строки таблицы для DOCX", async () => {
const html = `
<table>
+31 -6
View File
@@ -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 ? `<td>${content}</td>` : "<td></td>";
const colSpan = orderedDocxTableCellColspan(cell);
const attributes = colSpan ? ` colspan="${colSpan}"` : "";
return content ? `<td${attributes}>${content}</td>` : `<td${attributes}></td>`;
})
.join("");
@@ -1133,6 +1142,12 @@ function orderedDocxTableToHtml(
return { html: rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : "", 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) {
+19
View File
@@ -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",
'<table><tr><td colspan="2">Merged</td><td>Tail</td></tr></table>'
);
const file = new File([blob], "Table colspan.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
const imported = await importDocxFile(file);
expect(imported.html).toContain('<td colspan="2"><p>Merged</p></td>');
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",