diff --git a/demo/21-bookmarks.ts b/demo/21-bookmarks.ts index cc0bac5517..1ad50eb9b1 100644 --- a/demo/21-bookmarks.ts +++ b/demo/21-bookmarks.ts @@ -1,7 +1,7 @@ // This demo shows how to create bookmarks then link to them with internal hyperlinks // Import from 'docx' rather than '../build' if you install from npm import * as fs from "fs"; -import { Document, HeadingLevel, Packer, PageBreak, Paragraph } from "../build"; +import { Bookmark, Document, HeadingLevel, HyperlinkRef, HyperlinkType, Packer, PageBreak, Paragraph } from "../build"; const LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam mi velit, convallis convallis scelerisque nec, faucibus nec leo. Phasellus at posuere mauris, tempus dignissim velit. Integer et tortor dolor. Duis auctor efficitur mattis. Vivamus ut metus accumsan tellus auctor sollicitudin venenatis et nibh. Cras quis massa ac metus fringilla venenatis. Proin rutrum mauris purus, ut suscipit magna consectetur id. Integer consectetur sollicitudin ante, vitae faucibus neque efficitur in. Praesent ultricies nibh lectus. Mauris pharetra id odio eget iaculis. Duis dictum, risus id pellentesque rutrum, lorem quam malesuada massa, quis ullamcorper turpis urna a diam. Cras vulputate metus vel massa porta ullamcorper. Etiam porta condimentum nulla nec tristique. Sed nulla urna, pharetra non tortor sed, sollicitudin molestie diam. Maecenas enim leo, feugiat eget vehicula id, sollicitudin vitae ante."; @@ -10,19 +10,19 @@ const doc = new Document({ creator: "Clippy", title: "Sample Document", description: "A brief example of using docx with bookmarks and internal hyperlinks", + hyperlinks: { + myAnchorId: { + text: "Hyperlink", + type: HyperlinkType.INTERNAL, + }, + }, }); -const anchorId = "anchorID"; - -// First create the bookmark -const bookmark = doc.createBookmark(anchorId, "Lorem Ipsum"); -const hyperlink = doc.createInternalHyperLink(anchorId, `Click me!`); - doc.addSection({ children: [ new Paragraph({ heading: HeadingLevel.HEADING_1, - children: [bookmark], + children: [new Bookmark("myAnchorId", "Lorem Ipsum")], }), new Paragraph("\n"), new Paragraph(LOREM_IPSUM), @@ -30,7 +30,7 @@ doc.addSection({ children: [new PageBreak()], }), new Paragraph({ - children: [hyperlink], + children: [new HyperlinkRef("myAnchorId")], }), ], }); diff --git a/demo/35-hyperlinks.ts b/demo/35-hyperlinks.ts index 6392299b84..fb84f13099 100644 --- a/demo/35-hyperlinks.ts +++ b/demo/35-hyperlinks.ts @@ -1,15 +1,22 @@ // Example on how to add hyperlinks to websites // Import from 'docx' rather than '../build' if you install from npm import * as fs from "fs"; -import { Document, Packer, Paragraph } from "../build"; +import { Document, HyperlinkRef, HyperlinkType, Packer, Paragraph } from "../build"; -const doc = new Document(); -const link = doc.createHyperlink("http://www.example.com", "Hyperlink"); +const doc = new Document({ + hyperlinks: { + myCoolLink: { + link: "http://www.example.com", + text: "Hyperlink", + type: HyperlinkType.EXTERNAL, + }, + }, +}); doc.addSection({ children: [ new Paragraph({ - children: [link], + children: [new HyperlinkRef("myCoolLink")], }), ], }); diff --git a/package-lock.json b/package-lock.json index ee20450d0e..792ca7e14b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -355,6 +355,12 @@ "@types/node": "*" } }, + "@types/shortid": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/shortid/-/shortid-0.0.29.tgz", + "integrity": "sha1-gJPuBBam4r8qpjOBCRFLP7/6Dps=", + "dev": true + }, "@types/sinon": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-4.3.3.tgz", diff --git a/package.json b/package.json index cc50a5d9e0..2ad66b4a64 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@types/chai": "^3.4.35", "@types/mocha": "^2.2.39", "@types/request-promise": "^4.1.42", + "@types/shortid": "0.0.29", "@types/sinon": "^4.3.1", "@types/webpack": "^4.4.24", "awesome-typescript-loader": "^3.4.1", diff --git a/src/export/formatter.ts b/src/export/formatter.ts index 0a28070d13..b863ea4b97 100644 --- a/src/export/formatter.ts +++ b/src/export/formatter.ts @@ -1,8 +1,9 @@ import { BaseXmlComponent, IXmlableObject } from "file/xml-components"; +import { File } from "../file"; export class Formatter { - public format(input: BaseXmlComponent): IXmlableObject { - const output = input.prepForXml(); + public format(input: BaseXmlComponent, file?: File): IXmlableObject { + const output = input.prepForXml(file); if (output) { return output; diff --git a/src/export/packer/next-compiler.ts b/src/export/packer/next-compiler.ts index bff24a8ef7..a7b5fb617e 100644 --- a/src/export/packer/next-compiler.ts +++ b/src/export/packer/next-compiler.ts @@ -71,7 +71,7 @@ export class Compiler { file.verifyUpdateFields(); const documentRelationshipCount = file.DocumentRelationships.RelationshipCount + 1; - const documentXmlData = xml(this.formatter.format(file.Document), prettify); + const documentXmlData = xml(this.formatter.format(file.Document, file), prettify); const documentMediaDatas = this.imageReplacer.getMediaData(documentXmlData, file.Media); return { @@ -85,7 +85,7 @@ export class Compiler { ); }); - return xml(this.formatter.format(file.DocumentRelationships), prettify); + return xml(this.formatter.format(file.DocumentRelationships, file), prettify); })(), path: "word/_rels/document.xml.rels", }, @@ -99,11 +99,11 @@ export class Compiler { path: "word/document.xml", }, Styles: { - data: xml(this.formatter.format(file.Styles), prettify), + data: xml(this.formatter.format(file.Styles, file), prettify), path: "word/styles.xml", }, Properties: { - data: xml(this.formatter.format(file.CoreProperties), { + data: xml(this.formatter.format(file.CoreProperties, file), { declaration: { standalone: "yes", encoding: "UTF-8", @@ -112,15 +112,15 @@ export class Compiler { path: "docProps/core.xml", }, Numbering: { - data: xml(this.formatter.format(file.Numbering), prettify), + data: xml(this.formatter.format(file.Numbering, file), prettify), path: "word/numbering.xml", }, FileRelationships: { - data: xml(this.formatter.format(file.FileRelationships), prettify), + data: xml(this.formatter.format(file.FileRelationships, file), prettify), path: "_rels/.rels", }, HeaderRelationships: file.Headers.map((headerWrapper, index) => { - const xmlData = xml(this.formatter.format(headerWrapper.Header), prettify); + const xmlData = xml(this.formatter.format(headerWrapper.Header, file), prettify); const mediaDatas = this.imageReplacer.getMediaData(xmlData, file.Media); mediaDatas.forEach((mediaData, i) => { @@ -132,12 +132,12 @@ export class Compiler { }); return { - data: xml(this.formatter.format(headerWrapper.Relationships), prettify), + data: xml(this.formatter.format(headerWrapper.Relationships, file), prettify), path: `word/_rels/header${index + 1}.xml.rels`, }; }), FooterRelationships: file.Footers.map((footerWrapper, index) => { - const xmlData = xml(this.formatter.format(footerWrapper.Footer), prettify); + const xmlData = xml(this.formatter.format(footerWrapper.Footer, file), prettify); const mediaDatas = this.imageReplacer.getMediaData(xmlData, file.Media); mediaDatas.forEach((mediaData, i) => { @@ -149,12 +149,12 @@ export class Compiler { }); return { - data: xml(this.formatter.format(footerWrapper.Relationships), prettify), + data: xml(this.formatter.format(footerWrapper.Relationships, file), prettify), path: `word/_rels/footer${index + 1}.xml.rels`, }; }), Headers: file.Headers.map((headerWrapper, index) => { - const tempXmlData = xml(this.formatter.format(headerWrapper.Header), prettify); + const tempXmlData = xml(this.formatter.format(headerWrapper.Header, file), prettify); const mediaDatas = this.imageReplacer.getMediaData(tempXmlData, file.Media); // TODO: 0 needs to be changed when headers get relationships of their own const xmlData = this.imageReplacer.replace(tempXmlData, mediaDatas, 0); @@ -165,7 +165,7 @@ export class Compiler { }; }), Footers: file.Footers.map((footerWrapper, index) => { - const tempXmlData = xml(this.formatter.format(footerWrapper.Footer), prettify); + const tempXmlData = xml(this.formatter.format(footerWrapper.Footer, file), prettify); const mediaDatas = this.imageReplacer.getMediaData(tempXmlData, file.Media); // TODO: 0 needs to be changed when headers get relationships of their own const xmlData = this.imageReplacer.replace(tempXmlData, mediaDatas, 0); @@ -176,19 +176,19 @@ export class Compiler { }; }), ContentTypes: { - data: xml(this.formatter.format(file.ContentTypes), prettify), + data: xml(this.formatter.format(file.ContentTypes, file), prettify), path: "[Content_Types].xml", }, AppProperties: { - data: xml(this.formatter.format(file.AppProperties), prettify), + data: xml(this.formatter.format(file.AppProperties, file), prettify), path: "docProps/app.xml", }, FootNotes: { - data: xml(this.formatter.format(file.FootNotes), prettify), + data: xml(this.formatter.format(file.FootNotes, file), prettify), path: "word/footnotes.xml", }, Settings: { - data: xml(this.formatter.format(file.Settings), prettify), + data: xml(this.formatter.format(file.Settings, file), prettify), path: "word/settings.xml", }, }; diff --git a/src/file/core-properties/properties.ts b/src/file/core-properties/properties.ts index e807f0fd5f..ceddff39e0 100644 --- a/src/file/core-properties/properties.ts +++ b/src/file/core-properties/properties.ts @@ -2,10 +2,21 @@ import { XmlComponent } from "file/xml-components"; import { DocumentAttributes } from "../document/document-attributes"; import { INumberingOptions } from "../numbering"; -import { Paragraph } from "../paragraph"; +import { HyperlinkType, Paragraph } from "../paragraph"; import { IStylesOptions } from "../styles"; import { Created, Creator, Description, Keywords, LastModifiedBy, Modified, Revision, Subject, Title } from "./components"; +export interface IInternalHyperlinkDefinition { + readonly text: string; + readonly type: HyperlinkType.INTERNAL; +} + +export interface IExternalHyperlinkDefinition { + readonly link: string; + readonly text: string; + readonly type: HyperlinkType.EXTERNAL; +} + export interface IPropertiesOptions { readonly title?: string; readonly subject?: string; @@ -18,6 +29,9 @@ export interface IPropertiesOptions { readonly styles?: IStylesOptions; readonly numbering?: INumberingOptions; readonly footnotes?: Paragraph[]; + readonly hyperlinks?: { + readonly [key: string]: IInternalHyperlinkDefinition | IExternalHyperlinkDefinition; + }; } export class CoreProperties extends XmlComponent { diff --git a/src/file/document/body/body.ts b/src/file/document/body/body.ts index ddf577b429..a0b7c27a2c 100644 --- a/src/file/document/body/body.ts +++ b/src/file/document/body/body.ts @@ -1,5 +1,6 @@ import { IXmlableObject, XmlComponent } from "file/xml-components"; import { Paragraph, ParagraphProperties, TableOfContents } from "../.."; +import { File } from "../../../file"; import { SectionProperties, SectionPropertiesOptions } from "./section-properties/section-properties"; export class Body extends XmlComponent { @@ -24,13 +25,13 @@ export class Body extends XmlComponent { this.sections.push(new SectionProperties(options)); } - public prepForXml(): IXmlableObject | undefined { + public prepForXml(file?: File): IXmlableObject | undefined { if (this.sections.length === 1) { this.root.splice(0, 1); this.root.push(this.sections.pop() as SectionProperties); } - return super.prepForXml(); + return super.prepForXml(file); } public push(component: XmlComponent): void { diff --git a/src/file/document/document.ts b/src/file/document/document.ts index d8c0c55131..80b04a379c 100644 --- a/src/file/document/document.ts +++ b/src/file/document/document.ts @@ -1,6 +1,6 @@ // http://officeopenxml.com/WPdocument.php import { XmlComponent } from "file/xml-components"; -import { Paragraph } from "../paragraph"; +import { Hyperlink, Paragraph } from "../paragraph"; import { Table } from "../table"; import { TableOfContents } from "../table-of-contents"; import { Body } from "./body"; @@ -36,7 +36,7 @@ export class Document extends XmlComponent { this.root.push(this.body); } - public add(item: Paragraph | Table | TableOfContents): Document { + public add(item: Paragraph | Table | TableOfContents | Hyperlink): Document { this.body.push(item); return this; } diff --git a/src/file/file.spec.ts b/src/file/file.spec.ts index 13bdec87af..6ec30e1be9 100644 --- a/src/file/file.spec.ts +++ b/src/file/file.spec.ts @@ -5,7 +5,7 @@ import { Formatter } from "export/formatter"; import { File } from "./file"; import { Footer, Header } from "./header"; -import { Paragraph } from "./paragraph"; +import { HyperlinkRef, Paragraph } from "./paragraph"; import { Table, TableCell, TableRow } from "./table"; import { TableOfContents } from "./table-of-contents"; @@ -89,6 +89,91 @@ describe("File", () => { expect(tree["w:body"][0]["w:sectPr"][8]["w:footerReference"]._attr["w:type"]).to.equal("first"); expect(tree["w:body"][0]["w:sectPr"][9]["w:footerReference"]._attr["w:type"]).to.equal("even"); }); + + it("should add child", () => { + const doc = new File(undefined, undefined, [ + { + children: [new Paragraph("test")], + }, + ]); + + const tree = new Formatter().format(doc.Document.Body); + + expect(tree).to.deep.equal({ + "w:body": [ + { + "w:p": [ + { + "w:r": [ + { + "w:t": [ + { + _attr: { + "xml:space": "preserve", + }, + }, + "test", + ], + }, + ], + }, + ], + }, + { + "w:sectPr": [ + { + "w:pgSz": { + _attr: { + "w:h": 16838, + "w:orient": "portrait", + "w:w": 11906, + }, + }, + }, + { + "w:pgMar": { + _attr: { + "w:bottom": 1440, + "w:footer": 708, + "w:gutter": 0, + "w:header": 708, + "w:left": 1440, + "w:mirrorMargins": false, + "w:right": 1440, + "w:top": 1440, + }, + }, + }, + { + "w:cols": { + _attr: { + "w:num": 1, + "w:space": 708, + }, + }, + }, + { + "w:docGrid": { + _attr: { + "w:linePitch": 360, + }, + }, + }, + ], + }, + ], + }); + }); + + it("should add hyperlink child", () => { + const doc = new File(undefined, undefined, [ + { + children: [new HyperlinkRef("test")], + }, + ]); + + expect(doc.HyperlinkCache).to.deep.equal({}); + }); }); describe("#addSection", () => { @@ -102,6 +187,16 @@ describe("File", () => { expect(spy.called).to.equal(true); }); + it("should add hyperlink child", () => { + const doc = new File(); + + doc.addSection({ + children: [new HyperlinkRef("test")], + }); + + expect(doc.HyperlinkCache).to.deep.equal({}); + }); + it("should call the underlying document's add when adding a Table", () => { const file = new File(); const spy = sinon.spy(file.Document, "add"); @@ -148,6 +243,14 @@ describe("File", () => { }); }); + describe("#HyperlinkCache", () => { + it("should initially have empty hyperlink cache", () => { + const file = new File(); + + expect(file.HyperlinkCache).to.deep.equal({}); + }); + }); + describe("#createFootnote", () => { it("should create footnote", () => { const wrapper = new File({ diff --git a/src/file/file.ts b/src/file/file.ts index f2bcad3610..59af9fa7f7 100644 --- a/src/file/file.ts +++ b/src/file/file.ts @@ -17,7 +17,7 @@ import { Footer, Header } from "./header"; import { HeaderWrapper, IDocumentHeader } from "./header-wrapper"; import { Media } from "./media"; import { Numbering } from "./numbering"; -import { Bookmark, Hyperlink, Paragraph } from "./paragraph"; +import { Hyperlink, HyperlinkRef, HyperlinkType, Paragraph } from "./paragraph"; import { Relationships } from "./relationships"; import { TargetModeType } from "./relationships/relationship/relationship"; import { Settings } from "./settings"; @@ -41,7 +41,7 @@ export interface ISectionOptions { readonly size?: IPageSizeAttributes; readonly margins?: IPageMarginAttributes; readonly properties?: SectionPropertiesOptions; - readonly children: Array; + readonly children: Array; } export class File { @@ -61,13 +61,13 @@ export class File { private readonly contentTypes: ContentTypes; private readonly appProperties: AppProperties; private readonly styles: Styles; + private readonly hyperlinkCache: { readonly [key: string]: Hyperlink } = {}; constructor( options: IPropertiesOptions = { creator: "Un-named", revision: "1", lastModifiedBy: "Un-named", - footnotes: [], }, fileProperties: IFileProperties = {}, sections: ISectionOptions[] = [], @@ -134,6 +134,12 @@ export class File { this.document.Body.addSection(section.properties ? section.properties : {}); for (const child of section.children) { + if (child instanceof HyperlinkRef) { + const hyperlink = this.hyperlinkCache[child.id]; + this.document.add(hyperlink); + continue; + } + this.document.add(child); } } @@ -143,30 +149,27 @@ export class File { this.footNotes.createFootNote(paragraph); } } - } - public createHyperlink(link: string, text?: string): Hyperlink { - const newText = text === undefined ? link : text; - const hyperlink = new Hyperlink(newText, shortid.generate().toLowerCase()); - this.docRelationships.createRelationship( - hyperlink.linkId, - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", - link, - TargetModeType.EXTERNAL, - ); - return hyperlink; - } + if (options.hyperlinks) { + const cache = {}; - public createInternalHyperLink(anchor: string, text?: string): Hyperlink { - const newText = text === undefined ? anchor : text; - const hyperlink = new Hyperlink(newText, shortid.generate().toLowerCase(), anchor); - // NOTE: unlike File#createHyperlink(), since the link is to an internal bookmark - // we don't need to create a new relationship. - return hyperlink; - } + for (const key in options.hyperlinks) { + if (!options.hyperlinks[key]) { + continue; + } - public createBookmark(name: string, text: string = name): Bookmark { - return new Bookmark(name, text, this.docRelationships.RelationshipCount); + const hyperlinkRef = options.hyperlinks[key]; + + const hyperlink = + hyperlinkRef.type === HyperlinkType.EXTERNAL + ? this.createHyperlink(hyperlinkRef.link, hyperlinkRef.text) + : this.createInternalHyperLink(key, hyperlinkRef.text); + + cache[key] = hyperlink; + } + + this.hyperlinkCache = cache; + } } public addSection({ @@ -194,6 +197,12 @@ export class File { }); for (const child of children) { + if (child instanceof HyperlinkRef) { + const hyperlink = this.hyperlinkCache[child.id]; + this.document.add(hyperlink); + continue; + } + this.document.add(child); } } @@ -204,6 +213,24 @@ export class File { } } + private createHyperlink(link: string, text: string = link): Hyperlink { + const hyperlink = new Hyperlink(text, shortid.generate().toLowerCase()); + this.docRelationships.createRelationship( + hyperlink.linkId, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + link, + TargetModeType.EXTERNAL, + ); + return hyperlink; + } + + private createInternalHyperLink(anchor: string, text: string = anchor): Hyperlink { + const hyperlink = new Hyperlink(text, shortid.generate().toLowerCase(), anchor); + // NOTE: unlike File#createHyperlink(), since the link is to an internal bookmark + // we don't need to create a new relationship. + return hyperlink; + } + private createHeader(header: Header): HeaderWrapper { const wrapper = new HeaderWrapper(this.media, this.currentRelationshipId++); @@ -336,4 +363,8 @@ export class File { public get Settings(): Settings { return this.settings; } + + public get HyperlinkCache(): { readonly [key: string]: Hyperlink } { + return this.hyperlinkCache; + } } diff --git a/src/file/paragraph/formatting/style.ts b/src/file/paragraph/formatting/style.ts index a493e4b025..edde301290 100644 --- a/src/file/paragraph/formatting/style.ts +++ b/src/file/paragraph/formatting/style.ts @@ -11,11 +11,8 @@ export enum HeadingLevel { } export class Style extends XmlComponent { - public readonly styleId: string; - constructor(styleId: string) { super("w:pStyle"); - this.styleId = styleId; this.root.push( new Attributes({ val: styleId, diff --git a/src/file/paragraph/links/bookmark.spec.ts b/src/file/paragraph/links/bookmark.spec.ts index a597347013..fe342fc374 100644 --- a/src/file/paragraph/links/bookmark.spec.ts +++ b/src/file/paragraph/links/bookmark.spec.ts @@ -1,4 +1,4 @@ -import { assert } from "chai"; +import { assert, expect } from "chai"; import { Utility } from "tests/utility"; @@ -8,7 +8,7 @@ describe("Bookmark", () => { let bookmark: Bookmark; beforeEach(() => { - bookmark = new Bookmark("anchor", "Internal Link", 0); + bookmark = new Bookmark("anchor", "Internal Link"); }); it("should create a bookmark with three root elements", () => { @@ -21,11 +21,8 @@ describe("Bookmark", () => { it("should create a bookmark with the correct attributes on the bookmark start element", () => { const newJson = Utility.jsonify(bookmark); - const attributes = { - name: "anchor", - id: "1", - }; - assert.equal(JSON.stringify(newJson.start.root[0].root), JSON.stringify(attributes)); + + assert.equal(newJson.start.root[0].root.name, "anchor"); }); it("should create a bookmark with the correct attributes on the text element", () => { @@ -35,9 +32,6 @@ describe("Bookmark", () => { it("should create a bookmark with the correct attributes on the bookmark end element", () => { const newJson = Utility.jsonify(bookmark); - const attributes = { - id: "1", - }; - assert.equal(JSON.stringify(newJson.end.root[0].root), JSON.stringify(attributes)); + expect(newJson.end.root[0].root.id).to.be.a("string"); }); }); diff --git a/src/file/paragraph/links/bookmark.ts b/src/file/paragraph/links/bookmark.ts index c8c339e578..261a756864 100644 --- a/src/file/paragraph/links/bookmark.ts +++ b/src/file/paragraph/links/bookmark.ts @@ -1,49 +1,41 @@ // http://officeopenxml.com/WPbookmark.php import { XmlComponent } from "file/xml-components"; +import * as shortid from "shortid"; import { TextRun } from "../run"; import { BookmarkEndAttributes, BookmarkStartAttributes } from "./bookmark-attributes"; export class Bookmark { - public readonly linkId: number; public readonly start: BookmarkStart; public readonly text: TextRun; public readonly end: BookmarkEnd; - constructor(name: string, text: string, relationshipsCount: number) { - this.linkId = relationshipsCount + 1; + constructor(name: string, text: string) { + const linkId = shortid.generate().toLowerCase(); - this.start = new BookmarkStart(name, this.linkId); + this.start = new BookmarkStart(name, linkId); this.text = new TextRun(text); - this.end = new BookmarkEnd(this.linkId); + this.end = new BookmarkEnd(linkId); } } export class BookmarkStart extends XmlComponent { - public readonly linkId: number; - - constructor(name: string, relationshipsCount: number) { + constructor(name: string, linkId: string) { super("w:bookmarkStart"); - this.linkId = relationshipsCount; - const id = `${this.linkId}`; const attributes = new BookmarkStartAttributes({ name, - id, + id: linkId, }); this.root.push(attributes); } } export class BookmarkEnd extends XmlComponent { - public readonly linkId: number; - - constructor(relationshipsCount: number) { + constructor(linkId: string) { super("w:bookmarkEnd"); - this.linkId = relationshipsCount; - const id = `${this.linkId}`; const attributes = new BookmarkEndAttributes({ - id, + id: linkId, }); this.root.push(attributes); } diff --git a/src/file/paragraph/links/hyperlink.spec.ts b/src/file/paragraph/links/hyperlink.spec.ts index 93b59e07b6..4b06a933f5 100644 --- a/src/file/paragraph/links/hyperlink.spec.ts +++ b/src/file/paragraph/links/hyperlink.spec.ts @@ -3,6 +3,7 @@ import { expect } from "chai"; import { Formatter } from "export/formatter"; import { Hyperlink } from "./"; +import { HyperlinkRef } from "./hyperlink"; describe("Hyperlink", () => { let hyperlink: Hyperlink; @@ -59,3 +60,11 @@ describe("Hyperlink", () => { }); }); }); + +describe("HyperlinkRef", () => { + describe("#constructor()", () => { + const hyperlinkRef = new HyperlinkRef("test-id"); + + expect(hyperlinkRef.id).to.equal("test-id"); + }); +}); diff --git a/src/file/paragraph/links/hyperlink.ts b/src/file/paragraph/links/hyperlink.ts index 30e60616ec..302acfd603 100644 --- a/src/file/paragraph/links/hyperlink.ts +++ b/src/file/paragraph/links/hyperlink.ts @@ -3,6 +3,15 @@ import { XmlComponent } from "file/xml-components"; import { TextRun } from "../run"; import { HyperlinkAttributes, IHyperlinkAttributesProperties } from "./hyperlink-attributes"; +export enum HyperlinkType { + INTERNAL = "INTERNAL", + EXTERNAL = "EXTERNAL", +} + +export class HyperlinkRef { + constructor(public readonly id: string) {} +} + export class Hyperlink extends XmlComponent { public readonly linkId: string; private readonly textRun: TextRun; diff --git a/src/file/paragraph/paragraph.spec.ts b/src/file/paragraph/paragraph.spec.ts index b4a797a8c2..cdbe99c7d8 100644 --- a/src/file/paragraph/paragraph.spec.ts +++ b/src/file/paragraph/paragraph.spec.ts @@ -1,9 +1,12 @@ import { assert, expect } from "chai"; +import * as shortid from "shortid"; +import { stub } from "sinon"; import { Formatter } from "export/formatter"; import { EMPTY_OBJECT } from "file/xml-components"; import { AlignmentType, HeadingLevel, LeaderType, PageBreak, TabStopPosition, TabStopType } from "./formatting"; +import { Bookmark } from "./links"; import { Paragraph } from "./paragraph"; describe("Paragraph", () => { @@ -638,6 +641,49 @@ describe("Paragraph", () => { }); }); + it("it should add bookmark", () => { + stub(shortid, "generate").callsFake(() => { + return "test-unique-id"; + }); + const paragraph = new Paragraph({ + children: [new Bookmark("test-id", "test")], + }); + const tree = new Formatter().format(paragraph); + expect(tree).to.deep.equal({ + "w:p": [ + { + "w:bookmarkStart": { + _attr: { + "w:id": "test-unique-id", + "w:name": "test-id", + }, + }, + }, + { + "w:r": [ + { + "w:t": [ + { + _attr: { + "xml:space": "preserve", + }, + }, + "test", + ], + }, + ], + }, + { + "w:bookmarkEnd": { + _attr: { + "w:id": "test-unique-id", + }, + }, + }, + ], + }); + }); + describe("#style", () => { it("should set the paragraph style to the given styleId", () => { const paragraph = new Paragraph({ diff --git a/src/file/paragraph/paragraph.ts b/src/file/paragraph/paragraph.ts index 7de245fab8..c97d8d34fd 100644 --- a/src/file/paragraph/paragraph.ts +++ b/src/file/paragraph/paragraph.ts @@ -1,7 +1,8 @@ // http://officeopenxml.com/WPparagraph.php import { FootnoteReferenceRun } from "file/footnotes/footnote/run/reference-run"; -import { XmlComponent } from "file/xml-components"; +import { IXmlableObject, XmlComponent } from "file/xml-components"; +import { File } from "../file"; import { Alignment, AlignmentType } from "./formatting/alignment"; import { Bidirectional } from "./formatting/bidirectional"; import { IBorderOptions, ThematicBreak } from "./formatting/border"; @@ -12,7 +13,7 @@ import { ContextualSpacing, ISpacingProperties, Spacing } from "./formatting/spa import { HeadingLevel, Style } from "./formatting/style"; import { LeaderType, TabStop, TabStopPosition, TabStopType } from "./formatting/tab-stop"; import { NumberProperties } from "./formatting/unordered-list"; -import { Bookmark, Hyperlink, OutlineLevel } from "./links"; +import { Bookmark, HyperlinkRef, OutlineLevel } from "./links"; import { ParagraphProperties } from "./properties"; import { PictureRun, Run, SequentialIdentifier, SymbolRun, TextRun } from "./run"; @@ -45,7 +46,7 @@ export interface IParagraphOptions { readonly custom?: boolean; }; readonly children?: Array< - TextRun | PictureRun | Hyperlink | SymbolRun | Bookmark | PageBreak | SequentialIdentifier | FootnoteReferenceRun + TextRun | PictureRun | SymbolRun | Bookmark | PageBreak | SequentialIdentifier | FootnoteReferenceRun | HyperlinkRef >; } @@ -159,6 +160,17 @@ export class Paragraph extends XmlComponent { } } + public prepForXml(file: File): IXmlableObject | undefined { + for (const element of this.root) { + if (element instanceof HyperlinkRef) { + const index = this.root.indexOf(element); + this.root[index] = file.HyperlinkCache[element.id]; + } + } + + return super.prepForXml(); + } + public addRunToFront(run: Run): Paragraph { this.root.splice(1, 0, run); return this; diff --git a/src/file/paragraph/run/picture-run.ts b/src/file/paragraph/run/picture-run.ts index c796beebfc..e98dba573a 100644 --- a/src/file/paragraph/run/picture-run.ts +++ b/src/file/paragraph/run/picture-run.ts @@ -7,10 +7,6 @@ export class PictureRun extends Run { constructor(imageData: IMediaData, drawingOptions?: IDrawingOptions) { super({}); - if (imageData === undefined) { - throw new Error("imageData cannot be undefined"); - } - const drawing = new Drawing(imageData, drawingOptions); this.root.push(drawing); diff --git a/src/file/paragraph/run/run.spec.ts b/src/file/paragraph/run/run.spec.ts index 37aadfb8d8..b5febf199e 100644 --- a/src/file/paragraph/run/run.spec.ts +++ b/src/file/paragraph/run/run.spec.ts @@ -132,6 +132,30 @@ describe("Run", () => { }); }); + describe("#subScript()", () => { + it("it should add subScript to the properties", () => { + const run = new Run({ + subScript: true, + }); + const tree = new Formatter().format(run); + expect(tree).to.deep.equal({ + "w:r": [{ "w:rPr": [{ "w:vertAlign": { _attr: { "w:val": "subscript" } } }] }], + }); + }); + }); + + describe("#superScript()", () => { + it("it should add superScript to the properties", () => { + const run = new Run({ + superScript: true, + }); + const tree = new Formatter().format(run); + expect(tree).to.deep.equal({ + "w:r": [{ "w:rPr": [{ "w:vertAlign": { _attr: { "w:val": "superscript" } } }] }], + }); + }); + }); + describe("#highlight()", () => { it("it should add highlight to the properties", () => { const run = new Run({ diff --git a/src/file/table/table-cell/table-cell.ts b/src/file/table/table-cell/table-cell.ts index 469331bc08..fe8f8b5868 100644 --- a/src/file/table/table-cell/table-cell.ts +++ b/src/file/table/table-cell/table-cell.ts @@ -3,6 +3,7 @@ import { Paragraph } from "file/paragraph"; import { BorderStyle } from "file/styles"; import { IXmlableObject, XmlComponent } from "file/xml-components"; +import { File } from "../../file"; import { ITableShadingAttributesProperties } from "../shading"; import { Table } from "../table"; import { ITableCellMarginOptions } from "./cell-margin/table-cell-margins"; @@ -110,11 +111,11 @@ export class TableCell extends XmlComponent { } } - public prepForXml(): IXmlableObject | undefined { + public prepForXml(file?: File): IXmlableObject | undefined { // Cells must end with a paragraph if (!(this.root[this.root.length - 1] instanceof Paragraph)) { this.root.push(new Paragraph({})); } - return super.prepForXml(); + return super.prepForXml(file); } } diff --git a/src/file/xml-components/base.ts b/src/file/xml-components/base.ts index cfc4ec47b3..782dfdba12 100644 --- a/src/file/xml-components/base.ts +++ b/src/file/xml-components/base.ts @@ -1,3 +1,4 @@ +import { File } from "../file"; import { IXmlableObject } from "./xmlable-object"; export abstract class BaseXmlComponent { @@ -9,7 +10,7 @@ export abstract class BaseXmlComponent { this.rootKey = rootKey; } - public abstract prepForXml(): IXmlableObject | undefined; + public abstract prepForXml(file?: File): IXmlableObject | undefined; public get IsDeleted(): boolean { return this.deleted; diff --git a/src/file/xml-components/index.ts b/src/file/xml-components/index.ts index 66e9641bfd..295161b395 100644 --- a/src/file/xml-components/index.ts +++ b/src/file/xml-components/index.ts @@ -4,3 +4,4 @@ export * from "./default-attributes"; export * from "./imported-xml-component"; export * from "./xmlable-object"; export * from "./initializable-xml-component"; +export * from "./base"; diff --git a/src/file/xml-components/xml-component.ts b/src/file/xml-components/xml-component.ts index 59192e3e4d..dfe3800b96 100644 --- a/src/file/xml-components/xml-component.ts +++ b/src/file/xml-components/xml-component.ts @@ -1,19 +1,19 @@ +import { File } from "../file"; import { BaseXmlComponent } from "./base"; import { IXmlableObject } from "./xmlable-object"; -export { BaseXmlComponent }; export const EMPTY_OBJECT = Object.seal({}); export abstract class XmlComponent extends BaseXmlComponent { - // tslint:disable-next-line:readonly-keyword - protected root: Array; + // tslint:disable-next-line:readonly-keyword no-any + protected root: Array; constructor(rootKey: string) { super(rootKey); this.root = new Array(); } - public prepForXml(): IXmlableObject | undefined { + public prepForXml(file?: File): IXmlableObject | undefined { const children = this.root .filter((c) => { if (c instanceof BaseXmlComponent) { @@ -23,7 +23,7 @@ export abstract class XmlComponent extends BaseXmlComponent { }) .map((comp) => { if (comp instanceof BaseXmlComponent) { - return comp.prepForXml(); + return comp.prepForXml(file); } return comp; })