diff --git a/package.json b/package.json index 74e591431c..cba0289008 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@types/image-size": "0.0.29", "@types/request-promise": "^4.1.41", "archiver": "^2.1.1", + "fast-xml-parser": "^3.3.6", "image-size": "^0.6.2", "request": "^2.83.0", "request-promise": "^4.2.2", diff --git a/src/file/core-properties/properties.ts b/src/file/core-properties/properties.ts index cd2395c27b..aa9d3aec51 100644 --- a/src/file/core-properties/properties.ts +++ b/src/file/core-properties/properties.ts @@ -10,6 +10,7 @@ export interface IPropertiesOptions { description?: string; lastModifiedBy?: string; revision?: string; + externalStyles?: string; } export class CoreProperties extends XmlComponent { diff --git a/src/file/file.ts b/src/file/file.ts index 67393a8c6f..c60c04d870 100644 --- a/src/file/file.ts +++ b/src/file/file.ts @@ -11,7 +11,9 @@ import { Paragraph, PictureRun } from "./paragraph"; import { Relationships } from "./relationships"; import { Styles } from "./styles"; import { DefaultStylesFactory } from "./styles/factory"; +import { ExternalStylesFactory } from "./styles/external-styles-factory"; import { Table } from "./table"; +import { IMediaData } from "index"; export class File { private readonly document: Document; @@ -28,8 +30,6 @@ export class File { constructor(options?: IPropertiesOptions, sectionPropertiesOptions?: SectionPropertiesOptions) { this.document = new Document(sectionPropertiesOptions); - const stylesFactory = new DefaultStylesFactory(); - this.styles = stylesFactory.newInstance(); if (!options) { options = { @@ -39,6 +39,14 @@ export class File { }; } + if (options.externalStyles) { + const stylesFactory = new ExternalStylesFactory(); + this.styles = stylesFactory.newInstance(options.externalStyles); + } else { + const stylesFactory = new DefaultStylesFactory(); + this.styles = stylesFactory.newInstance(); + } + this.coreProperties = new CoreProperties(options); this.numbering = new Numbering(); this.docRelationships = new Relationships(); @@ -111,6 +119,16 @@ export class File { return this.document.createDrawing(mediaData); } + public createImageData(imageName: string, data: Buffer, width?: number, height?: number): IMediaData { + const mediaData = this.media.addMediaWithData(imageName, data, this.docRelationships.RelationshipCount, width, height); + this.docRelationships.createRelationship( + mediaData.referenceId, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + `media/${mediaData.fileName}`, + ); + return mediaData; + } + public get Document(): Document { return this.document; } diff --git a/src/file/index.ts b/src/file/index.ts index aae389e50a..3d86dafdb2 100644 --- a/src/file/index.ts +++ b/src/file/index.ts @@ -5,3 +5,4 @@ export * from "./numbering"; export * from "./media"; export * from "./drawing"; export * from "./styles"; +export * from "./xml-components"; \ No newline at end of file diff --git a/src/file/media/data.ts b/src/file/media/data.ts index 00836ed962..cbc7d8c5bf 100644 --- a/src/file/media/data.ts +++ b/src/file/media/data.ts @@ -13,8 +13,8 @@ export interface IMediaDataDimensions { export interface IMediaData { referenceId: number; - stream: fs.ReadStream; - path: string; + stream: fs.ReadStream | Buffer; + path?: string; fileName: string; dimensions: IMediaDataDimensions; } diff --git a/src/file/media/media.ts b/src/file/media/media.ts index 5ac8905bc7..7f5c960a55 100644 --- a/src/file/media/media.ts +++ b/src/file/media/media.ts @@ -11,23 +11,10 @@ export class Media { this.map = new Map(); } - public getMedia(key: string): IMediaData { - const data = this.map.get(key); - - if (data === undefined) { - throw new Error(`Cannot find image with the key ${key}`); - } - - return data; - } - - public addMedia(filePath: string, relationshipsCount: number): IMediaData { - const key = path.basename(filePath); - const dimensions = sizeOf(filePath); - + private createMedia(key: string, relationshipsCount, dimensions, data: fs.ReadStream | Buffer, filePath?: string, ) { const imageData = { referenceId: this.map.size + relationshipsCount + 1, - stream: fs.createReadStream(filePath), + stream: data, path: filePath, fileName: key, dimensions: { @@ -45,6 +32,36 @@ export class Media { return imageData; } + public getMedia(key: string): IMediaData { + const data = this.map.get(key); + + if (data === undefined) { + throw new Error(`Cannot find image with the key ${key}`); + } + + return data; + } + + public addMedia(filePath: string, relationshipsCount: number): IMediaData { + const key = path.basename(filePath); + const dimensions = sizeOf(filePath); + return this.createMedia(key, relationshipsCount, dimensions, fs.createReadStream(filePath), filePath); + } + + public addMediaWithData(fileName: string, data: Buffer, relationshipsCount: number, width?, height?): IMediaData { + const key = fileName; + let dimensions; + if (width && height) { + dimensions = { + width: width, + height: height + } + } else { + dimensions = sizeOf(data); + } + + return this.createMedia(key, relationshipsCount, dimensions, data); + } public get array(): IMediaData[] { const array = new Array(); diff --git a/src/file/numbering/index.ts b/src/file/numbering/index.ts index 33832de65b..d7a38258d3 100644 --- a/src/file/numbering/index.ts +++ b/src/file/numbering/index.ts @@ -1 +1,2 @@ export * from "./numbering"; +export * from "./abstract-numbering"; \ No newline at end of file diff --git a/src/file/numbering/numbering.ts b/src/file/numbering/numbering.ts index 427fcec05a..517766987a 100644 --- a/src/file/numbering/numbering.ts +++ b/src/file/numbering/numbering.ts @@ -1,12 +1,14 @@ -import { XmlComponent } from "file/xml-components"; +import { XmlComponent, IXmlableObject } from "file/xml-components"; import { DocumentAttributes } from "../document/document-attributes"; -import { Indent } from "../paragraph/formatting"; import { AbstractNumbering } from "./abstract-numbering"; import { Num } from "./num"; export class Numbering extends XmlComponent { private nextId: number; + private abstractNumbering: Array = []; + private concreteNumbering: Array = []; + constructor() { super("w:numbering"); this.root.push( @@ -32,39 +34,23 @@ export class Numbering extends XmlComponent { ); this.nextId = 0; - - const abstractNumbering = this.createAbstractNumbering(); - - abstractNumbering.createLevel(0, "bullet", "\u25CF", "left").addParagraphProperty(new Indent({ left: 720, hanging: 360 })); - - abstractNumbering.createLevel(1, "bullet", "\u25CB", "left").addParagraphProperty(new Indent({ left: 1440, hanging: 360 })); - - abstractNumbering.createLevel(2, "bullet", "\u25A0", "left").addParagraphProperty(new Indent({ left: 2160, hanging: 360 })); - - abstractNumbering.createLevel(3, "bullet", "\u25CF", "left").addParagraphProperty(new Indent({ left: 2880, hanging: 360 })); - - abstractNumbering.createLevel(4, "bullet", "\u25CB", "left").addParagraphProperty(new Indent({ left: 3600, hanging: 360 })); - - abstractNumbering.createLevel(5, "bullet", "\u25A0", "left").addParagraphProperty(new Indent({ left: 4320, hanging: 360 })); - - abstractNumbering.createLevel(6, "bullet", "\u25CF", "left").addParagraphProperty(new Indent({ left: 5040, hanging: 360 })); - - abstractNumbering.createLevel(7, "bullet", "\u25CB", "left").addParagraphProperty(new Indent({ left: 5760, hanging: 360 })); - - abstractNumbering.createLevel(8, "bullet", "\u25A0", "left").addParagraphProperty(new Indent({ left: 6480, hanging: 360 })); - - this.createConcreteNumbering(abstractNumbering); } public createAbstractNumbering(): AbstractNumbering { const num = new AbstractNumbering(this.nextId++); - this.root.push(num); + this.abstractNumbering.push(num); return num; } public createConcreteNumbering(abstractNumbering: AbstractNumbering): Num { const num = new Num(this.nextId++, abstractNumbering.id); - this.root.push(num); + this.concreteNumbering.push(num); return num; } + + public prepForXml(): IXmlableObject { + this.abstractNumbering.forEach(x => this.root.push(x)); + this.concreteNumbering.forEach(x => this.root.push(x)); + return super.prepForXml(); + } } diff --git a/src/file/paragraph/paragraph.ts b/src/file/paragraph/paragraph.ts index 2c5e007a9b..b9274a4d7a 100644 --- a/src/file/paragraph/paragraph.ts +++ b/src/file/paragraph/paragraph.ts @@ -141,6 +141,11 @@ export class Paragraph extends XmlComponent { return this; } + public setCustomNumbering(numberId: number, indentLevel: number): Paragraph { + this.properties.push(new NumberProperties(numberId, indentLevel)); + return this; + } + public style(styleId: string): Paragraph { this.properties.push(new Style(styleId)); return this; diff --git a/src/file/styles/external-styles-factory.spec.ts b/src/file/styles/external-styles-factory.spec.ts new file mode 100644 index 0000000000..0957c25d9f --- /dev/null +++ b/src/file/styles/external-styles-factory.spec.ts @@ -0,0 +1,160 @@ +import { expect } from "chai"; + +import { ExternalStylesFactory } from "./external-styles-factory"; + +describe("External styles factory", () => { + let externalStyles; + + beforeEach(() => { + externalStyles = ` + + + + + + + + + + + + + + + + + + + + + + + + + + `; + }); + + describe("#parse", () => { + it("should parse w:styles attributes", () => { + const importedStyle = new ExternalStylesFactory().newInstance(externalStyles) as any; + + expect(importedStyle.rootKey).to.equal("w:styles"); + expect(importedStyle.root[0]._attr).to.eql({ + "xmlns:mc": "first", + "xmlns:r": "second", + }); + }); + + it("should parse other child elements of w:styles", () => { + const importedStyle = new ExternalStylesFactory().newInstance(externalStyles) as any; + + expect(importedStyle.root.length).to.equal(5); + expect(importedStyle.root[1]).to.eql({ + deleted: false, + root: [], + rootKey: "w:docDefaults", + }); + expect(importedStyle.root[2]).to.eql({ + _attr: { + "w:defLockedState": "1", + "w:defUIPriority": "99", + }, + deleted: false, + root: [], + rootKey: "w:latentStyles", + }); + }); + + it("should parse styles elements", () => { + const importedStyle = new ExternalStylesFactory().newInstance(externalStyles) as any; + + expect(importedStyle.root.length).to.equal(5); + expect(importedStyle.root[3]).to.eql({ + _attr: { + "w:default": "1", + "w:styleId": "Normal", + "w:type": "paragraph", + }, + deleted: false, + root: [ + { + _attr: { + "w:val": "Normal", + }, + deleted: false, + root: [], + rootKey: "w:name", + }, + { + deleted: false, + root: [], + rootKey: "w:qFormat", + }, + ], + rootKey: "w:style", + }); + + expect(importedStyle.root[4]).to.eql({ + _attr: { + "w:styleId": "Heading1", + "w:type": "paragraph", + }, + deleted: false, + root: [ + { + _attr: { + "w:val": "heading 1", + }, + deleted: false, + root: [], + rootKey: "w:name", + }, + { + _attr: { + "w:val": "Normal", + }, + deleted: false, + root: [], + rootKey: "w:basedOn", + }, + { + deleted: false, + root: [ + { + deleted: false, + root: [], + rootKey: "w:keepNext", + }, + { + deleted: false, + root: [], + rootKey: "w:keepLines", + }, + { + deleted: false, + root: [ + { + _attr: { + "w:color": "auto", + "w:space": "1", + "w:sz": "4", + "w:val": "single", + }, + deleted: false, + root: [], + rootKey: "w:bottom", + }, + ], + rootKey: "w:pBdr", + }, + ], + rootKey: "w:pPr", + }, + ], + rootKey: "w:style", + }); + + }); + }); +}); diff --git a/src/file/styles/external-styles-factory.ts b/src/file/styles/external-styles-factory.ts new file mode 100644 index 0000000000..703f017922 --- /dev/null +++ b/src/file/styles/external-styles-factory.ts @@ -0,0 +1,64 @@ +import { Styles } from "./"; +import * as fastXmlParser from "fast-xml-parser"; +import { ImportedXmlComponent, ImportedRootElementAttributes } from "./../../file/xml-components"; + +const parseOptions = { + ignoreAttributes: false, + attributeNamePrefix: "", + attrNodeName: "_attr", +}; + +export class ExternalStylesFactory { + /** + * Creates new Style based on the given styles. + * Parses the styles and convert them to XmlComponent. + * Example content from styles.xml: + * + * + * + * + * + * ..... + * + * + * + * + * ..... + * + * + * Or any other element will be parsed to + * + * + * @param externalStyles context from styles.xml + */ + public newInstance(externalStyles: string): Styles { + const xmlStyles = fastXmlParser.parse(externalStyles, parseOptions)["w:styles"]; + // create styles with attributes from the parsed xml + const importedStyle = new Styles(new ImportedRootElementAttributes(xmlStyles._attr)); + + // convert other elements (not styles definitions, but default styles and so on ...) + Object.keys(xmlStyles) + .filter((element) => element !== "_attr" && element !== "w:style") + .forEach((element) => { + importedStyle.push(new ImportedXmlComponent(element, xmlStyles[element]._attr)); + }); + + // convert the styles one by one + xmlStyles["w:style"] + .map((style) => this.convertElement("w:style", style)) + .forEach(importedStyle.push.bind(importedStyle)); + + return importedStyle; + } + + convertElement(elementName: string, element: any): ImportedXmlComponent { + const xmlElement = new ImportedXmlComponent(elementName, element._attr); + if (typeof element === "object") { + Object.keys(element) + .filter((key) => key !== "_attr") + .map((item) => this.convertElement(item, element[item])) + .forEach(xmlElement.push.bind(xmlElement)); + } + return xmlElement; + } +} diff --git a/src/file/styles/factory.ts b/src/file/styles/factory.ts index 0b5faf048d..d7016cb0a5 100644 --- a/src/file/styles/factory.ts +++ b/src/file/styles/factory.ts @@ -1,7 +1,8 @@ import { Color, Italics, Size } from "../paragraph/run/formatting"; import { Styles } from "./"; -// import { DocumentDefaults } from "./defaults"; +import { DocumentAttributes } from "../document/document-attributes"; + import { Heading1Style, Heading2Style, @@ -15,7 +16,15 @@ import { export class DefaultStylesFactory { public newInstance(): Styles { - const styles = new Styles(); + const documentAttributes = new DocumentAttributes({ + mc: "http://schemas.openxmlformats.org/markup-compatibility/2006", + r: "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + w: "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + w14: "http://schemas.microsoft.com/office/word/2010/wordml", + w15: "http://schemas.microsoft.com/office/word/2012/wordml", + Ignorable: "w14 w15", + }); + const styles = new Styles(documentAttributes); styles.createDocumentDefaults(); const titleStyle = new TitleStyle(); diff --git a/src/file/styles/index.ts b/src/file/styles/index.ts index 5d8e89ee10..5b0bd1b063 100644 --- a/src/file/styles/index.ts +++ b/src/file/styles/index.ts @@ -1,21 +1,13 @@ -import { XmlComponent } from "file/xml-components"; -import { DocumentAttributes } from "../document/document-attributes"; +import { XmlComponent, BaseXmlComponent } from "file/xml-components"; import { DocumentDefaults } from "./defaults"; import { ParagraphStyle } from "./style"; export class Styles extends XmlComponent { - constructor() { + constructor(_initialStyles?: BaseXmlComponent) { super("w:styles"); - this.root.push( - new DocumentAttributes({ - mc: "http://schemas.openxmlformats.org/markup-compatibility/2006", - r: "http://schemas.openxmlformats.org/officeDocument/2006/relationships", - w: "http://schemas.openxmlformats.org/wordprocessingml/2006/main", - w14: "http://schemas.microsoft.com/office/word/2010/wordml", - w15: "http://schemas.microsoft.com/office/word/2012/wordml", - Ignorable: "w14 w15", - }), - ); + if (_initialStyles) { + this.root.push(_initialStyles); + } } public push(style: XmlComponent): Styles { diff --git a/src/file/table/index.ts b/src/file/table/index.ts index 0e948df9e8..ef3d91a47b 100644 --- a/src/file/table/index.ts +++ b/src/file/table/index.ts @@ -1 +1,2 @@ export * from "./table"; +export * from './table-cell'; \ No newline at end of file diff --git a/src/file/table/table-cell.spec.ts b/src/file/table/table-cell.spec.ts new file mode 100644 index 0000000000..596150d6d5 --- /dev/null +++ b/src/file/table/table-cell.spec.ts @@ -0,0 +1,181 @@ +import { expect } from "chai"; + +import { TableCellBorders, BorderStyle, TableCellWidth, WidthType } from "./table-cell"; +import { Formatter } from "../../export/formatter"; + +describe("TableCellBorders", () => { + describe("#prepForXml", () => { + it("should not add empty borders element if there are no borders defined", () => { + const tb = new TableCellBorders(); + const tree = new Formatter().format(tb); + expect(tree).to.deep.equal(""); + }); + }); + + describe("#addingBorders", () => { + it("should add top border", () => { + const tb = new TableCellBorders(); + tb.addTopBorder(BorderStyle.DOTTED, 1, "FF00FF"); + + const tree = new Formatter().format(tb); + expect(tree).to.deep.equal({ + "w:tcBorders": [ + { + "w:top": [ + { + _attr: { + "w:color": "FF00FF", + "w:sz": 1, + "w:val": "dotted", + }, + }, + ], + }, + ], + }); + }); + + it("should add start(left) border", () => { + const tb = new TableCellBorders(); + tb.addStartBorder(BorderStyle.SINGLE, 2, "FF00FF"); + + const tree = new Formatter().format(tb); + expect(tree).to.deep.equal({ + "w:tcBorders": [ + { + "w:start": [ + { + _attr: { + "w:color": "FF00FF", + "w:sz": 2, + "w:val": "single", + }, + }, + ], + }, + ], + }); + }); + + it("should add bottom border", () => { + const tb = new TableCellBorders(); + tb.addBottomBorder(BorderStyle.DOUBLE, 1, "FF00FF"); + + const tree = new Formatter().format(tb); + expect(tree).to.deep.equal({ + "w:tcBorders": [ + { + "w:bottom": [ + { + _attr: { + "w:color": "FF00FF", + "w:sz": 1, + "w:val": "double", + }, + }, + ], + }, + ], + }); + }); + + it("should add end(right) border", () => { + const tb = new TableCellBorders(); + tb.addEndBorder(BorderStyle.THICK, 3, "FF00FF"); + + const tree = new Formatter().format(tb); + expect(tree).to.deep.equal({ + "w:tcBorders": [ + { + "w:end": [ + { + _attr: { + "w:color": "FF00FF", + "w:sz": 3, + "w:val": "thick", + }, + }, + ], + }, + ], + }); + }); + + it("should add multiple borders", () => { + const tb = new TableCellBorders(); + tb.addTopBorder(BorderStyle.DOTTED, 1, "FF00FF"); + tb.addEndBorder(BorderStyle.THICK, 3, "FF00FF"); + tb.addBottomBorder(BorderStyle.DOUBLE, 1, "FF00FF"); + tb.addStartBorder(BorderStyle.SINGLE, 2, "FF00FF"); + + const tree = new Formatter().format(tb); + expect(tree).to.deep.equal({ + "w:tcBorders": [ + { + "w:top": [ + { + _attr: { + "w:color": "FF00FF", + "w:sz": 1, + "w:val": "dotted", + }, + }, + ], + }, + { + "w:end": [ + { + _attr: { + "w:color": "FF00FF", + "w:sz": 3, + "w:val": "thick", + }, + }, + ], + }, + { + "w:bottom": [ + { + _attr: { + "w:color": "FF00FF", + "w:sz": 1, + "w:val": "double", + }, + }, + ], + }, + { + "w:start": [ + { + _attr: { + "w:color": "FF00FF", + "w:sz": 2, + "w:val": "single", + }, + }, + ], + }, + ], + }); + }); + }); +}); + +describe("TableCellWidth", () => { + describe("#constructor", () => { + it("should create object", () => { + const tcWidth = new TableCellWidth(100, WidthType.DXA); + const tree = new Formatter().format(tcWidth); + expect(tree).to.deep.equal({ + "w:tcW": [ + { + "_attr": { + "w:type": "dxa", + "w:w": 100 + } + } + ] + }); + }); + }); +}); \ No newline at end of file diff --git a/src/file/table/table-cell.ts b/src/file/table/table-cell.ts new file mode 100644 index 0000000000..99282f1125 --- /dev/null +++ b/src/file/table/table-cell.ts @@ -0,0 +1,197 @@ +import { XmlComponent, XmlAttributeComponent, IXmlableObject } from "file/xml-components"; + +export enum BorderStyle { + SINGLE = "single", + DASH_DOT_STROKED = "dashDotStroked", + DASHED = "dashed", + DASH_SMALL_GAP = "dashSmallGap", + DOT_DASH = "dotDash", + DOT_DOT_DASH = "dotDotDash", + DOTTED = "dotted", + DOUBLE = "double", + DOUBLE_WAVE = "doubleWave", + INSET = "inset", + NIL = "nil", + NONE = "none", + OUTSET = "outset", + THICK = "thick", + THICK_THIN_LARGE_GAP = "thickThinLargeGap", + THICK_THIN_MEDIUM_GAP = "thickThinMediumGap", + THICK_THIN_SMALL_GAP = "thickThinSmallGap", + THIN_THICK_LARGE_GAP = "thinThickLargeGap", + THIN_THICK_MEDIUM_GAP = "thinThickMediumGap", + THIN_THICK_SMALL_GAP = "thinThickSmallGap", + THIN_THICK_THIN_LARGE_GAP = "thinThickThinLargeGap", + THIN_THICK_THIN_MEDIUM_GAP = "thinThickThinMediumGap", + THIN_THICK_THIN_SMALL_GAP = "thinThickThinSmallGap", + THREE_D_EMBOSS = "threeDEmboss", + THREE_D_ENGRAVE = "threeDEngrave", + TRIPLE = "triple", + WAVE = "wave", +} + +interface ICellBorder { + style: BorderStyle; + size: number; + color: string; +} + +class CellBorderAttributes extends XmlAttributeComponent { + protected xmlKeys = { style: "w:val", size: "w:sz", color: "w:color" }; +} + +class BaseTableCellBorder extends XmlComponent { + setProperties(style: BorderStyle, size: number, color: string) { + let attrs = new CellBorderAttributes({ + style: style, + size: size, + color: color, + }); + this.root.push(attrs); + } +} + +export class TableCellBorders extends XmlComponent { + constructor() { + super("w:tcBorders"); + } + + public prepForXml(): IXmlableObject { + return this.root.length > 0 ? super.prepForXml() : ""; + } + + addTopBorder(style: BorderStyle, size: number, color: string) { + const top = new BaseTableCellBorder("w:top"); + top.setProperties(style, size, color); + this.root.push(top); + } + + addStartBorder(style: BorderStyle, size: number, color: string) { + const start = new BaseTableCellBorder("w:start"); + start.setProperties(style, size, color); + this.root.push(start); + } + + addBottomBorder(style: BorderStyle, size: number, color: string) { + const bottom = new BaseTableCellBorder("w:bottom"); + bottom.setProperties(style, size, color); + this.root.push(bottom); + } + + addEndBorder(style: BorderStyle, size: number, color: string) { + const end = new BaseTableCellBorder("w:end"); + end.setProperties(style, size, color); + this.root.push(end); + } +} + +/** + * Attributes fot the GridSpan element. + */ +class GridSpanAttributes extends XmlAttributeComponent<{ val: number }> { + protected xmlKeys = { val: "w:val" }; +} + +/** + * GridSpan element. Should be used in a table cell. Pass the number of columns that this cell need to span. + */ +export class GridSpan extends XmlComponent { + constructor(value: number) { + super("w:gridSpan"); + + this.root.push( + new GridSpanAttributes({ + val: value, + }), + ); + } +} + +/** + * Vertical merge types. + */ +export enum VMergeType { + /** + * Cell that is merged with upper one. + */ + CONTINUE = "continue", + /** + * Cell that is starting the vertical merge. + */ + RESTART = "restart", +} + +class VMergeAttributes extends XmlAttributeComponent<{ val: VMergeType }> { + protected xmlKeys = { val: "w:val" }; +} + +/** + * Vertical merge element. Should be used in a table cell. + */ +export class VMerge extends XmlComponent { + constructor(value: VMergeType) { + super("w:vMerge"); + + this.root.push( + new VMergeAttributes({ + val: value, + }), + ); + } +} + +export enum VerticalAlign { + BOTTOM = "bottom", + CENTER = "center", + TOP = "top", +} + +class VAlignAttributes extends XmlAttributeComponent<{ val: VerticalAlign }> { + protected xmlKeys = { val: "w:val" }; +} + +/** + * Vertical align element. + */ +export class VAlign extends XmlComponent { + constructor(value: VerticalAlign) { + super("w:vAlign"); + + this.root.push( + new VAlignAttributes({ + val: value, + }), + ); + } +} + +export enum WidthType { + /** Auto. */ + AUTO = "auto", + /** Value is in twentieths of a point */ + DXA = "dxa", + /** No (empty) value. */ + NIL = "nil", + /** Value is in percentage. */ + PERCENTAGE = "pct", +} + +class TableCellWidthAttributes extends XmlAttributeComponent<{ type: WidthType; width: string | number }> { + protected xmlKeys = { width: "w:w", type: "w:type" }; +} + +/** + * Table cell width element. + */ +export class TableCellWidth extends XmlComponent { + constructor(value: string | number, type: WidthType) { + super("w:tcW"); + + this.root.push( + new TableCellWidthAttributes({ + width: value, + type: type, + }), + ); + } +} diff --git a/src/file/table/table.ts b/src/file/table/table.ts index 8fdec3d37c..3e180e9dfb 100644 --- a/src/file/table/table.ts +++ b/src/file/table/table.ts @@ -2,33 +2,39 @@ import { IXmlableObject, XmlComponent } from "file/xml-components"; import { Paragraph } from "../paragraph"; import { TableGrid } from "./grid"; import { TableProperties, WidthTypes } from "./properties"; +import { TableCellBorders, GridSpan, VMerge, VMergeType, VerticalAlign, VAlign, TableCellWidth, WidthType } from "file/table/table-cell"; export class Table extends XmlComponent { private readonly properties: TableProperties; private readonly rows: TableRow[]; private readonly grid: TableGrid; - constructor(rows: number, cols: number) { + constructor(rows: number, cols: number, colSizes?: number[]) { super("w:tbl"); this.properties = new TableProperties(); this.root.push(this.properties); this.properties.setBorder(); - 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); + if (colSizes && colSizes.length > 0) { + this.grid = new TableGrid(colSizes); + } else { + 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.grid = new TableGrid(gridCols); + this.root.push(this.grid); this.rows = []; @@ -112,10 +118,36 @@ export class TableCell extends XmlComponent { this.addContent(para); return para; } + + get cellProperties() { + return this.properties; + } } export class TableCellProperties extends XmlComponent { + private cellBorder: TableCellBorders; constructor() { super("w:tcPr"); + this.cellBorder = new TableCellBorders(); + this.root.push(this.cellBorder); + } + + get borders() { + return this.cellBorder; + } + addGridSpan(cellSpan: number) { + this.root.push(new GridSpan(cellSpan)); + } + + addVerticalMerge(type: VMergeType) { + this.root.push(new VMerge(type)); + } + + setVerticalAlign(vAlignType: VerticalAlign) { + this.root.push(new VAlign(vAlignType)); + } + + setWidth(width: string | number, type: WidthType) { + this.root.push(new TableCellWidth(width, type)); } } diff --git a/src/file/xml-components/base.ts b/src/file/xml-components/base.ts index d634a418a9..f6382f5e7e 100644 --- a/src/file/xml-components/base.ts +++ b/src/file/xml-components/base.ts @@ -2,10 +2,15 @@ import { IXmlableObject } from "./xmlable-object"; export abstract class BaseXmlComponent { protected rootKey: string; + protected deleted: boolean = false; constructor(rootKey: string) { this.rootKey = rootKey; } public abstract prepForXml(): IXmlableObject; + + get isDeleted() { + return this.deleted; + } } diff --git a/src/file/xml-components/imported-xml-component.spec.ts b/src/file/xml-components/imported-xml-component.spec.ts new file mode 100644 index 0000000000..d7b638ba9f --- /dev/null +++ b/src/file/xml-components/imported-xml-component.spec.ts @@ -0,0 +1,34 @@ +import { expect } from "chai"; +import { ImportedXmlComponent } from "./"; + +describe("ImportedXmlComponent", () => { + let importedXmlComponent: ImportedXmlComponent; + + beforeEach(() => { + const attributes = { + someAttr: "1", + otherAttr: "2", + }; + importedXmlComponent = new ImportedXmlComponent("w:test", attributes); + importedXmlComponent.push(new ImportedXmlComponent("w:child")); + }); + + describe("#prepForXml()", () => { + it("should transform for xml", () => { + const converted = importedXmlComponent.prepForXml(); + expect(converted).to.eql({ + "w:test": [ + { + _attr: { + someAttr: "1", + otherAttr: "2", + }, + }, + { + "w:child": [], + }, + ], + }); + }); + }); +}); diff --git a/src/file/xml-components/imported-xml-component.ts b/src/file/xml-components/imported-xml-component.ts new file mode 100644 index 0000000000..51af733c2e --- /dev/null +++ b/src/file/xml-components/imported-xml-component.ts @@ -0,0 +1,71 @@ +import { XmlComponent, IXmlableObject } from "."; + +/** + * Represents imported xml component from xml file. + */ +export class ImportedXmlComponent extends XmlComponent { + private _attr: any; + + constructor(rootKey: string, _attr?: any) { + super(rootKey); + if (_attr) { + this._attr = _attr; + } + } + + /** + * Transforms the object so it can be converted to xml. Example: + * + * + * + * + * { + * 'w:someKey': [ + * { + * _attr: { + * someAttr: "1", + * otherAttr: "11" + * } + * }, + * { + * 'w:child': [ + * { + * _attr: { + * childAttr: "2" + * } + * } + * ] + * } + * ] + * } + */ + prepForXml(): IXmlableObject { + const result = super.prepForXml(); + if (!!this._attr) { + if (!Array.isArray(result[this.rootKey])) { + result[this.rootKey] = [result[this.rootKey]]; + } + result[this.rootKey].unshift({ _attr: this._attr }); + } + return result; + } + + push(xmlComponent: XmlComponent) { + this.root.push(xmlComponent); + } +} + +/** + * Used for the attributes of root element that is being imported. + */ +export class ImportedRootElementAttributes extends XmlComponent { + constructor(private _attr: any) { + super(""); + } + + public prepForXml(): IXmlableObject { + return { + _attr: this._attr, + }; + } +} diff --git a/src/file/xml-components/index.ts b/src/file/xml-components/index.ts index 5d20da53d2..85e7e383f7 100644 --- a/src/file/xml-components/index.ts +++ b/src/file/xml-components/index.ts @@ -1,4 +1,5 @@ export * from "./xml-component"; export * from "./attributes"; export * from "./default-attributes"; +export * from './imported-xml-component'; export * from "./xmlable-object"; diff --git a/src/file/xml-components/xml-component.spec.ts b/src/file/xml-components/xml-component.spec.ts index 17d3d4d1cb..25bf442bc9 100644 --- a/src/file/xml-components/xml-component.spec.ts +++ b/src/file/xml-components/xml-component.spec.ts @@ -18,4 +18,15 @@ describe("XmlComponent", () => { assert.equal(newJson.rootKey, "w:test"); }); }); + + describe("#prepForXml()", () => { + it("should skip deleted elements", () => { + const child = new TestComponent("w:test1"); + child.delete(); + xmlComponent.addChildElement(child); + + const xml = xmlComponent.prepForXml(); + assert.equal(xml['w:test'].length, 0); + }); + }); }); diff --git a/src/file/xml-components/xml-component.ts b/src/file/xml-components/xml-component.ts index d3705478cb..b41bb01267 100644 --- a/src/file/xml-components/xml-component.ts +++ b/src/file/xml-components/xml-component.ts @@ -12,6 +12,12 @@ export abstract class XmlComponent extends BaseXmlComponent { public prepForXml(): IXmlableObject { const children = this.root + .filter(c => { + if (c instanceof BaseXmlComponent) { + return !c.isDeleted; + } + return true; + }) .map((comp) => { if (comp instanceof BaseXmlComponent) { return comp.prepForXml(); @@ -23,4 +29,12 @@ export abstract class XmlComponent extends BaseXmlComponent { [this.rootKey]: children, }; } + + public addChildElement(child: XmlComponent | string) { + this.root.push(child); + } + + public delete() { + this.deleted = true; + } }