Skip to content

Commit eb51d4f

Browse files
committed
fix: support hyperlinks on DrawingML images (a:hlinkClick)
1 parent 8dfbf95 commit eb51d4f

6 files changed

Lines changed: 287 additions & 8 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,8 @@ export type ImageRun = {
361361
// OOXML image effects
362362
grayscale?: boolean; // Apply grayscale filter to image
363363
lum?: ImageLuminanceAdjustment; // DrawingML luminance adjustment from a:lum
364+
/** Image hyperlink from OOXML a:hlinkClick. When set, clicking the image opens the URL. */
365+
hyperlink?: { url: string; tooltip?: string };
364366
};
365367

366368
export type BreakRun = {
@@ -635,6 +637,8 @@ export type ImageBlock = {
635637
rotation?: number; // Rotation angle in degrees
636638
flipH?: boolean; // Horizontal flip
637639
flipV?: boolean; // Vertical flip
640+
/** Image hyperlink from OOXML a:hlinkClick. When set, clicking the image opens the URL. */
641+
hyperlink?: { url: string; tooltip?: string };
638642
};
639643

640644
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
@@ -7507,6 +7507,135 @@ describe('ImageFragment (block-level images)', () => {
75077507
expect(metadataAttr).toBeTruthy();
75087508
});
75097509
});
7510+
7511+
describe('hyperlink (DrawingML a:hlinkClick)', () => {
7512+
const makePainter = (hyperlink?: { url: string; tooltip?: string }) => {
7513+
const block: FlowBlock = {
7514+
kind: 'image',
7515+
id: 'linked-img',
7516+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
7517+
width: 100,
7518+
height: 50,
7519+
...(hyperlink ? { hyperlink } : {}),
7520+
};
7521+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
7522+
const fragment = {
7523+
kind: 'image' as const,
7524+
blockId: 'linked-img',
7525+
x: 20,
7526+
y: 20,
7527+
width: 100,
7528+
height: 50,
7529+
};
7530+
const layout: Layout = {
7531+
pageSize: { w: 400, h: 300 },
7532+
pages: [{ number: 1, fragments: [fragment] }],
7533+
};
7534+
return createDomPainter({ blocks: [block], measures: [measure] });
7535+
};
7536+
7537+
it('wraps linked image in <a class="superdoc-link"> with correct href', () => {
7538+
const painter = makePainter({ url: 'https://example.com' });
7539+
const layout: Layout = {
7540+
pageSize: { w: 400, h: 300 },
7541+
pages: [
7542+
{
7543+
number: 1,
7544+
fragments: [
7545+
{
7546+
kind: 'image' as const,
7547+
blockId: 'linked-img',
7548+
x: 20,
7549+
y: 20,
7550+
width: 100,
7551+
height: 50,
7552+
},
7553+
],
7554+
},
7555+
],
7556+
};
7557+
painter.paint(layout, mount);
7558+
7559+
const fragmentEl = mount.querySelector('.superdoc-image-fragment');
7560+
expect(fragmentEl).toBeTruthy();
7561+
7562+
const anchor = fragmentEl?.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
7563+
expect(anchor).toBeTruthy();
7564+
expect(anchor?.href).toBe('https://example.com/');
7565+
expect(anchor?.target).toBe('_blank');
7566+
expect(anchor?.rel).toContain('noopener');
7567+
expect(anchor?.getAttribute('role')).toBe('link');
7568+
});
7569+
7570+
it('sets tooltip as title attribute when present', () => {
7571+
const block: FlowBlock = {
7572+
kind: 'image',
7573+
id: 'tip-img',
7574+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
7575+
width: 100,
7576+
height: 50,
7577+
hyperlink: { url: 'https://example.com', tooltip: 'Go here' },
7578+
};
7579+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
7580+
const fragment = { kind: 'image' as const, blockId: 'tip-img', x: 0, y: 0, width: 100, height: 50 };
7581+
const layout: Layout = {
7582+
pageSize: { w: 400, h: 300 },
7583+
pages: [{ number: 1, fragments: [fragment] }],
7584+
};
7585+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
7586+
painter.paint(layout, mount);
7587+
7588+
const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
7589+
expect(anchor?.title).toBe('Go here');
7590+
});
7591+
7592+
it('does NOT wrap unlinked image in anchor', () => {
7593+
const block: FlowBlock = {
7594+
kind: 'image',
7595+
id: 'plain-img',
7596+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
7597+
width: 100,
7598+
height: 50,
7599+
};
7600+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
7601+
const fragment = { kind: 'image' as const, blockId: 'plain-img', x: 0, y: 0, width: 100, height: 50 };
7602+
const layout: Layout = {
7603+
pageSize: { w: 400, h: 300 },
7604+
pages: [{ number: 1, fragments: [fragment] }],
7605+
};
7606+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
7607+
painter.paint(layout, mount);
7608+
7609+
const anchor = mount.querySelector('a.superdoc-link');
7610+
expect(anchor).toBeNull();
7611+
7612+
// Image element should still be present
7613+
const img = mount.querySelector('.superdoc-image-fragment img');
7614+
expect(img).toBeTruthy();
7615+
});
7616+
7617+
it('does NOT wrap image when hyperlink URL fails sanitization', () => {
7618+
const block: FlowBlock = {
7619+
kind: 'image',
7620+
id: 'unsafe-img',
7621+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
7622+
width: 100,
7623+
height: 50,
7624+
hyperlink: { url: 'javascript:alert(1)' },
7625+
};
7626+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
7627+
const fragment = { kind: 'image' as const, blockId: 'unsafe-img', x: 0, y: 0, width: 100, height: 50 };
7628+
const layout: Layout = {
7629+
pageSize: { w: 400, h: 300 },
7630+
pages: [{ number: 1, fragments: [fragment] }],
7631+
};
7632+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
7633+
painter.paint(layout, mount);
7634+
7635+
const anchor = mount.querySelector('a.superdoc-link');
7636+
expect(anchor).toBeNull();
7637+
});
7638+
});
75107639
});
75117640

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

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

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3668,7 +3668,10 @@ export class DomPainter {
36683668
if (filters.length > 0) {
36693669
img.style.filter = filters.join(' ');
36703670
}
3671-
fragmentEl.appendChild(img);
3671+
3672+
// Wrap in anchor when block has a DrawingML hyperlink (a:hlinkClick)
3673+
const imageChild = this.buildImageHyperlinkAnchor(img, block.hyperlink, 'block');
3674+
fragmentEl.appendChild(imageChild);
36723675

36733676
return fragmentEl;
36743677
} catch (error) {
@@ -3677,11 +3680,63 @@ export class DomPainter {
36773680
}
36783681
}
36793682

