diff --git a/ts/docx/document/index.ts b/ts/docx/document/index.ts index 555ebf55bf..7b0d99aaed 100644 --- a/ts/docx/document/index.ts +++ b/ts/docx/document/index.ts @@ -1,7 +1,9 @@ import { Paragraph } from "../paragraph"; +import { Table } from "../table"; import { XmlComponent } from "../xml-components"; import { Body } from "./body"; import { DocumentAttributes } from "./document-attributes"; + export class Document extends XmlComponent { private body: Body; @@ -39,4 +41,15 @@ export class Document extends XmlComponent { this.addParagraph(para); return para; } + + public addTable(table: Table): void { + this.body.push(table); + } + + public createTable(rows: number, cols: number): Table { + const table = new Table(rows, cols); + this.addTable(table); + return table; + } + } diff --git a/ts/docx/index.ts b/ts/docx/index.ts index 3c7ccdd38a..875821c29a 100644 --- a/ts/docx/index.ts +++ b/ts/docx/index.ts @@ -2,3 +2,4 @@ export { Document } from "./document"; export { Paragraph } from "./paragraph"; export { Run } from "./run"; export { TextRun } from "./run/text-run"; +export { Table } from "./table"; diff --git a/ts/docx/table.ts b/ts/docx/table.ts deleted file mode 100644 index fa2be84ac5..0000000000 --- a/ts/docx/table.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class Table { - -} diff --git a/ts/docx/table/grid.ts b/ts/docx/table/grid.ts new file mode 100644 index 0000000000..3731891177 --- /dev/null +++ b/ts/docx/table/grid.ts @@ -0,0 +1,21 @@ +import { XmlAttributeComponent, XmlComponent } from "../xml-components"; + +export class TableGrid extends XmlComponent { + constructor(cols: number[]) { + super("w:tblGrid"); + cols.forEach((col) => this.root.push(new GridCol(col))); + } +} + +class GridColAttributes extends XmlAttributeComponent<{w: number}> { + protected xmlKeys = {w: "w:w"}; +} + +export class GridCol extends XmlComponent { + constructor(width?: number) { + super("w:gridCol"); + if (width !== undefined) { + this.root.push(new GridColAttributes({w: width})); + } + } +} diff --git a/ts/docx/table/index.ts b/ts/docx/table/index.ts new file mode 100644 index 0000000000..f6be964129 --- /dev/null +++ b/ts/docx/table/index.ts @@ -0,0 +1,123 @@ +import { Paragraph } from "../paragraph"; +import { XmlComponent } from "../xml-components"; + +import { TableGrid } from "./grid"; +import { TableProperties, widthTypes } from "./properties"; + +export class Table extends XmlComponent { + private properties: TableProperties; + private rows: TableRow[]; + private grid: TableGrid; + + constructor(rows: number, cols: number) { + super("w:tbl"); + this.properties = new TableProperties(); + this.root.push(this.properties); + + const gridCols: number[] = []; + for (let i = 0; i < cols; i++) { + /* + 0-width columns don't get rendered correctly, so we need + to give them some value. A reasonable default would be + ~6in / numCols, but if we do that it becomes very hard + to resize the table using setWidth, unless the layout + algorithm is set to 'fixed'. Instead, the approach here + means even in 'auto' layout, setting a width on the + table will make it look reasonable, as the layout + algorithm will expand columns to fit its content + */ + gridCols.push(1); + } + this.grid = new TableGrid(gridCols); + this.root.push(this.grid); + + this.rows = []; + for (let i = 0; i < rows; i++) { + const cells: TableCell[] = []; + for (let j = 0; j < cols; j++) { + cells.push(new TableCell()); + } + const row = new TableRow(cells); + this.rows.push(row); + this.root.push(row); + } + } + + public getRow(ix: number): TableRow { + return this.rows[ix]; + } + + public getCell(row: number, col: number): TableCell { + return this.getRow(row).getCell(col); + } + + public setWidth(type: widthTypes, width: number | string): Table { + this.properties.setWidth(type, width); + return this; + } + + public fixedWidthLayout(): Table { + this.properties.fixedWidthLayout(); + return this; + } +} + +class TableRow extends XmlComponent { + private properties: TableRowProperties; + private cells: TableCell[]; + + constructor(cells: TableCell[]) { + super("w:tr"); + this.properties = new TableRowProperties(); + this.root.push(this.properties); + this.cells = cells; + cells.forEach((c) => this.root.push(c)); + } + + public getCell(ix: number): TableCell { + return this.cells[ix]; + } +} + +class TableRowProperties extends XmlComponent { + constructor() { + super("w:trPr"); + } +} + +class TableCell extends XmlComponent { + private properties: TableCellProperties; + + constructor() { + super("w:tc"); + this.properties = new TableCellProperties(); + this.root.push(this.properties); + } + + public addContent(content: Paragraph | Table): TableCell { + this.root.push(content); + return this; + } + + public prepForXml(): object { + // Cells must end with a paragraph + const retval = super.prepForXml(); + const content = retval["w:tc"]; + if (!content[content.length - 1]["w:p"]) { + content.push(new Paragraph().prepForXml()); + } + return retval; + } + + public createParagraph(text?: string): Paragraph { + const para = new Paragraph(text); + this.addContent(para); + return para; + } +} + +class TableCellProperties extends XmlComponent { + constructor() { + super("w:tcPr"); + } +} diff --git a/ts/docx/table/properties.ts b/ts/docx/table/properties.ts new file mode 100644 index 0000000000..ab487e58fb --- /dev/null +++ b/ts/docx/table/properties.ts @@ -0,0 +1,48 @@ +import { XmlAttributeComponent, XmlComponent } from "../xml-components"; + +export type widthTypes = "dxa" | "pct" | "nil" | "auto"; + +export class TableProperties extends XmlComponent { + constructor() { + super("w:tblPr"); + } + + public setWidth(type: widthTypes, w: number | string): TableProperties { + this.root.push(new PreferredTableWidth(type, w)); + return this; + } + + public fixedWidthLayout(): TableProperties { + this.root.push(new TableLayout("fixed")); + return this; + } +} + +interface ITableWidth { + type: widthTypes; + w: number | string; +} + +class TableWidthAttributes extends XmlAttributeComponent { + protected xmlKeys = {type: "w:type", w: "w:w"}; +} + +class PreferredTableWidth extends XmlComponent { + constructor(type: widthTypes, w: number | string) { + super("w:tblW"); + this.root.push(new TableWidthAttributes({type, w})); + } +} + +type tableLayout = "autofit" | "fixed"; + +class TableLayoutAttributes extends XmlAttributeComponent<{type: tableLayout}> { + protected xmlKeys = {type: "w:type"}; +} + +class TableLayout extends XmlComponent { + constructor(type: tableLayout) { + super("w:tblLayout"); + this.root.push(new TableLayoutAttributes({type})); + } +} diff --git a/ts/test-tsconfig.json b/ts/test-tsconfig.json index 844ea999ff..255c0e089c 100644 --- a/ts/test-tsconfig.json +++ b/ts/test-tsconfig.json @@ -1,7 +1,12 @@ { "compilerOptions": { "target": "es6", + "strictNullChecks": true, + "sourceMap": true, + "removeComments": true, + "preserveConstEnums": true, "outDir": "../build-tests", + "sourceRoot": "./", "rootDir": "./", "module": "commonjs" } diff --git a/ts/tests/docx/document/documentTest.ts b/ts/tests/docx/document/documentTest.ts index 98b9a9cf97..9531768e2d 100644 --- a/ts/tests/docx/document/documentTest.ts +++ b/ts/tests/docx/document/documentTest.ts @@ -46,4 +46,28 @@ describe("Document", () => { }); }); }); + + describe("#createTable", () => { + it("should create a new table and append it to body", () => { + const table = document.createTable(2, 3); + expect(table).to.be.an.instanceof(docx.Table); + const body = new Formatter().format(document)["w:document"][1]["w:body"]; + expect(body).to.be.an("array").which.has.length.at.least(1); + expect(body[0]).to.have.property("w:tbl"); + }); + + it("should create a table with the correct dimensions", () => { + document.createTable(2, 3); + const body = new Formatter().format(document)["w:document"][1]["w:body"]; + expect(body).to.be.an("array").which.has.length.at.least(1); + expect(body[0]).to.have.property("w:tbl").which.includes({ + "w:tblGrid": [ + {"w:gridCol": [{_attr: {"w:w": 1}}]}, + {"w:gridCol": [{_attr: {"w:w": 1}}]}, + {"w:gridCol": [{_attr: {"w:w": 1}}]}, + ], + }); + expect(body[0]["w:tbl"].filter((x) => x["w:tr"])).to.have.length(2); + }); + }); }); diff --git a/ts/tests/docx/table/testGrid.ts b/ts/tests/docx/table/testGrid.ts new file mode 100644 index 0000000000..5eb234fd29 --- /dev/null +++ b/ts/tests/docx/table/testGrid.ts @@ -0,0 +1,37 @@ +import { expect } from "chai"; +import { GridCol, TableGrid } from "../../../docx/table/grid"; +import { Formatter } from "../../../export/formatter"; + +describe("GridCol", () => { + describe("#constructor", () => { + it("sets the width attribute to the value given", () => { + const grid = new GridCol(1234); + const tree = new Formatter().format(grid); + expect(tree).to.deep.equal({ + "w:gridCol": [{_attr: {"w:w": 1234}}], + }); + }); + + it("does not set a width attribute if not given", () => { + const grid = new GridCol(); + const tree = new Formatter().format(grid); + expect(tree).to.deep.equal({"w:gridCol": []}); + }); + }); +}); + +describe("TableGrid", () => { + describe("#constructor", () => { + it("creates a column for each width given", () => { + const grid = new TableGrid([1234, 321, 123]); + const tree = new Formatter().format(grid); + expect(tree).to.deep.equal({ + "w:tblGrid": [ + {"w:gridCol": [{_attr: {"w:w": 1234}}]}, + {"w:gridCol": [{_attr: {"w:w": 321}}]}, + {"w:gridCol": [{_attr: {"w:w": 123}}]}, + ], + }); + }); + }); +}); diff --git a/ts/tests/docx/table/testProperties.ts b/ts/tests/docx/table/testProperties.ts new file mode 100644 index 0000000000..427419cbd8 --- /dev/null +++ b/ts/tests/docx/table/testProperties.ts @@ -0,0 +1,37 @@ +import { expect } from "chai"; +import { TableProperties } from "../../../docx/table/properties"; +import { Formatter } from "../../../export/formatter"; + +describe("TableProperties", () => { + describe("#constructor", () => { + it("creates an initially empty property object", () => { + const tp = new TableProperties(); + const tree = new Formatter().format(tp); + expect(tree).to.deep.equal({"w:tblPr": []}); + }); + }); + + describe("#setWidth", () => { + it("adds a table width property", () => { + const tp = new TableProperties().setWidth("dxa", 1234); + const tree = new Formatter().format(tp); + expect(tree).to.deep.equal({ + "w:tblPr": [ + {"w:tblW": [{_attr: {"w:type": "dxa", "w:w": 1234}}]}, + ], + }); + }); + }); + + describe("#fixedWidthLayout", () => { + it("sets the table to fixed width layout", () => { + const tp = new TableProperties().fixedWidthLayout(); + const tree = new Formatter().format(tp); + expect(tree).to.deep.equal({ + "w:tblPr": [ + {"w:tblLayout": [{_attr: {"w:type": "fixed"}}]}, + ], + }); + }); + }); +}); diff --git a/ts/tests/docx/table/testTable.ts b/ts/tests/docx/table/testTable.ts new file mode 100644 index 0000000000..a88cc1b79b --- /dev/null +++ b/ts/tests/docx/table/testTable.ts @@ -0,0 +1,185 @@ +import { expect } from "chai"; +import { Paragraph } from "../../../docx/paragraph"; +import { Table } from "../../../docx/table"; +import { Formatter } from "../../../export/formatter"; + +describe("Table", () => { + describe("#constructor", () => { + it("creates a table with the correct number of rows and columns", () => { + const table = new Table(3, 2); + const tree = new Formatter().format(table); + const cell = {"w:tc": [{"w:tcPr": []}, {"w:p": [{"w:pPr": []}]}]}; + expect(tree).to.deep.equal({ + "w:tbl": [ + {"w:tblPr": []}, + {"w:tblGrid": [ + {"w:gridCol": [{_attr: {"w:w": 1}}]}, + {"w:gridCol": [{_attr: {"w:w": 1}}]}, + ]}, + {"w:tr": [{"w:trPr": []}, cell, cell]}, + {"w:tr": [{"w:trPr": []}, cell, cell]}, + {"w:tr": [{"w:trPr": []}, cell, cell]}, + ], + }); + }); + }); + + describe("#getRow and Row#getCell", () => { + it("returns the correct row", () => { + const table = new Table(2, 2); + table.getRow(0).getCell(0).addContent(new Paragraph("A1")); + table.getRow(0).getCell(1).addContent(new Paragraph("B1")); + table.getRow(1).getCell(0).addContent(new Paragraph("A2")); + table.getRow(1).getCell(1).addContent(new Paragraph("B2")); + const tree = new Formatter().format(table); + const cell = (c) => ({"w:tc": [ + {"w:tcPr": []}, + {"w:p": [ + {"w:pPr": []}, + {"w:r": [{"w:rPr": []}, {"w:t": [c]}]}, + ]}, + ]}); + expect(tree).to.deep.equal({ + "w:tbl": [ + {"w:tblPr": []}, + {"w:tblGrid": [ + {"w:gridCol": [{_attr: {"w:w": 1}}]}, + {"w:gridCol": [{_attr: {"w:w": 1}}]}, + ]}, + {"w:tr": [{"w:trPr": []}, cell("A1"), cell("B1")]}, + {"w:tr": [{"w:trPr": []}, cell("A2"), cell("B2")]}, + ], + }); + }); + }); + + describe("#getCell", () => { + it("returns the correct cell", () => { + const table = new Table(2, 2); + table.getCell(0, 0).addContent(new Paragraph("A1")); + table.getCell(0, 1).addContent(new Paragraph("B1")); + table.getCell(1, 0).addContent(new Paragraph("A2")); + table.getCell(1, 1).addContent(new Paragraph("B2")); + const tree = new Formatter().format(table); + const cell = (c) => ({"w:tc": [ + {"w:tcPr": []}, + {"w:p": [ + {"w:pPr": []}, + {"w:r": [{"w:rPr": []}, {"w:t": [c]}]}, + ]}, + ]}); + expect(tree).to.deep.equal({ + "w:tbl": [ + {"w:tblPr": []}, + {"w:tblGrid": [ + {"w:gridCol": [{_attr: {"w:w": 1}}]}, + {"w:gridCol": [{_attr: {"w:w": 1}}]}, + ]}, + {"w:tr": [{"w:trPr": []}, cell("A1"), cell("B1")]}, + {"w:tr": [{"w:trPr": []}, cell("A2"), cell("B2")]}, + ], + }); + }); + }); + + describe("#setWidth", () => { + it("sets the preferred width on the table", () => { + const table = new Table(2, 2).setWidth("pct", 1000); + const tree = new Formatter().format(table); + expect(tree).to.have.property("w:tbl").which.is.an("array").with.has.length.at.least(1); + expect(tree["w:tbl"][0]).to.deep.equal({ + "w:tblPr": [ + {"w:tblW": [{_attr: {"w:type": "pct", "w:w": 1000}}]}, + ], + }); + }); + }); + + describe("#fixedWidthLayout", () => { + it("sets the table to fixed width layout", () => { + const table = new Table(2, 2).fixedWidthLayout(); + const tree = new Formatter().format(table); + expect(tree).to.have.property("w:tbl").which.is.an("array").with.has.length.at.least(1); + expect(tree["w:tbl"][0]).to.deep.equal({ + "w:tblPr": [ + {"w:tblLayout": [{_attr: {"w:type": "fixed"}}]}, + ], + }); + }); + }); + + describe("Cell", () => { + describe("#prepForXml", () => { + it("inserts a paragraph at the end of the cell if it is empty", () => { + const table = new Table(1, 1); + const tree = new Formatter().format(table); + expect(tree).to.have.property("w:tbl").which.is.an("array"); + const row = tree["w:tbl"].find((x) => x["w:tr"]); + expect(row).not.to.be.undefined; + expect(row["w:tr"]).to.be.an("array").which.has.length.at.least(1); + expect(row["w:tr"].find((x) => x["w:tc"])).to.deep.equal({ + "w:tc": [ + {"w:tcPr": []}, + {"w:p": [{"w:pPr": []}]}, + ], + }); + }); + + it("inserts a paragraph at the end of the cell even if it has a child table", () => { + const parentTable = new Table(1, 1); + parentTable.getCell(0, 0).addContent(new Table(1, 1)); + const tree = new Formatter().format(parentTable); + expect(tree).to.have.property("w:tbl").which.is.an("array"); + const row = tree["w:tbl"].find((x) => x["w:tr"]); + expect(row).not.to.be.undefined; + expect(row["w:tr"]).to.be.an("array").which.has.length.at.least(1); + const cell = row["w:tr"].find((x) => x["w:tc"]); + expect(cell).not.to.be.undefined; + expect(cell["w:tc"][cell["w:tc"].length - 1]).to.deep.equal({ + "w:p": [{"w:pPr": []}], + }); + }); + + it("does not insert a paragraph if it already ends with one", () => { + const parentTable = new Table(1, 1); + parentTable.getCell(0, 0).addContent(new Paragraph("Hello")); + const tree = new Formatter().format(parentTable); + expect(tree).to.have.property("w:tbl").which.is.an("array"); + const row = tree["w:tbl"].find((x) => x["w:tr"]); + expect(row).not.to.be.undefined; + expect(row["w:tr"]).to.be.an("array").which.has.length.at.least(1); + expect(row["w:tr"].find((x) => x["w:tc"])).to.deep.equal({ + "w:tc": [ + {"w:tcPr": []}, + {"w:p": [ + {"w:pPr": []}, + {"w:r": [{"w:rPr": []}, {"w:t": ["Hello"]}]}, + ]}, + ], + }); + }); + }); + + describe("#createParagraph", () => { + it("inserts a new paragraph in the cell", () => { + const table = new Table(1, 1); + const para = table.getCell(0, 0).createParagraph("Test paragraph"); + expect(para).to.be.an.instanceof(Paragraph); + const tree = new Formatter().format(table); + expect(tree).to.have.property("w:tbl").which.is.an("array"); + const row = tree["w:tbl"].find((x) => x["w:tr"]); + expect(row).not.to.be.undefined; + expect(row["w:tr"]).to.be.an("array").which.has.length.at.least(1); + expect(row["w:tr"].find((x) => x["w:tc"])).to.deep.equal({ + "w:tc": [ + {"w:tcPr": []}, + {"w:p": [ + {"w:pPr": []}, + {"w:r": [{"w:rPr": []}, {"w:t": ["Test paragraph"]}]}, + ]}, + ], + }); + }); + }); + }); +});