Make hyperlinks declarative

This commit is contained in:
Dolan
2019-12-18 21:11:15 +00:00
parent 2bece0bb61
commit c68dc8c52a
14 changed files with 114 additions and 53 deletions

View File

@ -1,15 +1,21 @@
// Example on how to add hyperlinks to websites // Example on how to add hyperlinks to websites
// Import from 'docx' rather than '../build' if you install from npm // Import from 'docx' rather than '../build' if you install from npm
import * as fs from "fs"; import * as fs from "fs";
import { Document, Packer, Paragraph } from "../build"; import { Document, HyperlinkRef, Packer, Paragraph } from "../build";
const doc = new Document(); const doc = new Document({
const link = doc.createHyperlink("http://www.example.com", "Hyperlink"); hyperlinks: {
myCoolLink: {
link: "http://www.example.com",
text: "Hyperlink",
},
},
});
doc.addSection({ doc.addSection({
children: [ children: [
new Paragraph({ new Paragraph({
children: [link], children: [new HyperlinkRef("myCoolLink")],
}), }),
], ],
}); });

View File

@ -1,8 +1,9 @@
import { BaseXmlComponent, IXmlableObject } from "file/xml-components"; import { BaseXmlComponent, IXmlableObject } from "file/xml-components";
import { File } from "../file";
export class Formatter { export class Formatter {
public format(input: BaseXmlComponent): IXmlableObject { public format(input: BaseXmlComponent, file?: File): IXmlableObject {
const output = input.prepForXml(); const output = input.prepForXml(file);
if (output) { if (output) {
return output; return output;

View File

@ -71,7 +71,7 @@ export class Compiler {
file.verifyUpdateFields(); file.verifyUpdateFields();
const documentRelationshipCount = file.DocumentRelationships.RelationshipCount + 1; 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); const documentMediaDatas = this.imageReplacer.getMediaData(documentXmlData, file.Media);
return { 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", path: "word/_rels/document.xml.rels",
}, },
@ -99,11 +99,11 @@ export class Compiler {
path: "word/document.xml", path: "word/document.xml",
}, },
Styles: { Styles: {
data: xml(this.formatter.format(file.Styles), prettify), data: xml(this.formatter.format(file.Styles, file), prettify),
path: "word/styles.xml", path: "word/styles.xml",
}, },
Properties: { Properties: {
data: xml(this.formatter.format(file.CoreProperties), { data: xml(this.formatter.format(file.CoreProperties, file), {
declaration: { declaration: {
standalone: "yes", standalone: "yes",
encoding: "UTF-8", encoding: "UTF-8",
@ -112,15 +112,15 @@ export class Compiler {
path: "docProps/core.xml", path: "docProps/core.xml",
}, },
Numbering: { Numbering: {
data: xml(this.formatter.format(file.Numbering), prettify), data: xml(this.formatter.format(file.Numbering, file), prettify),
path: "word/numbering.xml", path: "word/numbering.xml",
}, },
FileRelationships: { FileRelationships: {
data: xml(this.formatter.format(file.FileRelationships), prettify), data: xml(this.formatter.format(file.FileRelationships, file), prettify),
path: "_rels/.rels", path: "_rels/.rels",
}, },
HeaderRelationships: file.Headers.map((headerWrapper, index) => { 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); const mediaDatas = this.imageReplacer.getMediaData(xmlData, file.Media);
mediaDatas.forEach((mediaData, i) => { mediaDatas.forEach((mediaData, i) => {
@ -132,12 +132,12 @@ export class Compiler {
}); });
return { 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`, path: `word/_rels/header${index + 1}.xml.rels`,
}; };
}), }),
FooterRelationships: file.Footers.map((footerWrapper, index) => { 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); const mediaDatas = this.imageReplacer.getMediaData(xmlData, file.Media);
mediaDatas.forEach((mediaData, i) => { mediaDatas.forEach((mediaData, i) => {
@ -149,12 +149,12 @@ export class Compiler {
}); });
return { 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`, path: `word/_rels/footer${index + 1}.xml.rels`,
}; };
}), }),
Headers: file.Headers.map((headerWrapper, index) => { 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); const mediaDatas = this.imageReplacer.getMediaData(tempXmlData, file.Media);
// TODO: 0 needs to be changed when headers get relationships of their own // TODO: 0 needs to be changed when headers get relationships of their own
const xmlData = this.imageReplacer.replace(tempXmlData, mediaDatas, 0); const xmlData = this.imageReplacer.replace(tempXmlData, mediaDatas, 0);
@ -165,7 +165,7 @@ export class Compiler {
}; };
}), }),
Footers: file.Footers.map((footerWrapper, index) => { 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); const mediaDatas = this.imageReplacer.getMediaData(tempXmlData, file.Media);
// TODO: 0 needs to be changed when headers get relationships of their own // TODO: 0 needs to be changed when headers get relationships of their own
const xmlData = this.imageReplacer.replace(tempXmlData, mediaDatas, 0); const xmlData = this.imageReplacer.replace(tempXmlData, mediaDatas, 0);
@ -176,19 +176,19 @@ export class Compiler {
}; };
}), }),
ContentTypes: { ContentTypes: {
data: xml(this.formatter.format(file.ContentTypes), prettify), data: xml(this.formatter.format(file.ContentTypes, file), prettify),
path: "[Content_Types].xml", path: "[Content_Types].xml",
}, },
AppProperties: { AppProperties: {
data: xml(this.formatter.format(file.AppProperties), prettify), data: xml(this.formatter.format(file.AppProperties, file), prettify),
path: "docProps/app.xml", path: "docProps/app.xml",
}, },
FootNotes: { FootNotes: {
data: xml(this.formatter.format(file.FootNotes), prettify), data: xml(this.formatter.format(file.FootNotes, file), prettify),
path: "word/footnotes.xml", path: "word/footnotes.xml",
}, },
Settings: { Settings: {
data: xml(this.formatter.format(file.Settings), prettify), data: xml(this.formatter.format(file.Settings, file), prettify),
path: "word/settings.xml", path: "word/settings.xml",
}, },
}; };

