#773 Better hyperlink and bookmark syntax

Allow for images to be hyperlinked as well
This commit is contained in:
Dolan
2021-02-27 19:23:29 +00:00
parent 4159be5644
commit 0de7116b78
13 changed files with 195 additions and 203 deletions

View File

@ -3,21 +3,10 @@ import { IDocumentBackgroundOptions } from "../document";
import { DocumentAttributes } from "../document/document-attributes";
import { INumberingOptions } from "../numbering";
import { HyperlinkType, Paragraph } from "../paragraph";
import { 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;
@ -30,9 +19,6 @@ export interface IPropertiesOptions {
readonly styles?: IStylesOptions;
readonly numbering?: INumberingOptions;
readonly footnotes?: Paragraph[];
readonly hyperlinks?: {
readonly [key: string]: IInternalHyperlinkDefinition | IExternalHyperlinkDefinition;
};
readonly background?: IDocumentBackgroundOptions;
readonly features?: {
readonly trackRevisions?: boolean;

View File

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

View File

@ -5,7 +5,7 @@ import { Formatter } from "export/formatter";
import { File } from "./file";
import { Footer, Header } from "./header";
import { HyperlinkRef, HyperlinkType, Paragraph } from "./paragraph";
import { Paragraph } from "./paragraph";
import { Table, TableCell, TableRow } from "./table";
import { TableOfContents } from "./table-of-contents";
@ -164,16 +164,6 @@ describe("File", () => {
],
});
});
it("should add hyperlink child", () => {
const doc = new File(undefined, undefined, [
{
children: [new HyperlinkRef("test")],
},
]);
expect(doc.HyperlinkCache).to.deep.equal({});
});
});
describe("#addSection", () => {
@ -187,16 +177,6 @@ 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");
@ -256,29 +236,6 @@ describe("File", () => {
});
});
describe("#HyperlinkCache", () => {
it("should initially have empty hyperlink cache", () => {
const file = new File();
expect(file.HyperlinkCache).to.deep.equal({});
});
it("should have hyperlink cache when option is added", () => {
const file = new File({
hyperlinks: {
myCoolLink: {
link: "http://www.example.com",
text: "Hyperlink",
type: HyperlinkType.EXTERNAL,
},
},
});
// tslint:disable-next-line: no-unused-expression no-string-literal
expect(file.HyperlinkCache["myCoolLink"]).to.exist;
});
});
describe("#createFootnote", () => {
it("should create footnote", () => {
const wrapper = new File({

View File

@ -1,4 +1,3 @@
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";
@ -17,9 +16,8 @@ import { Footer, Header } from "./header";
import { HeaderWrapper, IDocumentHeader } from "./header-wrapper";
import { Media } from "./media";
import { Numbering } from "./numbering";
import { Hyperlink, HyperlinkRef, HyperlinkType, Paragraph } from "./paragraph";
import { Paragraph } from "./paragraph";
import { Relationships } from "./relationships";
import { TargetModeType } from "./relationships/relationship/relationship";
import { Settings } from "./settings";
import { Styles } from "./styles";
import { ExternalStylesFactory } from "./styles/external-styles-factory";
@ -41,7 +39,7 @@ export interface ISectionOptions {
readonly size?: IPageSizeAttributes;
readonly margins?: IPageMarginAttributes;
readonly properties?: SectionPropertiesOptions;
readonly children: (Paragraph | Table | TableOfContents | HyperlinkRef)[];
readonly children: (Paragraph | Table | TableOfContents)[];
}
export class File {
@ -61,7 +59,6 @@ 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 = {
@ -136,12 +133,6 @@ 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);
}
}
@ -152,27 +143,6 @@ export class File {
}
}
if (options.hyperlinks) {
const cache = {};
for (const key in options.hyperlinks) {
if (!options.hyperlinks[key]) {
continue;
}
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;
}
if (options.features) {
if (options.features.trackRevisions) {
this.settings.addTrackRevisions();
@ -205,12 +175,6 @@ 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);
}
}
@ -221,24 +185,6 @@ 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++);
@ -371,8 +317,4 @@ export class File {
public get Settings(): Settings {
return this.settings;
}
public get HyperlinkCache(): { readonly [key: string]: Hyperlink } {
return this.hyperlinkCache;
}
}

View File

@ -82,7 +82,7 @@ export class Media {
return imageData;
}
public get Array(): IMediaData[] {
public get Array(): readonly IMediaData[] {
const array = new Array<IMediaData>();
this.map.forEach((data) => {

View File

@ -2,14 +2,20 @@ import { expect } from "chai";
import { Formatter } from "export/formatter";
import { Hyperlink } from "./";
import { HyperlinkRef } from "./hyperlink";
import { TextRun } from "../run";
import { ConcreteHyperlink, ExternalHyperlink, InternalHyperlink } from "./hyperlink";
describe("Hyperlink", () => {
let hyperlink: Hyperlink;
describe("ConcreteHyperlink", () => {
let hyperlink: ConcreteHyperlink;
beforeEach(() => {
hyperlink = new Hyperlink("https://example.com", "superid");
hyperlink = new ConcreteHyperlink(
new TextRun({
text: "https://example.com",
style: "Hyperlink",
}),
"superid",
);
});
describe("#constructor()", () => {
@ -35,7 +41,14 @@ describe("Hyperlink", () => {
describe("with optional anchor parameter", () => {
beforeEach(() => {
hyperlink = new Hyperlink("Anchor Text", "superid2", "anchor");
hyperlink = new ConcreteHyperlink(
new TextRun({
text: "Anchor Text",
style: "Hyperlink",
}),
"superid2",
"anchor",
);
});
it("should create an internal link with anchor tag", () => {
@ -61,10 +74,53 @@ describe("Hyperlink", () => {
});
});
describe("HyperlinkRef", () => {
describe("ExternalHyperlink", () => {
describe("#constructor()", () => {
const hyperlinkRef = new HyperlinkRef("test-id");
it("should create", () => {
const externalHyperlink = new ExternalHyperlink({
child: new TextRun("test"),
link: "http://www.google.com",
});
expect(hyperlinkRef.id).to.equal("test-id");
expect(externalHyperlink.options.link).to.equal("http://www.google.com");
});
});
});
describe("InternalHyperlink", () => {
describe("#constructor()", () => {
it("should create", () => {
const internalHyperlink = new InternalHyperlink({
child: new TextRun("test"),
anchor: "test-id",
});
const tree = new Formatter().format(internalHyperlink);
expect(tree).to.deep.equal({
"w:hyperlink": [
{
_attr: {
"w:anchor": "test-id",
"w:history": 1,
},
},
{
"w:r": [
{
"w:t": [
{
_attr: {
"xml:space": "preserve",
},
},
"test",
],
},
],
},
],
});
});
});
});

View File

@ -1,6 +1,9 @@
// http://officeopenxml.com/WPhyperlink.php
import * as shortid from "shortid";
import { XmlComponent } from "file/xml-components";
import { TextRun } from "../run";
import { ParagraphChild } from "../paragraph";
import { HyperlinkAttributes, IHyperlinkAttributesProperties } from "./hyperlink-attributes";
export enum HyperlinkType {
@ -8,15 +11,10 @@ export enum HyperlinkType {
EXTERNAL = "EXTERNAL",
}
export class HyperlinkRef {
constructor(public readonly id: string) {}
}
export class Hyperlink extends XmlComponent {
export class ConcreteHyperlink extends XmlComponent {
public readonly linkId: string;
private readonly textRun: TextRun;
constructor(text: string, relationshipId: string, anchor?: string) {
constructor(child: ParagraphChild, relationshipId: string, anchor?: string) {
super("w:hyperlink");
this.linkId = relationshipId;
@ -29,14 +27,16 @@ export class Hyperlink extends XmlComponent {
const attributes = new HyperlinkAttributes(props);
this.root.push(attributes);
this.textRun = new TextRun({
text: text,
style: "Hyperlink",
});
this.root.push(this.textRun);
}
public get TextRun(): TextRun {
return this.textRun;
this.root.push(child);
}
}
export class InternalHyperlink extends ConcreteHyperlink {
constructor(options: { readonly child: ParagraphChild; readonly anchor: string }) {
super(options.child, shortid.generate().toLowerCase(), options.anchor);
}
}
export class ExternalHyperlink {
constructor(public readonly options: { readonly child: ParagraphChild; readonly link: string }) {}
}

View File

@ -8,8 +8,9 @@ import { EMPTY_OBJECT } from "file/xml-components";
import { File } from "../file";
import { ShadingType } from "../table/shading";
import { AlignmentType, HeadingLevel, LeaderType, PageBreak, TabStopPosition, TabStopType } from "./formatting";
import { Bookmark, HyperlinkRef } from "./links";
import { Bookmark, ExternalHyperlink } from "./links";
import { Paragraph } from "./paragraph";
import { TextRun } from "./run";
describe("Paragraph", () => {
describe("#constructor()", () => {
@ -763,7 +764,7 @@ describe("Paragraph", () => {
});
describe("#shading", () => {
it("should set paragraph outline level to the given value", () => {
it("should set shading to the given value", () => {
const paragraph = new Paragraph({
shading: {
type: ShadingType.REVERSE_DIAGONAL_STRIPE,
@ -793,20 +794,49 @@ describe("Paragraph", () => {
});
describe("#prepForXml", () => {
it("should set paragraph outline level to the given value", () => {
it("should set Internal Hyperlink", () => {
const paragraph = new Paragraph({
children: [new HyperlinkRef("myAnchorId")],
children: [
new ExternalHyperlink({
child: new TextRun("test"),
link: "http://www.google.com",
}),
],
});
const fileMock = ({
HyperlinkCache: {
myAnchorId: "test",
DocumentRelationships: {
createRelationship: () => ({}),
},
// tslint:disable-next-line: no-any
} as any) as File;
} as unknown) as File;
paragraph.prepForXml(fileMock);
const tree = new Formatter().format(paragraph);
expect(tree).to.deep.equal({
"w:p": ["test"],
"w:p": [
{
"w:hyperlink": [
{
_attr: {
"r:id": "rIdtest-unique-id",
"w:history": 1,
},
},
{
"w:r": [
{
"w:t": [
{
_attr: {
"xml:space": "preserve",
},
},
"test",
],
},
],
},
],
},
],
});
});
});

View File

@ -1,30 +1,35 @@
// http://officeopenxml.com/WPparagraph.php
import * as shortid from "shortid";
import { FootnoteReferenceRun } from "file/footnotes/footnote/run/reference-run";
import { IXmlableObject, XmlComponent } from "file/xml-components";
import { File } from "../file";
import { TargetModeType } from "../relationships/relationship/relationship";
import { DeletedTextRun, InsertedTextRun } from "../track-revision";
import { PageBreak } from "./formatting/page-break";
import { Bookmark, HyperlinkRef } from "./links";
import { Bookmark, ConcreteHyperlink, ExternalHyperlink, InternalHyperlink } from "./links";
import { Math } from "./math";
import { IParagraphPropertiesOptions, ParagraphProperties } from "./properties";
import { PictureRun, Run, SequentialIdentifier, SymbolRun, TextRun } from "./run";
export type ParagraphChild =
| TextRun
| PictureRun
| SymbolRun
| Bookmark
| PageBreak
| SequentialIdentifier
| FootnoteReferenceRun
| InternalHyperlink
| ExternalHyperlink
| InsertedTextRun
| DeletedTextRun
| Math;
export interface IParagraphOptions extends IParagraphPropertiesOptions {
readonly text?: string;
readonly children?: (
| TextRun
| PictureRun
| SymbolRun
| Bookmark
| PageBreak
| SequentialIdentifier
| FootnoteReferenceRun
| HyperlinkRef
| InsertedTextRun
| DeletedTextRun
| Math
)[];
readonly children?: ParagraphChild[];
}
export class Paragraph extends XmlComponent {
@ -71,9 +76,16 @@ export class Paragraph extends XmlComponent {
public prepForXml(file: File): IXmlableObject | undefined {
for (const element of this.root) {
if (element instanceof HyperlinkRef) {
if (element instanceof ExternalHyperlink) {
const index = this.root.indexOf(element);
this.root[index] = file.HyperlinkCache[element.id];
const concreteHyperlink = new ConcreteHyperlink(element.options.child, shortid.generate().toLowerCase());
file.DocumentRelationships.createRelationship(
concreteHyperlink.linkId,
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
element.options.link,
TargetModeType.EXTERNAL,
);
this.root[index] = concreteHyperlink;
}
}