From 7bea14889672685a83cdc941dd0b41e18974d0a4 Mon Sep 17 00:00:00 2001 From: Igor Alves Date: Wed, 25 Mar 2026 07:20:08 -0300 Subject: [PATCH] fix: support hyperlinks on DrawingML images (a:hlinkClick) --- packages/layout-engine/contracts/src/index.ts | 4 + .../painters/dom/src/index.test.ts | 129 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 67 ++++++++- .../pm-adapter/src/converters/image.test.ts | 70 ++++++++++ .../pm-adapter/src/converters/image.ts | 12 ++ .../src/converters/inline-converters/image.ts | 9 ++ 6 files changed, 287 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index d65bed151f..ae98918142 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -332,6 +332,8 @@ export type ImageRun = { // OOXML image effects grayscale?: boolean; // Apply grayscale filter to image lum?: ImageLuminanceAdjustment; // DrawingML luminance adjustment from a:lum + /** Image hyperlink from OOXML a:hlinkClick. When set, clicking the image opens the URL. */ + hyperlink?: { url: string; tooltip?: string }; }; export type BreakRun = { @@ -588,6 +590,8 @@ export type ImageBlock = { rotation?: number; // Rotation angle in degrees flipH?: boolean; // Horizontal flip flipV?: boolean; // Vertical flip + /** Image hyperlink from OOXML a:hlinkClick. When set, clicking the image opens the URL. */ + hyperlink?: { url: string; tooltip?: string }; }; export type DrawingKind = 'image' | 'vectorShape' | 'shapeGroup' | 'chart'; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index b0c650e9ed..3793841fab 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -6442,6 +6442,135 @@ describe('ImageFragment (block-level images)', () => { expect(metadataAttr).toBeTruthy(); }); }); + + describe('hyperlink (DrawingML a:hlinkClick)', () => { + const makePainter = (hyperlink?: { url: string; tooltip?: string }) => { + const block: FlowBlock = { + kind: 'image', + id: 'linked-img', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 100, + height: 50, + ...(hyperlink ? { hyperlink } : {}), + }; + const measure: Measure = { kind: 'image', width: 100, height: 50 }; + const fragment = { + kind: 'image' as const, + blockId: 'linked-img', + x: 20, + y: 20, + width: 100, + height: 50, + }; + const layout: Layout = { + pageSize: { w: 400, h: 300 }, + pages: [{ number: 1, fragments: [fragment] }], + }; + return createDomPainter({ blocks: [block], measures: [measure] }); + }; + + it('wraps linked image in with correct href', () => { + const painter = makePainter({ url: 'https://example.com' }); + const layout: Layout = { + pageSize: { w: 400, h: 300 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'image' as const, + blockId: 'linked-img', + x: 20, + y: 20, + width: 100, + height: 50, + }, + ], + }, + ], + }; + painter.paint(layout, mount); + + const fragmentEl = mount.querySelector('.superdoc-image-fragment'); + expect(fragmentEl).toBeTruthy(); + + const anchor = fragmentEl?.querySelector('a.superdoc-link') as HTMLAnchorElement | null; + expect(anchor).toBeTruthy(); + expect(anchor?.href).toBe('https://example.com/'); + expect(anchor?.target).toBe('_blank'); + expect(anchor?.rel).toContain('noopener'); + expect(anchor?.getAttribute('role')).toBe('link'); + }); + + it('sets tooltip as title attribute when present', () => { + const block: FlowBlock = { + kind: 'image', + id: 'tip-img', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 100, + height: 50, + hyperlink: { url: 'https://example.com', tooltip: 'Go here' }, + }; + const measure: Measure = { kind: 'image', width: 100, height: 50 }; + const fragment = { kind: 'image' as const, blockId: 'tip-img', x: 0, y: 0, width: 100, height: 50 }; + const layout: Layout = { + pageSize: { w: 400, h: 300 }, + pages: [{ number: 1, fragments: [fragment] }], + }; + const painter = createDomPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null; + expect(anchor?.title).toBe('Go here'); + }); + + it('does NOT wrap unlinked image in anchor', () => { + const block: FlowBlock = { + kind: 'image', + id: 'plain-img', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 100, + height: 50, + }; + const measure: Measure = { kind: 'image', width: 100, height: 50 }; + const fragment = { kind: 'image' as const, blockId: 'plain-img', x: 0, y: 0, width: 100, height: 50 }; + const layout: Layout = { + pageSize: { w: 400, h: 300 }, + pages: [{ number: 1, fragments: [fragment] }], + }; + const painter = createDomPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const anchor = mount.querySelector('a.superdoc-link'); + expect(anchor).toBeNull(); + + // Image element should still be present + const img = mount.querySelector('.superdoc-image-fragment img'); + expect(img).toBeTruthy(); + }); + + it('does NOT wrap image when hyperlink URL fails sanitization', () => { + const block: FlowBlock = { + kind: 'image', + id: 'unsafe-img', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 100, + height: 50, + hyperlink: { url: 'javascript:alert(1)' }, + }; + const measure: Measure = { kind: 'image', width: 100, height: 50 }; + const fragment = { kind: 'image' as const, blockId: 'unsafe-img', x: 0, y: 0, width: 100, height: 50 }; + const layout: Layout = { + pageSize: { w: 400, h: 300 }, + pages: [{ number: 1, fragments: [fragment] }], + }; + const painter = createDomPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const anchor = mount.querySelector('a.superdoc-link'); + expect(anchor).toBeNull(); + }); + }); }); describe('URL sanitization security', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 7977015122..dbdb8b4293 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3440,7 +3440,10 @@ export class DomPainter { if (filters.length > 0) { img.style.filter = filters.join(' '); } - fragmentEl.appendChild(img); + + // Wrap in anchor when block has a DrawingML hyperlink (a:hlinkClick) + const imageChild = this.buildImageHyperlinkAnchor(img, block.hyperlink, 'block'); + fragmentEl.appendChild(imageChild); return fragmentEl; } catch (error) { @@ -3449,6 +3452,62 @@ export class DomPainter { } } + /** + * Optionally wrap an image element in an anchor for DrawingML hyperlinks (a:hlinkClick). + * + * When `hyperlink` is present and its URL passes sanitization, returns an + * `` wrapping `imageEl`. The existing EditorInputManager + * click-delegation on `a.superdoc-link` handles both viewing-mode navigation and + * editing-mode event dispatch automatically, with no extra wiring needed here. + * + * When `hyperlink` is absent or the URL fails sanitization the original element + * is returned unchanged. + * + * @param imageEl - The image element (img or span wrapper) to potentially wrap. + * @param hyperlink - Hyperlink metadata from the ImageBlock/ImageRun, or undefined. + * @param display - CSS display value for the anchor: 'block' for fragment images, + * 'inline-block' for inline runs. + */ + private buildImageHyperlinkAnchor( + imageEl: HTMLElement, + hyperlink: { url: string; tooltip?: string } | undefined, + display: 'block' | 'inline-block', + ): HTMLElement { + if (!hyperlink?.url || !this.doc) return imageEl; + + const sanitized = sanitizeHref(hyperlink.url); + if (!sanitized?.href) return imageEl; + + const anchor = this.doc.createElement('a'); + anchor.href = sanitized.href; + anchor.classList.add('superdoc-link'); + + if (sanitized.protocol === 'http' || sanitized.protocol === 'https') { + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + } + if (hyperlink.tooltip) { + anchor.title = hyperlink.tooltip; + } + + // Accessibility: explicit role and keyboard focus (mirrors applyLinkAttributes for text links) + anchor.setAttribute('role', 'link'); + anchor.setAttribute('tabindex', '0'); + + if (display === 'block') { + anchor.style.cssText = 'display: block; width: 100%; height: 100%; cursor: pointer;'; + } else { + // inline-block preserves the image's layout box inside a paragraph line + anchor.style.display = 'inline-block'; + anchor.style.lineHeight = '0'; + anchor.style.cursor = 'pointer'; + anchor.style.verticalAlign = imageEl.style.verticalAlign || 'bottom'; + } + + anchor.appendChild(imageEl); + return anchor; + } + private renderDrawingFragment(fragment: DrawingFragment, context: FragmentRenderContext): HTMLElement { try { const lookup = this.blockLookup.get(fragment.blockId); @@ -4941,7 +5000,7 @@ export class DomPainter { this.applySdtDataset(wrapper, run.sdt); if (run.dataAttrs) applyRunDataAttributes(wrapper, run.dataAttrs); wrapper.appendChild(img); - return wrapper; + return this.buildImageHyperlinkAnchor(wrapper, run.hyperlink, 'inline-block'); } // Apply PM position tracking for cursor placement (only on img when not wrapped) @@ -4996,10 +5055,10 @@ export class DomPainter { this.applySdtDataset(wrapper, run.sdt); wrapper.appendChild(img); - return wrapper; + return this.buildImageHyperlinkAnchor(wrapper, run.hyperlink, 'inline-block'); } - return img; + return this.buildImageHyperlinkAnchor(img, run.hyperlink, 'inline-block'); } /** diff --git a/packages/layout-engine/pm-adapter/src/converters/image.test.ts b/packages/layout-engine/pm-adapter/src/converters/image.test.ts index 5fdda71959..f11582d8cc 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.test.ts @@ -753,5 +753,75 @@ describe('image converter', () => { expect(result.flipH).toBeUndefined(); expect(result.flipV).toBeUndefined(); }); + + describe('hyperlink (DrawingML a:hlinkClick)', () => { + it('passes hyperlink url and tooltip from node attrs to ImageBlock', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.png', + hyperlink: { url: 'https://example.com', tooltip: 'Visit us' }, + }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.hyperlink).toEqual({ url: 'https://example.com', tooltip: 'Visit us' }); + }); + + it('passes hyperlink url without tooltip', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.png', + hyperlink: { url: 'https://example.com' }, + }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.hyperlink).toEqual({ url: 'https://example.com' }); + expect(result.hyperlink?.tooltip).toBeUndefined(); + }); + + it('omits hyperlink when node attrs has no hyperlink', () => { + const node: PMNode = { + type: 'image', + attrs: { src: 'image.png' }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.hyperlink).toBeUndefined(); + }); + + it('omits hyperlink when url is empty string', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.png', + hyperlink: { url: '' }, + }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.hyperlink).toBeUndefined(); + }); + + it('omits hyperlink when hyperlink attr is null', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.png', + hyperlink: null, + }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.hyperlink).toBeUndefined(); + }); + }); }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/image.ts index b83f99af89..50cc893732 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.ts @@ -313,6 +313,18 @@ export function imageNodeToBlock( ...(rotation !== undefined && { rotation }), ...(flipH !== undefined && { flipH }), ...(flipV !== undefined && { flipV }), + // Image hyperlink from OOXML a:hlinkClick + ...(() => { + const hlAttr = isPlainObject(attrs.hyperlink) ? attrs.hyperlink : undefined; + if (hlAttr && typeof hlAttr.url === 'string' && hlAttr.url.trim()) { + const hyperlink: { url: string; tooltip?: string } = { url: hlAttr.url as string }; + if (typeof hlAttr.tooltip === 'string' && (hlAttr.tooltip as string).trim()) { + hyperlink.tooltip = hlAttr.tooltip as string; + } + return { hyperlink }; + } + return {}; + })(), }; } diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts index efdcddc873..eda8f108f1 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts @@ -166,6 +166,15 @@ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverter }; } + // Image hyperlink from OOXML a:hlinkClick + const hlAttr = isPlainObject(attrs.hyperlink) ? attrs.hyperlink : undefined; + if (hlAttr && typeof hlAttr.url === 'string' && hlAttr.url.trim()) { + run.hyperlink = { url: hlAttr.url as string }; + if (typeof hlAttr.tooltip === 'string' && (hlAttr.tooltip as string).trim()) { + run.hyperlink.tooltip = hlAttr.tooltip as string; + } + } + return run; }