View File

@ -18,6 +18,12 @@ export interface IPropertiesOptions {
readonly styles?: IStylesOptions; readonly styles?: IStylesOptions;
readonly numbering?: INumberingOptions; readonly numbering?: INumberingOptions;
readonly footnotes?: Paragraph[]; readonly footnotes?: Paragraph[];
readonly hyperlinks?: {
readonly [key: string]: {
readonly link: string;
readonly text: string;
};
};
} }
export class CoreProperties extends XmlComponent { export class CoreProperties extends XmlComponent {

View File

@ -1,5 +1,6 @@
import { IXmlableObject, XmlComponent } from "file/xml-components"; import { IXmlableObject, XmlComponent } from "file/xml-components";
import { Paragraph, ParagraphProperties, TableOfContents } from "../.."; import { Paragraph, ParagraphProperties, TableOfContents } from "../..";
import { File } from "../../../file";
import { SectionProperties, SectionPropertiesOptions } from "./section-properties/section-properties"; import { SectionProperties, SectionPropertiesOptions } from "./section-properties/section-properties";
export class Body extends XmlComponent { export class Body extends XmlComponent {
@ -24,12 +25,12 @@ export class Body extends XmlComponent {
this.sections.push(new SectionProperties(options)); this.sections.push(new SectionProperties(options));
} }
public prepForXml(): IXmlableObject | undefined { public prepForXml(file?: File): IXmlableObject | undefined {
if (this.sections.length === 1) { if (this.sections.length === 1) {
this.root.push(this.sections.pop() as SectionProperties); this.root.push(this.sections.pop() as SectionProperties);
} }
return super.prepForXml(); return super.prepForXml(file);
} }
public push(component: XmlComponent): void { public push(component: XmlComponent): void {

View File

@ -1,6 +1,6 @@
// http://officeopenxml.com/WPdocument.php // http://officeopenxml.com/WPdocument.php
import { XmlComponent } from "file/xml-components"; import { XmlComponent } from "file/xml-components";
import { Paragraph } from "../paragraph"; import { Hyperlink, Paragraph } from "../paragraph";
import { Table } from "../table"; import { Table } from "../table";
import { TableOfContents } from "../table-of-contents"; import { TableOfContents } from "../table-of-contents";
import { Body } from "./body"; import { Body } from "./body";
@ -36,7 +36,7 @@ export class Document extends XmlComponent {
this.root.push(this.body); this.root.push(this.body);
} }
public add(item: Paragraph | Table | TableOfContents): Document { public add(item: Paragraph | Table | TableOfContents | Hyperlink): Document {
this.body.push(item); this.body.push(item);
return this; return this;
} }

View File

@ -17,7 +17,7 @@ import { Footer, Header } from "./header";
import { HeaderWrapper, IDocumentHeader } from "./header-wrapper"; import { HeaderWrapper, IDocumentHeader } from "./header-wrapper";
import { Media } from "./media"; import { Media } from "./media";
import { Numbering } from "./numbering"; import { Numbering } from "./numbering";
import { Bookmark, Hyperlink, Paragraph } from "./paragraph"; import { Bookmark, Hyperlink, HyperlinkRef, Paragraph } from "./paragraph";
import { Relationships } from "./relationships"; import { Relationships } from "./relationships";
import { TargetModeType } from "./relationships/relationship/relationship"; import { TargetModeType } from "./relationships/relationship/relationship";
import { Settings } from "./settings"; import { Settings } from "./settings";
@ -61,13 +61,13 @@ export class File {
private readonly contentTypes: ContentTypes; private readonly contentTypes: ContentTypes;
private readonly appProperties: AppProperties; private readonly appProperties: AppProperties;
private readonly styles: Styles; private readonly styles: Styles;
private readonly hyperlinkCache: { readonly [key: string]: Hyperlink };
constructor( constructor(
options: IPropertiesOptions = { options: IPropertiesOptions = {
creator: "Un-named", creator: "Un-named",
revision: "1", revision: "1",
lastModifiedBy: "Un-named", lastModifiedBy: "Un-named",
footnotes: [],
}, },
fileProperties: IFileProperties = {}, fileProperties: IFileProperties = {},
sections: ISectionOptions[] = [], sections: ISectionOptions[] = [],
@ -134,6 +134,12 @@ export class File {
this.document.Body.addSection(section.properties ? section.properties : {}); this.document.Body.addSection(section.properties ? section.properties : {});
for (const child of section.children) { 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); this.document.add(child);
} }
} }
@ -143,18 +149,21 @@ export class File {
this.footNotes.createFootNote(paragraph); this.footNotes.createFootNote(paragraph);
} }
} }
}
public createHyperlink(link: string, text?: string): Hyperlink { if (options.hyperlinks) {
const newText = text === undefined ? link : text; const cache = {};
const hyperlink = new Hyperlink(newText, shortid.generate().toLowerCase());
this.docRelationships.createRelationship( for (const key in options.hyperlinks) {
hyperlink.linkId, if (!options.hyperlinks[key]) {
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", continue;
link, }
TargetModeType.EXTERNAL,
); const hyperlink = this.createHyperlink(options.hyperlinks[key].link, options.hyperlinks[key].text);
return hyperlink; cache[key] = hyperlink;
}
this.hyperlinkCache = cache;
}
} }
public createInternalHyperLink(anchor: string, text?: string): Hyperlink { public createInternalHyperLink(anchor: string, text?: string): Hyperlink {
@ -194,6 +203,12 @@ export class File {
}); });
for (const child of children) { for (const child of children) {
if (child instanceof HyperlinkRef) {
const hyperlink = this.hyperlinkCache[child.id];
this.document.add(hyperlink);
continue;
}
this.document.add(child); 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 { private createHeader(header: Header): HeaderWrapper {
const wrapper = new HeaderWrapper(this.media, this.currentRelationshipId++); const wrapper = new HeaderWrapper(this.media, this.currentRelationshipId++);
@ -336,4 +363,8 @@ export class File {
public get Settings(): Settings { public get Settings(): Settings {
return this.settings; return this.settings;
} }
public get HyperlinkCache(): { readonly [key: string]: Hyperlink } {
return this.hyperlinkCache;
}
} }

View File

@ -11,11 +11,8 @@ export enum HeadingLevel {
} }
export class Style extends XmlComponent { export class Style extends XmlComponent {
public readonly styleId: string;
constructor(styleId: string) { constructor(styleId: string) {
super("w:pStyle"); super("w:pStyle");
this.styleId = styleId;
this.root.push( this.root.push(
new Attributes({ new Attributes({
val: styleId, val: styleId,

View File

@ -3,6 +3,10 @@ import { XmlComponent } from "file/xml-components";
import { TextRun } from "../run"; import { TextRun } from "../run";
import { HyperlinkAttributes, IHyperlinkAttributesProperties } from "./hyperlink-attributes"; import { HyperlinkAttributes, IHyperlinkAttributesProperties } from "./hyperlink-attributes";
export class HyperlinkRef {
constructor(public readonly id: string) {}
}
export class Hyperlink extends XmlComponent { export class Hyperlink extends XmlComponent {
public readonly linkId: string; public readonly linkId: string;
private readonly textRun: TextRun; private readonly textRun: TextRun;

View File

@ -1,7 +1,8 @@
// http://officeopenxml.com/WPparagraph.php // http://officeopenxml.com/WPparagraph.php
import { FootnoteReferenceRun } from "file/footnotes/footnote/run/reference-run"; 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 { Alignment, AlignmentType } from "./formatting/alignment";
import { Bidirectional } from "./formatting/bidirectional"; import { Bidirectional } from "./formatting/bidirectional";
import { IBorderOptions, ThematicBreak } from "./formatting/border"; import { IBorderOptions, ThematicBreak } from "./formatting/border";
@ -12,7 +13,7 @@ import { ContextualSpacing, ISpacingProperties, Spacing } from "./formatting/spa
import { HeadingLevel, Style } from "./formatting/style"; import { HeadingLevel, Style } from "./formatting/style";
import { LeaderType, TabStop, TabStopPosition, TabStopType } from "./formatting/tab-stop"; import { LeaderType, TabStop, TabStopPosition, TabStopType } from "./formatting/tab-stop";
import { NumberProperties } from "./formatting/unordered-list"; import { NumberProperties } from "./formatting/unordered-list";
import { Bookmark, Hyperlink, OutlineLevel } from "./links"; import { Bookmark, HyperlinkRef, OutlineLevel } from "./links";
import { ParagraphProperties } from "./properties"; import { ParagraphProperties } from "./properties";
import { PictureRun, Run, SequentialIdentifier, SymbolRun, TextRun } from "./run"; import { PictureRun, Run, SequentialIdentifier, SymbolRun, TextRun } from "./run";
@ -45,7 +46,7 @@ export interface IParagraphOptions {
readonly custom?: boolean; readonly custom?: boolean;
}; };
readonly children?: Array< 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 { public addRunToFront(run: Run): Paragraph {
this.root.splice(1, 0, run); this.root.splice(1, 0, run);
return this; return this;

View File

@ -3,6 +3,7 @@ import { Paragraph } from "file/paragraph";
import { BorderStyle } from "file/styles"; import { BorderStyle } from "file/styles";
import { IXmlableObject, XmlComponent } from "file/xml-components"; import { IXmlableObject, XmlComponent } from "file/xml-components";
import { File } from "../../file";
import { ITableShadingAttributesProperties } from "../shading"; import { ITableShadingAttributesProperties } from "../shading";
import { Table } from "../table"; import { Table } from "../table";
import { ITableCellMarginOptions } from "./cell-margin/table-cell-margins"; 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 // Cells must end with a paragraph
if (!(this.root[this.root.length - 1] instanceof Paragraph)) { if (!(this.root[this.root.length - 1] instanceof Paragraph)) {
this.root.push(new Paragraph({})); this.root.push(new Paragraph({}));
} }
return super.prepForXml(); return super.prepForXml(file);
} }
} }

View File

@ -1,3 +1,4 @@
import { File } from "../file";
import { IXmlableObject } from "./xmlable-object"; import { IXmlableObject } from "./xmlable-object";
export abstract class BaseXmlComponent { export abstract class BaseXmlComponent {
@ -9,7 +10,7 @@ export abstract class BaseXmlComponent {
this.rootKey = rootKey; this.rootKey = rootKey;
} }
public abstract prepForXml(): IXmlableObject | undefined; public abstract prepForXml(file?: File): IXmlableObject | undefined;
public get IsDeleted(): boolean { public get IsDeleted(): boolean {
return this.deleted; return this.deleted;

View File

@ -4,3 +4,4 @@ export * from "./default-attributes";
export * from "./imported-xml-component"; export * from "./imported-xml-component";
export * from "./xmlable-object"; export * from "./xmlable-object";
export * from "./initializable-xml-component"; export * from "./initializable-xml-component";
export * from "./base";

View File

@ -1,19 +1,19 @@
import { File } from "../file";
import { BaseXmlComponent } from "./base"; import { BaseXmlComponent } from "./base";
import { IXmlableObject } from "./xmlable-object"; import { IXmlableObject } from "./xmlable-object";
export { BaseXmlComponent };
export const EMPTY_OBJECT = Object.seal({}); export const EMPTY_OBJECT = Object.seal({});
export abstract class XmlComponent extends BaseXmlComponent { export abstract class XmlComponent extends BaseXmlComponent {
// tslint:disable-next-line:readonly-keyword // tslint:disable-next-line:readonly-keyword no-any
protected root: Array<BaseXmlComponent | string>; protected root: Array<BaseXmlComponent | string | any>;
constructor(rootKey: string) { constructor(rootKey: string) {
super(rootKey); super(rootKey);
this.root = new Array<BaseXmlComponent | string>(); this.root = new Array<BaseXmlComponent | string>();
} }
public prepForXml(): IXmlableObject | undefined { public prepForXml(file?: File): IXmlableObject | undefined {
const children = this.root const children = this.root
.filter((c) => { .filter((c) => {
if (c instanceof BaseXmlComponent) { if (c instanceof BaseXmlComponent) {
@ -23,7 +23,7 @@ export abstract class XmlComponent extends BaseXmlComponent {
}) })
.map((comp) => { .map((comp) => {
if (comp instanceof BaseXmlComponent) { if (comp instanceof BaseXmlComponent) {
return comp.prepForXml(); return comp.prepForXml(file);
} }
return comp; return comp;
}) })