Skip to content

Commit 5a42246

Browse files
committed
Feat: Initial Hyperlink Support (Backfitted from MP70)
1 parent 4c8a709 commit 5a42246

File tree

9 files changed

+662
-24
lines changed

9 files changed

+662
-24
lines changed

__tests__/modify-hyperlink.test.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import Automizer from '../src/index';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import * as JSZip from 'jszip';
5+
import { DOMParser } from '@xmldom/xmldom';
26

37
test('Add and modify hyperlinks', async () => {
48
const automizer = new Automizer({
@@ -11,12 +15,71 @@ test('Add and modify hyperlinks', async () => {
1115
.load(`EmptySlide.pptx`, 'empty')
1216
.load(`SlideWithLink.pptx`, 'link');
1317

18+
// Track if the hyperlink was added
19+
const outputFile = `modify-hyperlink.test.pptx`;
20+
const outputPath = path.join(`${__dirname}/pptx-output`, outputFile);
21+
1422
const result = await pres
15-
.addSlide('link', 1, (slide) => {})
23+
// Add the slide with the existing hyperlink
1624
.addSlide('empty', 1, (slide) => {
17-
slide.addElement('link', 1, 'Link');
25+
// Add the element with the hyperlink from the source slide
26+
slide.addElement('link', 1, 'ExternalLink');
1827
})
19-
.write(`modify-hyperlink.test.pptx`);
28+
.write(outputFile);
2029

21-
expect(result.slides).toBe(3);
30+
// Verify the number of slides
31+
expect(result.slides).toBe(2);
32+
33+
// Now verify that the hyperlink was actually copied by checking the PPTX file
34+
// Read the generated PPTX file
35+
const fileData = fs.readFileSync(outputPath);
36+
const zip = await JSZip.loadAsync(fileData);
37+
38+
// The second slide should be slide2.xml (index starts at 1 in PPTX)
39+
// Check its relationships file for hyperlink entries
40+
const slideRelsPath = 'ppt/slides/_rels/slide2.xml.rels';
41+
const slideRelsFile = zip.file(slideRelsPath);
42+
43+
// Make sure the file exists
44+
expect(slideRelsFile).not.toBeNull();
45+
46+
// Get the file content
47+
const slideRelsXml = await slideRelsFile!.async('text');
48+
49+
// Parse the XML
50+
const parser = new DOMParser();
51+
const xmlDoc = parser.parseFromString(slideRelsXml, 'application/xml');
52+
53+
// Look for hyperlink relationships
54+
const relationships = xmlDoc.getElementsByTagName('Relationship');
55+
let hasHyperlink = false;
56+
let hyperlinkId = '';
57+
58+
for (let i = 0; i < relationships.length; i++) {
59+
const relationship = relationships[i];
60+
const type = relationship.getAttribute('Type');
61+
if (type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink') {
62+
hasHyperlink = true;
63+
const id = relationship.getAttribute('Id');
64+
hyperlinkId = id || '';
65+
break;
66+
}
67+
}
68+
69+
// Verify that a hyperlink relationship exists
70+
expect(hasHyperlink).toBe(true);
71+
expect(hyperlinkId).not.toBe('');
72+
73+
// Now check if the slide XML contains the hyperlink reference
74+
const slidePath = 'ppt/slides/slide2.xml';
75+
const slideFile = zip.file(slidePath);
76+
77+
// Make sure the file exists
78+
expect(slideFile).not.toBeNull();
79+
80+
// Get the file content
81+
const slideXml = await slideFile!.async('text');
82+
83+
// Verify that the hyperlink ID is referenced in the slide content
84+
expect(slideXml.includes(`r:id="${hyperlinkId}"`)).toBe(true);
2285
});

src/classes/has-shapes.ts

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { ElementType } from '../enums/element-type';
3636
import { GenericShape } from '../shapes/generic';
3737
import { XmlSlideHelper } from '../helper/xml-slide-helper';
3838
import { OLEObject } from '../shapes/ole';
39+
import { Hyperlink } from '../shapes/hyperlink';
3940

4041
export default class HasShapes {
4142
/**
@@ -420,6 +421,21 @@ export default class HasShapes {
420421
info.mode
421422
](this.targetTemplate, this.targetNumber, this.targetType);
422423
break;
424+
case ElementType.Hyperlink:
425+
// For hyperlinks, we need to handle them differently
426+
if (info.target) {
427+
await new Hyperlink(
428+
info,
429+
this.targetType,
430+
this.sourceArchive,
431+
info.target.isExternal ? 'external' : 'internal',
432+
info.target.file
433+
)[info.mode](
434+
this.targetTemplate,
435+
this.targetNumber
436+
);
437+
}
438+
break;
423439
default:
424440
break;
425441
}
@@ -787,7 +803,7 @@ export default class HasShapes {
787803
sourceArchive: this.sourceArchive,
788804
sourceSlideNumber: this.sourceNumber,
789805
},
790-
this.targetType,
806+
this.targetType
791807
).modifyOnAddedSlide(this.targetTemplate, this.targetNumber);
792808
}
793809

@@ -800,14 +816,11 @@ export default class HasShapes {
800816
sourceArchive: this.sourceArchive,
801817
sourceSlideNumber: this.sourceNumber,
802818
},
803-
this.targetType,
819+
this.targetType
804820
).modifyOnAddedSlide(this.targetTemplate, this.targetNumber);
805821
}
806822

807-
const oleObjects = await OLEObject.getAllOnSlide(
808-
this.sourceArchive,
809-
this.relsPath,
810-
);
823+
const oleObjects = await OLEObject.getAllOnSlide(this.sourceArchive, this.relsPath);
811824
for (const oleObject of oleObjects) {
812825
await new OLEObject(
813826
{
@@ -817,9 +830,33 @@ export default class HasShapes {
817830
sourceSlideNumber: this.sourceNumber,
818831
},
819832
this.targetType,
820-
this.sourceArchive,
833+
this.sourceArchive
821834
).modifyOnAddedSlide(this.targetTemplate, this.targetNumber, oleObjects);
822835
}
836+
837+
// Copy hyperlinks
838+
const hyperlinks = await Hyperlink.getAllOnSlide(this.sourceArchive, this.relsPath);
839+
for (const hyperlink of hyperlinks) {
840+
// Create a new hyperlink with the correct target information
841+
const hyperlinkInstance = new Hyperlink(
842+
{
843+
mode: 'append',
844+
target: hyperlink,
845+
sourceArchive: this.sourceArchive,
846+
sourceSlideNumber: this.sourceNumber,
847+
},
848+
this.targetType,
849+
this.sourceArchive,
850+
hyperlink.isExternal ? 'external' : 'internal',
851+
hyperlink.file
852+
);
853+
854+
// Ensure the target property is properly set
855+
hyperlinkInstance.target = hyperlink;
856+
857+
// Process the hyperlink
858+
await hyperlinkInstance.modifyOnAddedSlide(this.targetTemplate, this.targetNumber, hyperlinks);
859+
}
823860
}
824861

825862
/**
@@ -894,11 +931,53 @@ export default class HasShapes {
894931
} as AnalyzedElementType;
895932
}
896933

934+
// Check for hyperlinks in text runs
935+
const hasHyperlink = this.findHyperlinkInElement(sourceElement);
936+
if (hasHyperlink) {
937+
try {
938+
const target = await XmlHelper.getTargetByRelId(
939+
sourceArchive,
940+
relsPath,
941+
sourceElement,
942+
'hyperlink',
943+
);
944+
945+
return {
946+
type: ElementType.Hyperlink,
947+
target: target,
948+
element: sourceElement,
949+
} as AnalyzedElementType;
950+
} catch (error) {
951+
console.warn('Error finding hyperlink target:', error);
952+
}
953+
}
954+
897955
return {
898956
type: ElementType.Shape,
899957
} as AnalyzedElementType;
900958
}
901959

960+
// Helper method to find hyperlinks in an element
961+
private findHyperlinkInElement(element: XmlElement): boolean {
962+
// Check for direct hyperlinks
963+
const directHyperlinks = element.getElementsByTagName('a:hlinkClick');
964+
if (directHyperlinks.length > 0) {
965+
return true;
966+
}
967+
968+
// Check for hyperlinks in text runs
969+
const textRuns = element.getElementsByTagName('a:r');
970+
for (let i = 0; i < textRuns.length; i++) {
971+
const run = textRuns[i];
972+
const rPr = run.getElementsByTagName('a:rPr')[0];
973+
if (rPr && rPr.getElementsByTagName('a:hlinkClick').length > 0) {
974+
return true;
975+
}
976+
}
977+
978+
return false;
979+
}
980+
902981
/**
903982
* Applys modifications
904983
* @internal

src/classes/shape.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,47 @@ export class Shape {
108108
.getElementsByTagName('p:spTree')[0]
109109
.appendChild(this.targetElement);
110110

111+
// Process hyperlinks in the element if this is a hyperlink element
112+
if (this.relRootTag === 'a:hlinkClick') {
113+
await this.processHyperlinks(targetSlideXml);
114+
}
115+
111116
XmlHelper.writeXmlToArchive(
112117
this.targetArchive,
113118
this.targetSlideFile,
114119
targetSlideXml,
115120
);
116121
}
117122

123+
// Process hyperlinks in the element
124+
async processHyperlinks(targetSlideXml: XmlDocument): Promise<void> {
125+
// Find all text runs in the element
126+
const runs = this.targetElement.getElementsByTagName('a:r');
127+
128+
for (let i = 0; i < runs.length; i++) {
129+
const run = runs[i];
130+
const rPr = run.getElementsByTagName('a:rPr')[0];
131+
132+
if (rPr) {
133+
// Find hyperlink elements
134+
const hlinkClicks = rPr.getElementsByTagName('a:hlinkClick');
135+
136+
for (let j = 0; j < hlinkClicks.length; j++) {
137+
const hlinkClick = hlinkClicks[j];
138+
const sourceRid = hlinkClick.getAttribute('r:id');
139+
140+
if (sourceRid) {
141+
// Update the r:id attribute to use the created relationship ID
142+
hlinkClick.setAttribute('r:id', this.createdRid);
143+
144+
// Ensure the xmlns:r attribute is set
145+
hlinkClick.setAttribute('xmlns:r', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships');
146+
}
147+
}
148+
}
149+
}
150+
}
151+
118152
async replaceIntoSlideTree(): Promise<void> {
119153
await this.modifySlideTree(true);
120154
}
@@ -155,6 +189,11 @@ export class Shape {
155189
sourceElementOnTargetSlide,
156190
);
157191

192+
// Process hyperlinks in the element if this is a hyperlink element
193+
if (this.relRootTag === 'a:hlinkClick') {
194+
await this.processHyperlinks(targetSlideXml);
195+
}
196+
158197
XmlHelper.writeXmlToArchive(archive, slideFile, targetSlideXml);
159198
}
160199

src/constants/constants.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ export const TargetByRelIdMap = {
2525
relAttribute: 'r:embed',
2626
prefix: '../media/image',
2727
} as TargetByRelIdMapParam,
28+
hyperlink: {
29+
relRootTag: 'a:hlinkClick',
30+
relAttribute: 'r:id',
31+
prefix: '',
32+
findAll: true,
33+
} as TargetByRelIdMapParam,
34+
oleObject: {
35+
relRootTag: 'p:oleObj',
36+
relAttribute: 'r:id',
37+
prefix: '../embeddings/oleObject',
38+
} as TargetByRelIdMapParam,
2839
};
2940

3041
export const imagesTrack: () => TrackedRelation[] = () => [
@@ -42,6 +53,15 @@ export const imagesTrack: () => TrackedRelation[] = () => [
4253
},
4354
];
4455

56+
export const hyperlinksTrack: () => TrackedRelation[] = () => [
57+
{
58+
type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink',
59+
tag: 'a:hlinkClick',
60+
role: 'hyperlink',
61+
attribute: 'r:id',
62+
},
63+
];
64+
4565
export const contentTrack = (): TrackedRelationTag[] => {
4666
return [
4767
{
@@ -76,6 +96,7 @@ export const contentTrack = (): TrackedRelationTag[] => {
7696
tag: null,
7797
},
7898
...imagesTrack(),
99+
...hyperlinksTrack(),
79100
],
80101
},
81102
{
@@ -101,6 +122,7 @@ export const contentTrack = (): TrackedRelationTag[] => {
101122
role: 'slideLayout',
102123
},
103124
...imagesTrack(),
125+
...hyperlinksTrack(),
104126
],
105127
},
106128
{
@@ -114,6 +136,7 @@ export const contentTrack = (): TrackedRelationTag[] => {
114136
tag: null,
115137
},
116138
...imagesTrack(),
139+
...hyperlinksTrack(),
117140
],
118141
},
119142
];

src/enums/element-type.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ export enum ElementType {
33
Image = 'Image',
44
Shape = 'Generic',
55
OLEObject = 'OLEObject',
6+
Hyperlink = 'Hyperlink',
67
}
78

89
export enum ElementSubtype {
910
chart = 'chart',
1011
chartEx = 'chartEx',
1112
oleObject = 'oleObject',
13+
hyperlink = 'hyperlink',
1214
}

0 commit comments

Comments
 (0)