From 870c222dd58fbc5d1afa17efc0983455afe69397 Mon Sep 17 00:00:00 2001 From: Max Lay Date: Mon, 3 Aug 2020 14:58:30 +1200 Subject: [PATCH 1/5] Allow custom text properties to be included --- src/export/packer/next-compiler.spec.ts | 7 +- src/export/packer/next-compiler.ts | 5 ++ src/file/content-types/content-types.spec.ts | 22 ++++-- src/file/content-types/content-types.ts | 1 + .../custom-properties-attributes.ts | 13 ++++ .../custom-properties.spec.ts | 67 +++++++++++++++++++ .../custom-properties/custom-properties.ts | 41 ++++++++++++ .../custom-property-attributes.ts | 15 +++++ src/file/custom-properties/custom-property.ts | 26 +++++++ src/file/custom-properties/index.ts | 2 + src/file/file.ts | 13 ++++ .../relationship/relationship.ts | 1 + 12 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 src/file/custom-properties/custom-properties-attributes.ts create mode 100644 src/file/custom-properties/custom-properties.spec.ts create mode 100644 src/file/custom-properties/custom-properties.ts create mode 100644 src/file/custom-properties/custom-property-attributes.ts create mode 100644 src/file/custom-properties/custom-property.ts create mode 100644 src/file/custom-properties/index.ts 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 a7b5fb617e..637646deb5 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), prettify), path: "[Content_Types].xml", }, + CustomProperties: { + data: xml(this.formatter.format(file.CustomProperties, file), prettify), + path: "docProps/custom.xml", + }, AppProperties: { data: xml(this.formatter.format(file.AppProperties, file), 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..bbe21fa72b --- /dev/null +++ b/src/file/custom-properties/custom-properties.spec.ts @@ -0,0 +1,67 @@ +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..0685a38f08 --- /dev/null +++ b/src/file/custom-properties/custom-properties.ts @@ -0,0 +1,41 @@ +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)); + } + + public get Properties(): CustomProperty[] { + return this.properties; + } +} 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..6804cafb5b --- /dev/null +++ b/src/file/custom-properties/custom-property.ts @@ -0,0 +1,26 @@ +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 270714ed17..4ecedaab47 100644 --- a/src/file/file.ts +++ b/src/file/file.ts @@ -2,6 +2,7 @@ import * as shortid from "shortid"; 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 { Document } from "./document"; import { FooterReferenceType, @@ -59,6 +60,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; private readonly hyperlinkCache: { readonly [key: string]: Hyperlink } = {}; @@ -71,6 +73,7 @@ export class File { }, fileProperties: IFileProperties = {}, sections: ISectionOptions[] = [], + customProperties: ICustomPropertyOptions[] = [], ) { this.coreProperties = new CoreProperties(options); this.numbering = new Numbering( @@ -82,6 +85,7 @@ export class File { ); this.docRelationships = new Relationships(); this.fileRelationships = new Relationships(); + this.customProperties = new CustomProperties(customProperties); this.appProperties = new AppProperties(); this.footNotes = new FootNotes(); this.contentTypes = new ContentTypes(); @@ -289,6 +293,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.docRelationships.createRelationship( this.currentRelationshipId++, @@ -352,6 +361,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"; From ec4c4fb5c427060f46098c20e0e3cd1dc5272a4b Mon Sep 17 00:00:00 2001 From: Max Lay Date: Tue, 4 Aug 2020 08:50:56 +1200 Subject: [PATCH 2/5] Run updated prettier --- .../custom-properties/custom-properties.spec.ts | 17 ++++++++--------- src/file/custom-properties/custom-property.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/file/custom-properties/custom-properties.spec.ts b/src/file/custom-properties/custom-properties.spec.ts index bbe21fa72b..a8bc92b7e4 100644 --- a/src/file/custom-properties/custom-properties.spec.ts +++ b/src/file/custom-properties/custom-properties.spec.ts @@ -10,7 +10,7 @@ describe("CustomProperties", () => { expect(tree).to.deep.equal({ Properties: { _attr: { - "xmlns": "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties", + xmlns: "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties", "xmlns:vt": "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes", }, }, @@ -18,13 +18,16 @@ describe("CustomProperties", () => { }); it("should create custom properties with all the attributes given", () => { - const properties = new CustomProperties([{ name: "Address", value: "123" }, { name: "Author", value: "456" }]); + 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: "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties", "xmlns:vt": "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes", }, }, @@ -38,9 +41,7 @@ describe("CustomProperties", () => { }, }, { - "vt:lpwstr": [ - "123", - ], + "vt:lpwstr": ["123"], }, ], }, @@ -54,9 +55,7 @@ describe("CustomProperties", () => { }, }, { - "vt:lpwstr": [ - "456", - ], + "vt:lpwstr": ["456"], }, ], }, diff --git a/src/file/custom-properties/custom-property.ts b/src/file/custom-properties/custom-property.ts index 6804cafb5b..fc18db2667 100644 --- a/src/file/custom-properties/custom-property.ts +++ b/src/file/custom-properties/custom-property.ts @@ -9,11 +9,13 @@ export interface ICustomPropertyOptions { 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 CustomPropertyAttributes({ + fmtid: "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}", + pid: id.toString(), + name: properties.name, + }), + ); this.root.push(new CustomPropertyValue(properties.value)); } } From fc87a73259c612f219956fe48679d56b49f4dd7c Mon Sep 17 00:00:00 2001 From: Max Lay Date: Tue, 4 Aug 2020 09:15:20 +1200 Subject: [PATCH 3/5] Remove unnecessary properties getter --- src/file/custom-properties/custom-properties.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/file/custom-properties/custom-properties.ts b/src/file/custom-properties/custom-properties.ts index 0685a38f08..c254ceeca8 100644 --- a/src/file/custom-properties/custom-properties.ts +++ b/src/file/custom-properties/custom-properties.ts @@ -34,8 +34,4 @@ export class CustomProperties extends XmlComponent { public addCustomProperty(property: ICustomPropertyOptions): void { this.properties.push(new CustomProperty(this.nextId++, property)); } - - public get Properties(): CustomProperty[] { - return this.properties; - } } From e65e7d6a324417108b08ea1a49574ebc378a56a6 Mon Sep 17 00:00:00 2001 From: Max Lay Date: Sat, 8 Aug 2020 11:01:21 +1200 Subject: [PATCH 4/5] Add custom properties demo --- demo/54-custom-properties.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 demo/54-custom-properties.ts 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); +}); From 3e58910bfc8ff1835dd94955bc935f49517de762 Mon Sep 17 00:00:00 2001 From: Dolan Miu Date: Tue, 2 Mar 2021 00:06:11 +0000 Subject: [PATCH 5/5] Use document wrapper component --- src/export/packer/next-compiler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/export/packer/next-compiler.ts b/src/export/packer/next-compiler.ts index 5f6b8183b0..275dd953d7 100644 --- a/src/export/packer/next-compiler.ts +++ b/src/export/packer/next-compiler.ts @@ -181,7 +181,7 @@ export class Compiler { path: "[Content_Types].xml", }, CustomProperties: { - data: xml(this.formatter.format(file.CustomProperties, file), prettify), + data: xml(this.formatter.format(file.CustomProperties, file.Document), prettify), path: "docProps/custom.xml", }, AppProperties: {