Skip to content

Commit 5f03418

Browse files
committed
feat(hyperlink): hyperlink processing for tables.
- Added a new test for adding tables with hyperlinks to ensure correct functionality. - Introduced a HyperlinkProcessor class to centralize hyperlink handling logic. - Updated HasShapes and Shape classes to utilize the new HyperlinkProcessor for hyperlink detection and processing. - Enhanced GenericShape to copy hyperlink relationships when added to slides.
1 parent 2f03bb8 commit 5f03418

File tree

5 files changed

+414
-84
lines changed

5 files changed

+414
-84
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Automizer } from '../src';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import * as JSZip from 'jszip';
5+
import { DOMParser } from '@xmldom/xmldom';
6+
7+
describe('addTable with hyperlinks', () => {
8+
const outputDir = path.join(__dirname, 'pptx-output');
9+
const outputFile = 'addTable-hyperlinks.test.pptx';
10+
const outputPath = path.join(outputDir, outputFile);
11+
12+
beforeAll(() => {
13+
if (fs.existsSync(outputPath)) {
14+
fs.unlinkSync(outputPath);
15+
}
16+
});
17+
18+
it('preserves different hyperlink URLs in table cells', async () => {
19+
const automizer = new Automizer({
20+
templateDir: path.join(__dirname, 'pptx-templates'),
21+
outputDir: outputDir,
22+
});
23+
24+
automizer.loadRoot('EmptyTemplate.pptx');
25+
automizer.load('EmptySlide.pptx', 'template');
26+
27+
automizer.addSlide('template', 1, (slide) => {
28+
slide.generate((pptxGenJSSlide) => {
29+
const tableData = [
30+
[
31+
{
32+
text: "Google",
33+
options: { hyperlink: { url: "https://google.com" } },
34+
},
35+
{
36+
text: "DuckDuckGo",
37+
options: { hyperlink: { url: "https://duckduckgo.com" } },
38+
},
39+
{ text: "No Link" },
40+
],
41+
];
42+
43+
pptxGenJSSlide.addTable(tableData, {
44+
w: 9,
45+
rowH: 2,
46+
align: "left",
47+
fontFace: "Arial",
48+
});
49+
});
50+
});
51+
52+
const summary = await automizer.write(outputFile);
53+
expect(summary.slides).toBe(1);
54+
55+
const fileData = fs.readFileSync(outputPath);
56+
const zip = await JSZip.loadAsync(fileData);
57+
const parser = new DOMParser();
58+
59+
const slide1RelsPath = 'ppt/slides/_rels/slide1.xml.rels';
60+
const slide1RelsFile = zip.file(slide1RelsPath);
61+
expect(slide1RelsFile).not.toBeNull();
62+
63+
const slide1RelsXml = await slide1RelsFile!.async('text');
64+
const slide1RelsDoc = parser.parseFromString(slide1RelsXml, 'application/xml');
65+
const relationships = slide1RelsDoc.getElementsByTagName('Relationship');
66+
67+
const hyperlinkRels = Array.from(relationships)
68+
.filter(rel => rel.getAttribute('Type') === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink')
69+
.map(rel => ({
70+
id: rel.getAttribute('Id'),
71+
target: rel.getAttribute('Target'),
72+
targetMode: rel.getAttribute('TargetMode')
73+
}));
74+
75+
expect(hyperlinkRels.length).toBe(2);
76+
77+
const targets = hyperlinkRels.map(rel => rel.target);
78+
expect(targets).toContain('https://google.com');
79+
expect(targets).toContain('https://duckduckgo.com');
80+
81+
hyperlinkRels.forEach(rel => {
82+
expect(rel.targetMode).toBe('External');
83+
});
84+
85+
const slide1Path = 'ppt/slides/slide1.xml';
86+
const slide1File = zip.file(slide1Path);
87+
expect(slide1File).not.toBeNull();
88+
89+
const slide1Xml = await slide1File!.async('text');
90+
const slide1Doc = parser.parseFromString(slide1Xml, 'application/xml');
91+
92+
const hlinkClicks = slide1Doc.getElementsByTagName('a:hlinkClick');
93+
expect(hlinkClicks.length).toBe(2);
94+
95+
const rIds = Array.from(hlinkClicks).map(hlink => hlink.getAttribute('r:id'));
96+
expect(new Set(rIds).size).toBe(rIds.length);
97+
98+
const relationshipIds = hyperlinkRels.map(rel => rel.id);
99+
rIds.forEach(rId => {
100+
expect(relationshipIds).toContain(rId);
101+
});
102+
});
103+
});

src/classes/has-shapes.ts

Lines changed: 25 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { GenericShape } from '../shapes/generic';
3737
import { XmlSlideHelper } from '../helper/xml-slide-helper';
3838
import { OLEObject } from '../shapes/ole';
3939
import { Hyperlink } from '../shapes/hyperlink';
40+
import { HyperlinkProcessor } from '../helper/hyperlink-processor';
4041

4142
export default class HasShapes {
4243
/**
@@ -938,53 +939,42 @@ export default class HasShapes {
938939
} as AnalyzedElementType;
939940
}
940941

941-
// Check for hyperlinks in text runs
942-
const hasHyperlink = this.findHyperlinkInElement(sourceElement);
942+
// Check for hyperlinks using the centralized processor
943+
const hasHyperlink = HyperlinkProcessor.hasHyperlinks(sourceElement);
944+
943945
if (hasHyperlink) {
944946
try {
945-
const target = await XmlHelper.getTargetByRelId(
946-
sourceArchive,
947-
relsPath,
948-
sourceElement,
949-
'hyperlink',
950-
);
947+
// Check if this element has multiple hyperlinks
948+
if (HyperlinkProcessor.hasMultipleHyperlinks(sourceElement)) {
949+
// For elements with multiple hyperlinks (like tables), treat as generic shape
950+
// The GenericShape class will handle copying the hyperlink relationships properly
951+
return {
952+
type: ElementType.Shape,
953+
} as AnalyzedElementType;
954+
} else {
955+
// Single hyperlink - use existing logic
956+
const target = await XmlHelper.getTargetByRelId(
957+
sourceArchive,
958+
relsPath,
959+
sourceElement,
960+
'hyperlink',
961+
);
951962

952-
return {
953-
type: ElementType.Hyperlink,
954-
target: target,
955-
element: sourceElement,
956-
} as AnalyzedElementType;
963+
return {
964+
type: ElementType.Hyperlink,
965+
target: target,
966+
element: sourceElement,
967+
} as AnalyzedElementType;
968+
}
957969
} catch (error) {
958970
console.warn('Error finding hyperlink target:', error);
959971
}
960972
}
961-
962973
return {
963974
type: ElementType.Shape,
964975
} as AnalyzedElementType;
965976
}
966977

967-
// Helper method to find hyperlinks in an element
968-
private findHyperlinkInElement(element: XmlElement): boolean {
969-
// Check for direct hyperlinks
970-
const directHyperlinks = element.getElementsByTagName('a:hlinkClick');
971-
if (directHyperlinks.length > 0) {
972-
return true;
973-
}
974-
975-
// Check for hyperlinks in text runs
976-
const textRuns = element.getElementsByTagName('a:r');
977-
for (let i = 0; i < textRuns.length; i++) {
978-
const run = textRuns[i];
979-
const rPr = run.getElementsByTagName('a:rPr')[0];
980-
if (rPr && rPr.getElementsByTagName('a:hlinkClick').length > 0) {
981-
return true;
982-
}
983-
}
984-
985-
return false;
986-
}
987-
988978
/**
989979
* Applys modifications
990980
* @internal

src/classes/shape.ts

Lines changed: 9 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { XmlHelper } from '../helper/xml-helper';
22
import { GeneralHelper } from '../helper/general-helper';
3+
import { HyperlinkProcessor } from '../helper/hyperlink-processor';
34
import {
45
ChartModificationCallback,
56
ImportedElement,
@@ -104,7 +105,7 @@ export class Shape {
104105

105106
// Process hyperlinks in the element if this is a hyperlink element
106107
if (this.relRootTag === 'a:hlinkClick') {
107-
await this.processHyperlinks(targetSlideXml);
108+
await this.processHyperlinks();
108109
}
109110

110111
XmlHelper.writeXmlToArchive(
@@ -114,54 +115,13 @@ export class Shape {
114115
);
115116
}
116117

117-
// Process hyperlinks in the element
118-
async processHyperlinks(targetSlideXml: XmlDocument): Promise<void> {
119-
if (!this.targetElement) return;
120-
121-
// Scenario 1: Update r:id in <p:nvSpPr><p:cNvPr><a:hlinkClick r:id="..." />
122-
// This is for hyperlinks applied to the shape itself.
123-
const nvSpPr = this.targetElement.getElementsByTagName('p:nvSpPr')[0];
124-
if (nvSpPr) {
125-
const cNvPr = nvSpPr.getElementsByTagName('p:cNvPr')[0];
126-
if (cNvPr) {
127-
const shapeHlinks = cNvPr.getElementsByTagName('a:hlinkClick');
128-
for (let k = 0; k < shapeHlinks.length; k++) {
129-
const shapeHlink = shapeHlinks[k];
130-
const currentRid = shapeHlink.getAttribute('r:id');
131-
132-
if (this.createdRid && currentRid) {
133-
shapeHlink.setAttribute('r:id', this.createdRid);
134-
shapeHlink.setAttribute(
135-
'xmlns:r',
136-
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
137-
);
138-
}
139-
}
140-
}
141-
}
118+
/**
119+
* Process hyperlinks in the element
120+
*/
121+
async processHyperlinks(): Promise<void> {
122+
if (!this.targetElement || !this.createdRid) return;
142123

143-
// Scenario 2: Update r:id in <p:txBody>...<a:rPr><a:hlinkClick r:id="..." />
144-
// This is for hyperlinks applied to text runs within the shape.
145-
const runs = this.targetElement.getElementsByTagName('a:r');
146-
for (let i = 0; i < runs.length; i++) {
147-
const run = runs[i];
148-
const rPr = run.getElementsByTagName('a:rPr')[0];
149-
if (rPr) {
150-
const hlinkClicks = rPr.getElementsByTagName('a:hlinkClick');
151-
for (let j = 0; j < hlinkClicks.length; j++) {
152-
const hlinkClick = hlinkClicks[j];
153-
const currentRid = hlinkClick.getAttribute('r:id');
154-
155-
if (this.createdRid && currentRid) {
156-
hlinkClick.setAttribute('r:id', this.createdRid);
157-
hlinkClick.setAttribute(
158-
'xmlns:r',
159-
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
160-
);
161-
}
162-
}
163-
}
164-
}
124+
await HyperlinkProcessor.processSingleHyperlink(this.targetElement, this.createdRid);
165125
}
166126

167127
async replaceIntoSlideTree(): Promise<void> {
@@ -206,7 +166,7 @@ export class Shape {
206166

207167
// Process hyperlinks in the element if this is a hyperlink element
208168
if (this.relRootTag === 'a:hlinkClick') {
209-
await this.processHyperlinks(targetSlideXml);
169+
await this.processHyperlinks();
210170
}
211171

212172
XmlHelper.writeXmlToArchive(archive, slideFile, targetSlideXml);

0 commit comments

Comments
 (0)