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;
}