3680-
private renderDrawingFragment(
3681-
fragment: DrawingFragment,
3682-
context: FragmentRenderContext,
3683-
resolvedItem?: ResolvedDrawingItem,
3683+
/**
3684+
* Optionally wrap an image element in an anchor for DrawingML hyperlinks (a:hlinkClick).
3685+
*
3686+
* When `hyperlink` is present and its URL passes sanitization, returns an
3687+
* `<a class="superdoc-link">` wrapping `imageEl`. The existing EditorInputManager
3688+
* click-delegation on `a.superdoc-link` handles both viewing-mode navigation and
3689+
* editing-mode event dispatch automatically, with no extra wiring needed here.
3690+
*
3691+
* When `hyperlink` is absent or the URL fails sanitization the original element
3692+
* is returned unchanged.
3693+
*
3694+
* @param imageEl - The image element (img or span wrapper) to potentially wrap.
3695+
* @param hyperlink - Hyperlink metadata from the ImageBlock/ImageRun, or undefined.
3696+
* @param display - CSS display value for the anchor: 'block' for fragment images,
3697+
* 'inline-block' for inline runs.
3698+
*/
3699+
private buildImageHyperlinkAnchor(
3700+
imageEl: HTMLElement,
3701+
hyperlink: { url: string; tooltip?: string } | undefined,
3702+
display: 'block' | 'inline-block',
36843703
): HTMLElement {
3704+
if (!hyperlink?.url || !this.doc) return imageEl;
3705+
3706+
const sanitized = sanitizeHref(hyperlink.url);
3707+
if (!sanitized?.href) return imageEl;
3708+
3709+
const anchor = this.doc.createElement('a');
3710+
anchor.href = sanitized.href;
3711+
anchor.classList.add('superdoc-link');
3712+
3713+
if (sanitized.protocol === 'http' || sanitized.protocol === 'https') {
3714+
anchor.target = '_blank';
3715+
anchor.rel = 'noopener noreferrer';
3716+
}
3717+
if (hyperlink.tooltip) {
3718+
anchor.title = hyperlink.tooltip;
3719+
}
3720+
3721+
// Accessibility: explicit role and keyboard focus (mirrors applyLinkAttributes for text links)
3722+
anchor.setAttribute('role', 'link');
3723+
anchor.setAttribute('tabindex', '0');
3724+
3725+
if (display === 'block') {
3726+
anchor.style.cssText = 'display: block; width: 100%; height: 100%; cursor: pointer;';
3727+
} else {
3728+
// inline-block preserves the image's layout box inside a paragraph line
3729+
anchor.style.display = 'inline-block';
3730+
anchor.style.lineHeight = '0';
3731+
anchor.style.cursor = 'pointer';
3732+
anchor.style.verticalAlign = imageEl.style.verticalAlign || 'bottom';
3733+
}
3734+
3735+
anchor.appendChild(imageEl);
3736+
return anchor;
3737+
}
3738+
3739+
private renderDrawingFragment(fragment: DrawingFragment, context: FragmentRenderContext, resolvedItem?: ResolvedDrawingItem,): HTMLElement {
36853740
try {
36863741
// Use pre-extracted block from resolved item; fall back to blockLookup when resolved item
36873742
// is a legacy ResolvedFragmentItem without the block field.
@@ -5246,7 +5301,7 @@ export class DomPainter {
52465301
this.applySdtDataset(wrapper, run.sdt);
52475302
if (run.dataAttrs) applyRunDataAttributes(wrapper, run.dataAttrs);
52485303
wrapper.appendChild(img);
5249-
return wrapper;
5304+
return this.buildImageHyperlinkAnchor(wrapper, run.hyperlink, 'inline-block');
52505305
}
52515306

52525307
// Apply PM position tracking for cursor placement (only on img when not wrapped)
@@ -5301,10 +5356,10 @@ export class DomPainter {
53015356
this.applySdtDataset(wrapper, run.sdt);
53025357

53035358
wrapper.appendChild(img);
5304-
return wrapper;
5359+
return this.buildImageHyperlinkAnchor(wrapper, run.hyperlink, 'inline-block');
53055360
}
53065361

5307-
return img;
5362+
return this.buildImageHyperlinkAnchor(img, run.hyperlink, 'inline-block');
53085363
}
53095364

53105365
/**

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)