Skip to content

Commit 7bea148

Browse files
committed
fix: support hyperlinks on DrawingML images (a:hlinkClick)
1 parent 6e25b85 commit 7bea148

6 files changed

Lines changed: 287 additions & 4 deletions

File tree

packages/layout-engine/contracts/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,8 @@ export type ImageRun = {
332332
// OOXML image effects
333333
grayscale?: boolean; // Apply grayscale filter to image
334334
lum?: ImageLuminanceAdjustment; // DrawingML luminance adjustment from a:lum
335+
/** Image hyperlink from OOXML a:hlinkClick. When set, clicking the image opens the URL. */
336+
hyperlink?: { url: string; tooltip?: string };
335337
};
336338

337339
export type BreakRun = {
@@ -588,6 +590,8 @@ export type ImageBlock = {
588590
rotation?: number; // Rotation angle in degrees
589591
flipH?: boolean; // Horizontal flip
590592
flipV?: boolean; // Vertical flip
593+
/** Image hyperlink from OOXML a:hlinkClick. When set, clicking the image opens the URL. */
594+
hyperlink?: { url: string; tooltip?: string };
591595
};
592596

593597
export type DrawingKind = 'image' | 'vectorShape' | 'shapeGroup' | 'chart';

packages/layout-engine/painters/dom/src/index.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6442,6 +6442,135 @@ describe('ImageFragment (block-level images)', () => {
64426442
expect(metadataAttr).toBeTruthy();
64436443
});
64446444
});
6445+
6446+
describe('hyperlink (DrawingML a:hlinkClick)', () => {
6447+
const makePainter = (hyperlink?: { url: string; tooltip?: string }) => {
6448+
const block: FlowBlock = {
6449+
kind: 'image',
6450+
id: 'linked-img',
6451+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
6452+
width: 100,
6453+
height: 50,
6454+
...(hyperlink ? { hyperlink } : {}),
6455+
};
6456+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
6457+
const fragment = {
6458+
kind: 'image' as const,
6459+
blockId: 'linked-img',
6460+
x: 20,
6461+
y: 20,
6462+
width: 100,
6463+
height: 50,
6464+
};
6465+
const layout: Layout = {
6466+
pageSize: { w: 400, h: 300 },
6467+
pages: [{ number: 1, fragments: [fragment] }],
6468+
};
6469+
return createDomPainter({ blocks: [block], measures: [measure] });
6470+
};
6471+
6472+
it('wraps linked image in <a class="superdoc-link"> with correct href', () => {
6473+
const painter = makePainter({ url: 'https://example.com' });
6474+
const layout: Layout = {
6475+
pageSize: { w: 400, h: 300 },
6476+
pages: [
6477+
{
6478+
number: 1,
6479+
fragments: [
6480+
{
6481+
kind: 'image' as const,
6482+
blockId: 'linked-img',
6483+
x: 20,
6484+
y: 20,
6485+
width: 100,
6486+
height: 50,
6487+
},
6488+
],
6489+
},
6490+
],
6491+
};
6492+
painter.paint(layout, mount);
6493+
6494+
const fragmentEl = mount.querySelector('.superdoc-image-fragment');
6495+
expect(fragmentEl).toBeTruthy();
6496+
6497+
const anchor = fragmentEl?.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
6498+
expect(anchor).toBeTruthy();
6499+
expect(anchor?.href).toBe('https://example.com/');
6500+
expect(anchor?.target).toBe('_blank');
6501+
expect(anchor?.rel).toContain('noopener');
6502+
expect(anchor?.getAttribute('role')).toBe('link');
6503+
});
6504+
6505+
it('sets tooltip as title attribute when present', () => {
6506+
const block: FlowBlock = {
6507+
kind: 'image',
6508+
id: 'tip-img',
6509+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
6510+
width: 100,
6511+
height: 50,
6512+
hyperlink: { url: 'https://example.com', tooltip: 'Go here' },
6513+
};
6514+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
6515+
const fragment = { kind: 'image' as const, blockId: 'tip-img', x: 0, y: 0, width: 100, height: 50 };
6516+
const layout: Layout = {
6517+
pageSize: { w: 400, h: 300 },
6518+
pages: [{ number: 1, fragments: [fragment] }],
6519+
};
6520+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
6521+
painter.paint(layout, mount);
6522+
6523+
const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
6524+
expect(anchor?.title).toBe('Go here');
6525+
});
6526+
6527+
it('does NOT wrap unlinked image in anchor', () => {
6528+
const block: FlowBlock = {
6529+
kind: 'image',
6530+
id: 'plain-img',
6531+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
6532+
width: 100,
6533+
height: 50,
6534+
};
6535+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
6536+
const fragment = { kind: 'image' as const, blockId: 'plain-img', x: 0, y: 0, width: 100, height: 50 };
6537+
const layout: Layout = {
6538+
pageSize: { w: 400, h: 300 },
6539+
pages: [{ number: 1, fragments: [fragment] }],
6540+
};
6541+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
6542+
painter.paint(layout, mount);
6543+
6544+
const anchor = mount.querySelector('a.superdoc-link');
6545+
expect(anchor).toBeNull();
6546+
6547+
// Image element should still be present
6548+
const img = mount.querySelector('.superdoc-image-fragment img');
6549+
expect(img).toBeTruthy();
6550+
});
6551+
6552+
it('does NOT wrap image when hyperlink URL fails sanitization', () => {
6553+
const block: FlowBlock = {
6554+
kind: 'image',
6555+
id: 'unsafe-img',
6556+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
6557+
width: 100,
6558+
height: 50,
6559+
hyperlink: { url: 'javascript:alert(1)' },
6560+
};
6561+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
6562+
const fragment = { kind: 'image' as const, blockId: 'unsafe-img', x: 0, y: 0, width: 100, height: 50 };
6563+
const layout: Layout = {
6564+
pageSize: { w: 400, h: 300 },
6565+
pages: [{ number: 1, fragments: [fragment] }],
6566+
};
6567+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
6568+
painter.paint(layout, mount);
6569+
6570+
const anchor = mount.querySelector('a.superdoc-link');
6571+
expect(anchor).toBeNull();
6572+
});
6573+
});
64456574
});
64466575

