diff --git a/demo/85-template-document.ts b/demo/85-template-document.ts index 64d4f0a58b..2a032586fb 100644 --- a/demo/85-template-document.ts +++ b/demo/85-template-document.ts @@ -28,7 +28,17 @@ patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), { }, item_1: { type: PatchType.PARAGRAPH, - children: [new TextRun("#657")], + children: [ + new TextRun("#657"), + new ExternalHyperlink({ + children: [ + new TextRun({ + text: "BBC News Link", + }), + ], + link: "https://www.bbc.co.uk/news", + }), + ], }, paragraph_replace: { type: PatchType.DOCUMENT, @@ -47,7 +57,6 @@ patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), { children: [ new TextRun({ text: "BBC News Link", - style: "Hyperlink", }), ], link: "https://www.bbc.co.uk/news", diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts index 339ba964ec..c05ccbb45d 100644 --- a/src/patcher/from-docx.ts +++ b/src/patcher/from-docx.ts @@ -1,13 +1,15 @@ import * as JSZip from "jszip"; import { Element, js2xml } from "xml-js"; -import { ParagraphChild } from "@file/paragraph"; +import { ConcreteHyperlink, ExternalHyperlink, ParagraphChild } from "@file/paragraph"; import { FileChild } from "@file/file-child"; import { IMediaData, Media } from "@file/media"; import { IViewWrapper } from "@file/document-wrapper"; import { File } from "@file/file"; import { IContext } from "@file/xml-components"; import { ImageReplacer } from "@export/packer/image-replacer"; +import { TargetModeType } from "@file/relationships/relationship/relationship"; +import { uniqueId } from "@util/convenience-functions"; import { replacer } from "./replacer"; import { findLocationOfText } from "./traverser"; @@ -33,11 +35,16 @@ type FilePatch = { readonly children: readonly FileChild[]; }; -interface IRelationshipReplacement { +interface IImageRelationshipAddition { readonly key: string; readonly mediaDatas: readonly IMediaData[]; } +interface IHyperlinkRelationshipAddition { + readonly key: string; + readonly hyperlink: { readonly id: string; readonly link: string }; +} + export type IPatch = ParagraphPatch | FilePatch; export interface PatchDocumentOptions { @@ -60,7 +67,9 @@ export const patchDocument = async (data: InputDataType, options: PatchDocumentO const map = new Map(); // eslint-disable-next-line functional/prefer-readonly-type - const relationshipReplacement: IRelationshipReplacement[] = []; + const imageRelationshipAdditions: IImageRelationshipAddition[] = []; + // eslint-disable-next-line functional/prefer-readonly-type + const hyperlinkRelationshipAdditions: IHyperlinkRelationshipAddition[] = []; let hasMedia = false; for (const [key, value] of Object.entries(zipContent.files)) { @@ -70,14 +79,39 @@ export const patchDocument = async (data: InputDataType, options: PatchDocumentO const patchText = `{{${patchKey}}}`; const renderedParagraphs = findLocationOfText(json, patchText); // TODO: mutates json. Make it immutable - replacer(json, patchValue, patchText, renderedParagraphs, context); + replacer( + json, + { + ...patchValue, + children: patchValue.children.map((element) => { + // We need to replace external hyperlinks with concrete hyperlinks + if (element instanceof ExternalHyperlink) { + const concreteHyperlink = new ConcreteHyperlink(element.options.children, uniqueId()); + // eslint-disable-next-line functional/immutable-data + hyperlinkRelationshipAdditions.push({ + key, + hyperlink: { + id: concreteHyperlink.linkId, + link: element.options.link, + }, + }); + return concreteHyperlink; + } else { + return element; + } + }), + }, + patchText, + renderedParagraphs, + context, + ); } const mediaDatas = imageReplacer.getMediaData(JSON.stringify(json), context.file.Media); if (mediaDatas.length > 0) { hasMedia = true; // eslint-disable-next-line functional/immutable-data - relationshipReplacement.push({ + imageRelationshipAdditions.push({ key, mediaDatas, }); @@ -87,24 +121,55 @@ export const patchDocument = async (data: InputDataType, options: PatchDocumentO map.set(key, json); } - for (const { key, mediaDatas } of relationshipReplacement) { + for (const { key, mediaDatas } of imageRelationshipAdditions) { // eslint-disable-next-line functional/immutable-data - const relationshipsJson = map.get(`word/_rels/${key.split("/").pop()}.rels`); + const relationshipKey = `word/_rels/${key.split("/").pop()}.rels`; - if (relationshipsJson) { - const index = getNextRelationshipIndex(relationshipsJson); - const newJson = imageReplacer.replace(JSON.stringify(map.get(key)), mediaDatas, index); - map.set(key, JSON.parse(newJson) as Element); - - for (const { fileName } of mediaDatas) { - appendRelationship( - relationshipsJson, - index, - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", - `media/${fileName}`, - ); - } + if (!map.has(relationshipKey)) { + map.set(relationshipKey, createRelationshipFile()); } + + const relationshipsJson = map.get(relationshipKey); + + if (!relationshipsJson) { + throw new Error("Could not find relationships file"); + } + + const index = getNextRelationshipIndex(relationshipsJson); + const newJson = imageReplacer.replace(JSON.stringify(map.get(key)), mediaDatas, index); + map.set(key, JSON.parse(newJson) as Element); + + for (const { fileName } of mediaDatas) { + appendRelationship( + relationshipsJson, + index, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + `media/${fileName}`, + ); + } + } + + for (const { key, hyperlink } of hyperlinkRelationshipAdditions) { + // eslint-disable-next-line functional/immutable-data + const relationshipKey = `word/_rels/${key.split("/").pop()}.rels`; + + if (!map.has(relationshipKey)) { + map.set(relationshipKey, createRelationshipFile()); + } + + const relationshipsJson = map.get(relationshipKey); + + if (!relationshipsJson) { + throw new Error("Could not find relationships file"); + } + + appendRelationship( + relationshipsJson, + hyperlink.id, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + hyperlink.link, + TargetModeType.EXTERNAL, + ); } if (hasMedia) { @@ -144,3 +209,23 @@ const toXml = (jsonObj: Element): string => { const output = js2xml(jsonObj); return output; }; + +const createRelationshipFile = (): Element => ({ + declaration: { + attributes: { + version: "1.0", + encoding: "UTF-8", + standalone: "yes", + }, + }, + elements: [ + { + type: "element", + name: "Relationships", + attributes: { + xmlns: "http://schemas.openxmlformats.org/package/2006/relationships", + }, + elements: [], + }, + ], +}); diff --git a/src/patcher/relationship-manager.ts b/src/patcher/relationship-manager.ts index 30f3bd425e..35eaab8e71 100644 --- a/src/patcher/relationship-manager.ts +++ b/src/patcher/relationship-manager.ts @@ -1,6 +1,6 @@ import { Element } from "xml-js"; -import { RelationshipType } from "@file/relationships/relationship/relationship"; +import { RelationshipType, TargetModeType } from "@file/relationships/relationship/relationship"; import { getFirstLevelElements } from "./util"; const getIdFromRelationshipId = (relationshipId: string): number => parseInt(relationshipId.substring(3), 10); @@ -15,7 +15,13 @@ export const getNextRelationshipIndex = (relationships: Element): number => { ); }; -export const appendRelationship = (relationships: Element, id: number, type: RelationshipType, target: string): void => { +export const appendRelationship = ( + relationships: Element, + id: number | string, + type: RelationshipType, + target: string, + targetMode?: TargetModeType, +): void => { const relationshipElements = getFirstLevelElements(relationships, "Relationships"); // eslint-disable-next-line functional/immutable-data relationshipElements.push({ @@ -23,6 +29,7 @@ export const appendRelationship = (relationships: Element, id: number, type: Rel Id: `rId${id}`, Type: type, Target: target, + TargetMode: targetMode, }, name: "Relationship", type: "element",