From c68dc8c52a44eb2ec198331bb1d2ddade149b330 Mon Sep 17 00:00:00 2001 From: Dolan Date: Wed, 18 Dec 2019 21:11:15 +0000 Subject: [PATCH] Make hyperlinks declarative --- demo/35-hyperlinks.ts | 14 ++++-- src/export/formatter.ts | 5 ++- src/export/packer/next-compiler.ts | 32 ++++++------- src/file/core-properties/properties.ts | 6 +++ src/file/document/body/body.ts | 5 ++- src/file/document/document.ts | 4 +- src/file/file.ts | 57 ++++++++++++++++++------ src/file/paragraph/formatting/style.ts | 3 -- src/file/paragraph/links/hyperlink.ts | 4 ++ src/file/paragraph/paragraph.ts | 18 ++++++-- src/file/table/table-cell/table-cell.ts | 5 ++- src/file/xml-components/base.ts | 3 +- src/file/xml-components/index.ts | 1 + src/file/xml-components/xml-component.ts | 10 ++--- 14 files changed, 114 insertions(+), 53 deletions(-) diff --git a/demo/35-hyperlinks.ts b/demo/35-hyperlinks.ts index 6392299b84..9d2bd5062e 100644 --- a/demo/35-hyperlinks.ts +++ b/demo/35-hyperlinks.ts @@ -1,15 +1,21 @@ // 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, 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", + }, + }, +}); doc.addSection({ children: [ new Paragraph({ - children: [link], + children: [new HyperlinkRef("myCoolLink")], }), ], }); 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..3fb7bf45a4 100644 --- a/src/file/core-properties/properties.ts +++ b/src/file/core-properties/properties.ts @@ -18,6 +18,12 @@ export interface IPropertiesOptions { readonly styles?: IStylesOptions; readonly numbering?: INumberingOptions; readonly footnotes?: Paragraph[]; + readonly hyperlinks?: { + readonly [key: string]: { + readonly link: string; + readonly text: string; + }; + }; } export class CoreProperties extends XmlComponent { diff --git a/src/file/document/body/body.ts b/src/file/document/body/body.ts index e91b72d59a..1fcb3ab412 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,12 +25,12 @@ 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.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.ts b/src/file/file.ts index f2bcad3610..14f6382b9f 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 { Bookmark, Hyperlink, HyperlinkRef, Paragraph } from "./paragraph"; import { Relationships } from "./relationships"; import { TargetModeType } from "./relationships/relationship/relationship"; import { Settings } from "./settings"; @@ -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,18 +149,21 @@ 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 = {}; + + for (const key in options.hyperlinks) { + if (!options.hyperlinks[key]) { + continue; + } + + const hyperlink = this.createHyperlink(options.hyperlinks[key].link, options.hyperlinks[key].text); + cache[key] = hyperlink; + } + + this.hyperlinkCache = cache; + } } public createInternalHyperLink(anchor: string, text?: string): Hyperlink { @@ -194,6 +203,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 +219,18 @@ export class File { } } + private 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; + } + 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/hyperlink.ts b/src/file/paragraph/links/hyperlink.ts index 30e60616ec..ed69331863 100644 --- a/src/file/paragraph/links/hyperlink.ts +++ b/src/file/paragraph/links/hyperlink.ts @@ -3,6 +3,10 @@ import { XmlComponent } from "file/xml-components"; import { TextRun } from "../run"; import { HyperlinkAttributes, IHyperlinkAttributesProperties } from "./hyperlink-attributes"; +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.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/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; })