64476576
describe('URL sanitization security', () => {

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3440,7 +3440,10 @@ export class DomPainter {
34403440
if (filters.length > 0) {
34413441
img.style.filter = filters.join(' ');
34423442
}
3443-
fragmentEl.appendChild(img);
3443+
3444+
// Wrap in anchor when block has a DrawingML hyperlink (a:hlinkClick)
3445+
const imageChild = this.buildImageHyperlinkAnchor(img, block.hyperlink, 'block');
3446+
fragmentEl.appendChild(imageChild);
34443447

34453448
return fragmentEl;
34463449
} catch (error) {
@@ -3449,6 +3452,62 @@ export class DomPainter {
34493452
}
34503453
}
34513454

3455+
/**
3456+
* Optionally wrap an image element in an anchor for DrawingML hyperlinks (a:hlinkClick).
3457+
*
3458+
* When `hyperlink` is present and its URL passes sanitization, returns an
3459+
* `<a class="superdoc-link">` wrapping `imageEl`. The existing EditorInputManager
3460+
* click-delegation on `a.superdoc-link` handles both viewing-mode navigation and
3461+
* editing-mode event dispatch automatically, with no extra wiring needed here.
3462+
*
3463+
* When `hyperlink` is absent or the URL fails sanitization the original element
3464+
* is returned unchanged.
3465+
*
3466+
* @param imageEl - The image element (img or span wrapper) to potentially wrap.
3467+
* @param hyperlink - Hyperlink metadata from the ImageBlock/ImageRun, or undefined.
3468+
* @param display - CSS display value for the anchor: 'block' for fragment images,
3469+
* 'inline-block' for inline runs.
3470+
*/
3471+
private buildImageHyperlinkAnchor(
3472+
imageEl: HTMLElement,
3473+
hyperlink: { url: string; tooltip?: string } | undefined,
3474+
display: 'block' | 'inline-block',
3475+
): HTMLElement {
3476+
if (!hyperlink?.url || !this.doc) return imageEl;
3477+
3478+
const sanitized = sanitizeHref(hyperlink.url);
3479+
if (!sanitized?.href) return imageEl;
3480+
3481+
const anchor = this.doc.createElement('a');
3482+
anchor.href = sanitized.href;
3483+
anchor.classList.add('superdoc-link');
3484+
3485+
if (sanitized.protocol === 'http' || sanitized.protocol === 'https') {
3486+
anchor.target = '_blank';
3487+
anchor.rel = 'noopener noreferrer';
3488+
}
3489+
if (hyperlink.tooltip) {
3490+
anchor.title = hyperlink.tooltip;
3491+
}
3492+
3493+
// Accessibility: explicit role and keyboard focus (mirrors applyLinkAttributes for text links)
3494+
anchor.setAttribute('role', 'link');
3495+
anchor.setAttribute('tabindex', '0');
3496+
3497+
if (display === 'block') {
3498+
anchor.style.cssText = 'display: block; width: 100%; height: 100%; cursor: pointer;';
3499+
} else {
3500+
// inline-block preserves the image's layout box inside a paragraph line
3501+
anchor.style.display = 'inline-block';
3502+
anchor.style.lineHeight = '0';
3503+
anchor.style.cursor = 'pointer';
3504+
anchor.style.verticalAlign = imageEl.style.verticalAlign || 'bottom';
3505+
}
3506+
3507+
anchor.appendChild(imageEl);
3508+
return anchor;
3509+
}
3510+
34523511
private renderDrawingFragment(fragment: DrawingFragment, context: FragmentRenderContext): HTMLElement {
34533512
try {
34543513
const lookup = this.blockLookup.get(fragment.blockId);
@@ -4941,7 +5000,7 @@ export class DomPainter {
49415000
this.applySdtDataset(wrapper, run.sdt);
49425001
if (run.dataAttrs) applyRunDataAttributes(wrapper, run.dataAttrs);
49435002
wrapper.appendChild(img);
4944-
return wrapper;
5003+
return this.buildImageHyperlinkAnchor(wrapper, run.hyperlink, 'inline-block');
49455004
}
49465005

49475006
// Apply PM position tracking for cursor placement (only on img when not wrapped)
@@ -4996,10 +5055,10 @@ export class DomPainter {
49965055
this.applySdtDataset(wrapper, run.sdt);
49975056

49985057
wrapper.appendChild(img);
4999-
return wrapper;
5058+
return this.buildImageHyperlinkAnchor(wrapper, run.hyperlink, 'inline-block');
50005059
}
50015060

5002-
return img;
5061+
return this.buildImageHyperlinkAnchor(img, run.hyperlink, 'inline-block');
50035062
}
50045063

50055064
/**

packages/layout-engine/pm-adapter/src/converters/image.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,5 +753,75 @@ describe('image converter', () => {
753753
expect(result.flipH).toBeUndefined();
754754
expect(result.flipV).toBeUndefined();
755755
});
756+
757+
describe('hyperlink (DrawingML a:hlinkClick)', () => {
758+
it('passes hyperlink url and tooltip from node attrs to ImageBlock', () => {
759+
const node: PMNode = {
760+
type: 'image',
761+
attrs: {
762+
src: 'image.png',
763+
hyperlink: { url: 'https://example.com', tooltip: 'Visit us' },
764+
},
765+
};
766+
767+
const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;
768+
769+
expect(result.hyperlink).toEqual({ url: 'https://example.com', tooltip: 'Visit us' });
770+
});
771+
772+
it('passes hyperlink url without tooltip', () => {
773+
const node: PMNode = {
774+
type: 'image',
775+
attrs: {
776+
src: 'image.png',
777+
hyperlink: { url: 'https://example.com' },
778+
},
779+
};
780+
781+
const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;
782+
783+
expect(result.hyperlink).toEqual({ url: 'https://example.com' });
784+
expect(result.hyperlink?.tooltip).toBeUndefined();
785+
});
786+
787+
it('omits hyperlink when node attrs has no hyperlink', () => {
788+
const node: PMNode = {
789+
type: 'image',
790+
attrs: { src: 'image.png' },
791+
};
792+
793+
const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;
794+
795+
expect(result.hyperlink).toBeUndefined();
796+
});
797+
798+
it('omits hyperlink when url is empty string', () => {
799+
const node: PMNode = {
800+
type: 'image',
801+
attrs: {
802+
src: 'image.png',
803+
hyperlink: { url: '' },
804+
},
805+
};
806+
807+
const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;
808+
809+
expect(result.hyperlink).toBeUndefined();
810+
});
811+
812+
it('omits hyperlink when hyperlink attr is null', () => {
813+
const node: PMNode = {
814+
type: 'image',
815+
attrs: {
816+
src: 'image.png',
817+
hyperlink: null,
818+
},
819+
};
820+
821+
const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock;
822+
823+
expect(result.hyperlink).toBeUndefined();
824+
});
825+
});
756826
});
757827
});

packages/layout-engine/pm-adapter/src/converters/image.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,18 @@ export function imageNodeToBlock(
313313
...(rotation !== undefined && { rotation }),
314314
...(flipH !== undefined && { flipH }),
315315
...(flipV !== undefined && { flipV }),
316+
// Image hyperlink from OOXML a:hlinkClick
317+
...(() => {
318+
const hlAttr = isPlainObject(attrs.hyperlink) ? attrs.hyperlink : undefined;
319+
if (hlAttr && typeof hlAttr.url === 'string' && hlAttr.url.trim()) {
320+
const hyperlink: { url: string; tooltip?: string } = { url: hlAttr.url as string };
321+
if (typeof hlAttr.tooltip === 'string' && (hlAttr.tooltip as string).trim()) {
322+
hyperlink.tooltip = hlAttr.tooltip as string;
323+
}
324+
return { hyperlink };
325+
}
326+
return {};
327+
})(),
316328
};
317329
}
318330

packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,15 @@ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverter
166166
};
167167
}
168168

169+
// Image hyperlink from OOXML a:hlinkClick
170+
const hlAttr = isPlainObject(attrs.hyperlink) ? attrs.hyperlink : undefined;
171+
if (hlAttr && typeof hlAttr.url === 'string' && hlAttr.url.trim()) {
172+
run.hyperlink = { url: hlAttr.url as string };
173+
if (typeof hlAttr.tooltip === 'string' && (hlAttr.tooltip as string).trim()) {
174+
run.hyperlink.tooltip = hlAttr.tooltip as string;
175+
}
176+
}
177+
169178
return run;
170179
}
171180

0 commit comments

Comments
 (0)