Preserve DOCX table column spans
This commit is contained in:
@@ -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 () => {
|
it("сохраняет пустые ячейки и строки таблицы для DOCX", async () => {
|
||||||
const html = `
|
const html = `
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
+31
-6
@@ -25,7 +25,7 @@ export interface WordListItem {
|
|||||||
runs: WordInlineRun[];
|
runs: WordInlineRun[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WordTableCell = string | { text: string; runs: WordInlineRun[] };
|
export type WordTableCell = string | { text: string; runs: WordInlineRun[]; colSpan?: number };
|
||||||
|
|
||||||
export interface WordImageBlock {
|
export interface WordImageBlock {
|
||||||
type: "image";
|
type: "image";
|
||||||
@@ -406,9 +406,7 @@ function wordBlockToDocxChildren(block: WordBlock, docx: DocxModule) {
|
|||||||
new docx.TableRow({
|
new docx.TableRow({
|
||||||
children: row.map(
|
children: row.map(
|
||||||
(cell) =>
|
(cell) =>
|
||||||
new docx.TableCell({
|
new docx.TableCell(wordTableCellToDocxOptions(cell, docx))
|
||||||
children: wordTableCellToDocxParagraphs(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) {
|
function wordTableCellToDocxParagraphs(cell: WordTableCell, docx: DocxModule) {
|
||||||
const runs = typeof cell === "string" ? [{ text: cell }] : cell.runs;
|
const runs = typeof cell === "string" ? [{ text: cell }] : cell.runs;
|
||||||
return [
|
return [
|
||||||
@@ -662,7 +668,8 @@ function tableCellValue(cell: Element): WordTableCell {
|
|||||||
const runs = elementInlineRuns(cell);
|
const runs = elementInlineRuns(cell);
|
||||||
const text = inlineRunsText(runs);
|
const text = inlineRunsText(runs);
|
||||||
const hasFormattedRuns = runs.some((run) => Object.keys(cleanInlineFormat(run)).length > 0);
|
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[] {
|
function paragraphElementToBlocks(element: Element, options: { quote?: boolean } = {}): WordBlock[] {
|
||||||
@@ -1122,7 +1129,9 @@ function orderedDocxTableToHtml(
|
|||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const content = paragraphs.length > 0 ? paragraphs.join("") : escapeHtml(orderedDocxCellText(cell));
|
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("");
|
.join("");
|
||||||
|
|
||||||
@@ -1133,6 +1142,12 @@ function orderedDocxTableToHtml(
|
|||||||
return { html: rows.length > 0 ? `<table><tbody>${rows.join("")}</tbody></table>` : "", hasImage, hasLineBreak, hasPageBreak, hasTab };
|
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) {
|
function orderedDocxCellText(cell: unknown) {
|
||||||
return orderedDirectElements(cell, "w:p")
|
return orderedDirectElements(cell, "w:p")
|
||||||
.flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {}))
|
.flatMap((paragraph) => orderedDocxParagraphInlineRecords(paragraph, {}))
|
||||||
@@ -1998,6 +2013,16 @@ function numericAttribute(element: Element, name: string) {
|
|||||||
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
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") {
|
function cssPixelValue(style: string, property: "width" | "height") {
|
||||||
const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style);
|
const match = new RegExp(`${property}\\s*:\\s*(\\d+(?:\\.\\d+)?)px`, "i").exec(style);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|||||||
@@ -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 () => {
|
it("imports DOCX bullet and numbered lists from OOXML numbering", async () => {
|
||||||
const blob = await exportDocxBlob(
|
const blob = await exportDocxBlob(
|
||||||
"Lists.docx",
|
"Lists.docx",
|
||||||
|
|||||||
Reference in New Issue
Block a user