diff --git a/demo/54-custom-properties.ts b/demo/54-custom-properties.ts new file mode 100644 index 0000000000..f6ef7cf778 --- /dev/null +++ b/demo/54-custom-properties.ts @@ -0,0 +1,25 @@ +// Custom Properties +// Custom properties are incredibly useful if you want to be able to apply quick parts or custom cover pages +// to the document in Word after the document has been generated. Standard properties (such as creator, title +// and subject) cover typical use cases, but sometimes custom properties are required. + +// Import from 'docx' rather than '../build' if you install from npm +import * as fs from "fs"; +import { Document, Packer } from "../build"; + +const doc = new Document( + // Standard properties + { creator: "Creator", title: "Title", subject: "Subject", description: "Description" }, + // No file properties + {}, + // No sections + [], + [ + { name: "Subtitle", value: "Subtitle" }, + { name: "Address", value: "Address" }, + ] +); + +Packer.toBuffer(doc).then((buffer) => { + fs.writeFileSync("My Document.docx", buffer); +}); diff --git a/src/export/packer/next-compiler.spec.ts b/src/export/packer/next-compiler.spec.ts index 7c9bcda904..367f7b1bff 100644 --- a/src/export/packer/next-compiler.spec.ts +++ b/src/export/packer/next-compiler.spec.ts @@ -22,10 +22,11 @@ describe("Compiler", () => { const fileNames = Object.keys(zipFile.files).map((f) => zipFile.files[f].name); expect(fileNames).is.an.instanceof(Array); - expect(fileNames).has.length(14); + expect(fileNames).has.length(15); expect(fileNames).to.include("word/document.xml"); expect(fileNames).to.include("word/styles.xml"); expect(fileNames).to.include("docProps/core.xml"); + expect(fileNames).to.include("docProps/custom.xml"); expect(fileNames).to.include("docProps/app.xml"); expect(fileNames).to.include("word/numbering.xml"); expect(fileNames).to.include("word/footnotes.xml"); @@ -62,7 +63,7 @@ describe("Compiler", () => { const fileNames = Object.keys(zipFile.files).map((f) => zipFile.files[f].name); expect(fileNames).is.an.instanceof(Array); - expect(fileNames).has.length(22); + expect(fileNames).has.length(23); expect(fileNames).to.include("word/header1.xml"); expect(fileNames).to.include("word/_rels/header1.xml.rels"); @@ -88,7 +89,7 @@ describe("Compiler", () => { const spy = sinon.spy(compiler["formatter"], "format"); compiler.compile(file); - expect(spy.callCount).to.equal(10); + expect(spy.callCount).to.equal(11); }); }); }); diff --git a/src/export/packer/next-compiler.ts b/src/export/packer/next-compiler.ts index aed3951ab5..275dd953d7 100644 --- a/src/export/packer/next-compiler.ts +++ b/src/export/packer/next-compiler.ts @@ -23,6 +23,7 @@ interface IXmlifyedFileMapping { readonly HeaderRelationships: IXmlifyedFile[]; readonly FooterRelationships: IXmlifyedFile[]; readonly ContentTypes: IXmlifyedFile; + readonly CustomProperties: IXmlifyedFile; readonly AppProperties: IXmlifyedFile; readonly FootNotes: IXmlifyedFile; readonly Settings: IXmlifyedFile; @@ -179,6 +180,10 @@ export class Compiler { data: xml(this.formatter.format(file.ContentTypes, file.Document), prettify), path: "[Content_Types].xml", }, + CustomProperties: { + data: xml(this.formatter.format(file.CustomProperties, file.Document), prettify), + path: "docProps/custom.xml", + }, AppProperties: { data: xml(this.formatter.format(file.AppProperties, file.Document), prettify), path: "docProps/app.xml", diff --git a/src/file/content-types/content-types.spec.ts b/src/file/content-types/content-types.spec.ts index 1d8e008f92..57d992f83d 100644 --- a/src/file/content-types/content-types.spec.ts +++ b/src/file/content-types/content-types.spec.ts @@ -54,6 +54,14 @@ describe("ContentTypes", () => { }, }); expect(tree["Types"][11]).to.deep.equal({ + Override: { + _attr: { + ContentType: "application/vnd.openxmlformats-officedocument.custom-properties+xml", + PartName: "/docProps/custom.xml", + }, + }, + }); + expect(tree["Types"][12]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.extended-properties+xml", @@ -61,7 +69,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][12]).to.deep.equal({ + expect(tree["Types"][13]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml", @@ -69,7 +77,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][13]).to.deep.equal({ + expect(tree["Types"][14]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", @@ -77,7 +85,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][14]).to.deep.equal({ + expect(tree["Types"][15]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml", @@ -94,7 +102,7 @@ describe("ContentTypes", () => { contentTypes.addFooter(102); const tree = new Formatter().format(contentTypes); - expect(tree["Types"][15]).to.deep.equal({ + expect(tree["Types"][16]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml", @@ -103,7 +111,7 @@ describe("ContentTypes", () => { }, }); - expect(tree["Types"][16]).to.deep.equal({ + expect(tree["Types"][17]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml", @@ -120,7 +128,7 @@ describe("ContentTypes", () => { contentTypes.addHeader(202); const tree = new Formatter().format(contentTypes); - expect(tree["Types"][15]).to.deep.equal({ + expect(tree["Types"][16]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml", @@ -129,7 +137,7 @@ describe("ContentTypes", () => { }, }); - expect(tree["Types"][16]).to.deep.equal({ + expect(tree["Types"][17]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml", diff --git a/src/file/content-types/content-types.ts b/src/file/content-types/content-types.ts index 86307724fc..34e495d3c0 100644 --- a/src/file/content-types/content-types.ts +++ b/src/file/content-types/content-types.ts @@ -27,6 +27,7 @@ export class ContentTypes extends XmlComponent { this.root.push(new Override("application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml", "/word/styles.xml")); this.root.push(new Override("application/vnd.openxmlformats-package.core-properties+xml", "/docProps/core.xml")); + this.root.push(new Override("application/vnd.openxmlformats-officedocument.custom-properties+xml", "/docProps/custom.xml")); this.root.push(new Override("application/vnd.openxmlformats-officedocument.extended-properties+xml", "/docProps/app.xml")); this.root.push(new Override("application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml", "/word/numbering.xml")); this.root.push(new Override("application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", "/word/footnotes.xml")); diff --git a/src/file/custom-properties/custom-properties-attributes.ts b/src/file/custom-properties/custom-properties-attributes.ts new file mode 100644 index 0000000000..087c9e98a1 --- /dev/null +++ b/src/file/custom-properties/custom-properties-attributes.ts @@ -0,0 +1,13 @@ +import { XmlAttributeComponent } from "file/xml-components"; + +export interface ICustomPropertiesAttributes { + readonly xmlns: string; + readonly vt: string; +} + +export class CustomPropertiesAttributes extends XmlAttributeComponent { + protected readonly xmlKeys = { + xmlns: "xmlns", + vt: "xmlns:vt", + }; +} diff --git a/src/file/custom-properties/custom-properties.spec.ts b/src/file/custom-properties/custom-properties.spec.ts new file mode 100644 index 0000000000..a8bc92b7e4 --- /dev/null +++ b/src/file/custom-properties/custom-properties.spec.ts @@ -0,0 +1,66 @@ +import { expect } from "chai"; +import { Formatter } from "export/formatter"; +import { CustomProperties } from "./custom-properties"; + +describe("CustomProperties", () => { + describe("#constructor()", () => { + it("sets the appropriate attributes on the top-level", () => { + const properties = new CustomProperties([]); + const tree = new Formatter().format(properties); + expect(tree).to.deep.equal({ + Properties: { + _attr: { + xmlns: "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties", + "xmlns:vt": "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes", + }, + }, + }); + }); + + it("should create custom properties with all the attributes given", () => { + const properties = new CustomProperties([ + { name: "Address", value: "123" }, + { name: "Author", value: "456" }, + ]); + const tree = new Formatter().format(properties); + expect(tree).to.deep.equal({ + Properties: [ + { + _attr: { + xmlns: "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties", + "xmlns:vt": "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes", + }, + }, + { + property: [ + { + _attr: { + fmtid: "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}", + pid: "2", + name: "Address", + }, + }, + { + "vt:lpwstr": ["123"], + }, + ], + }, + { + property: [ + { + _attr: { + fmtid: "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}", + pid: "3", + name: "Author", + }, + }, + { + "vt:lpwstr": ["456"], + }, + ], + }, + ], + }); + }); + }); +}); diff --git a/src/file/custom-properties/custom-properties.ts b/src/file/custom-properties/custom-properties.ts new file mode 100644 index 0000000000..c254ceeca8 --- /dev/null +++ b/src/file/custom-properties/custom-properties.ts @@ -0,0 +1,37 @@ +import { IXmlableObject, XmlComponent } from "file/xml-components"; +import { CustomPropertiesAttributes } from "./custom-properties-attributes"; +import { CustomProperty, ICustomPropertyOptions } from "./custom-property"; + +export class CustomProperties extends XmlComponent { + // tslint:disable-next-line:readonly-keyword + private nextId: number; + private readonly properties: CustomProperty[] = []; + + constructor(properties: ICustomPropertyOptions[]) { + super("Properties"); + + this.root.push( + new CustomPropertiesAttributes({ + xmlns: "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties", + vt: "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes", + }), + ); + + // I'm not sure why, but every example I have seen starts with 2 + // https://docs.microsoft.com/en-us/office/open-xml/how-to-set-a-custom-property-in-a-word-processing-document + this.nextId = 2; + + for (const property of properties) { + this.addCustomProperty(property); + } + } + + public prepForXml(): IXmlableObject | undefined { + this.properties.forEach((x) => this.root.push(x)); + return super.prepForXml(); + } + + public addCustomProperty(property: ICustomPropertyOptions): void { + this.properties.push(new CustomProperty(this.nextId++, property)); + } +} diff --git a/src/file/custom-properties/custom-property-attributes.ts b/src/file/custom-properties/custom-property-attributes.ts new file mode 100644 index 0000000000..9b2697fe94 --- /dev/null +++ b/src/file/custom-properties/custom-property-attributes.ts @@ -0,0 +1,15 @@ +import { XmlAttributeComponent } from "file/xml-components"; + +export interface ICustomPropertyAttributes { + readonly fmtid: string; + readonly pid: string; + readonly name: string; +} + +export class CustomPropertyAttributes extends XmlAttributeComponent { + protected readonly xmlKeys = { + fmtid: "fmtid", + pid: "pid", + name: "name", + }; +} diff --git a/src/file/custom-properties/custom-property.ts b/src/file/custom-properties/custom-property.ts new file mode 100644 index 0000000000..fc18db2667 --- /dev/null +++ b/src/file/custom-properties/custom-property.ts @@ -0,0 +1,28 @@ +import { XmlComponent } from "file/xml-components"; +import { CustomPropertyAttributes } from "./custom-property-attributes"; + +export interface ICustomPropertyOptions { + readonly name: string; + readonly value: string; +} + +export class CustomProperty extends XmlComponent { + constructor(id: number, properties: ICustomPropertyOptions) { + super("property"); + this.root.push( + new CustomPropertyAttributes({ + fmtid: "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}", + pid: id.toString(), + name: properties.name, + }), + ); + this.root.push(new CustomPropertyValue(properties.value)); + } +} + +export class CustomPropertyValue extends XmlComponent { + constructor(value: string) { + super("vt:lpwstr"); + this.root.push(value); + } +} diff --git a/src/file/custom-properties/index.ts b/src/file/custom-properties/index.ts new file mode 100644 index 0000000000..132da63d92 --- /dev/null +++ b/src/file/custom-properties/index.ts @@ -0,0 +1,2 @@ +export * from "./custom-properties"; +export * from "./custom-property"; diff --git a/src/file/file.ts b/src/file/file.ts index 9ec8316e9f..e00d57e532 100644 --- a/src/file/file.ts +++ b/src/file/file.ts @@ -1,6 +1,7 @@ import { AppProperties } from "./app-properties/app-properties"; import { ContentTypes } from "./content-types/content-types"; import { CoreProperties, IPropertiesOptions } from "./core-properties"; +import { CustomProperties, ICustomPropertyOptions } from "./custom-properties"; import { DocumentWrapper } from "./document-wrapper"; import { FooterReferenceType, @@ -56,6 +57,7 @@ export class File { private readonly footNotes: FootNotes; private readonly settings: Settings; private readonly contentTypes: ContentTypes; + private readonly customProperties: CustomProperties; private readonly appProperties: AppProperties; private readonly styles: Styles; @@ -67,6 +69,7 @@ export class File { }, fileProperties: IFileProperties = {}, sections: ISectionOptions[] = [], + customProperties: ICustomPropertyOptions[] = [], ) { this.coreProperties = new CoreProperties(options); this.numbering = new Numbering( @@ -78,6 +81,7 @@ export class File { ); // this.documentWrapper.Relationships = new Relationships(); this.fileRelationships = new Relationships(); + this.customProperties = new CustomProperties(customProperties); this.appProperties = new AppProperties(); this.footNotes = new FootNotes(); this.contentTypes = new ContentTypes(); @@ -242,6 +246,11 @@ export class File { "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", "docProps/app.xml", ); + this.fileRelationships.createRelationship( + 4, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties", + "docProps/custom.xml", + ); this.documentWrapper.Relationships.createRelationship( this.currentRelationshipId++, @@ -301,6 +310,10 @@ export class File { return this.contentTypes; } + public get CustomProperties(): CustomProperties { + return this.customProperties; + } + public get AppProperties(): AppProperties { return this.appProperties; } diff --git a/src/file/relationships/relationship/relationship.ts b/src/file/relationships/relationship/relationship.ts index 82672644c9..2b928de890 100644 --- a/src/file/relationships/relationship/relationship.ts +++ b/src/file/relationships/relationship/relationship.ts @@ -13,6 +13,7 @@ export type RelationshipType = | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" | "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" + | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";