Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,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 = {
Expand Down Expand Up @@ -635,6 +637,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';
Expand Down
227 changes: 227 additions & 0 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5996,6 +5996,58 @@ describe('DomPainter', () => {
});

describe('renderImageRun (inline image runs)', () => {
const renderInlineImageRun = (
run: Extract<FlowBlock, { kind: 'paragraph' }>['runs'][number],
lineWidth = 100,
lineHeight = 100,
) => {
const imageBlock: FlowBlock = {
kind: 'paragraph',
id: 'img-block',
runs: [run],
};

const imageMeasure: Measure = {
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 0,
width: lineWidth,
ascent: lineHeight,
descent: 0,
lineHeight,
},
],
totalHeight: lineHeight,
};

const imageLayout: Layout = {
pageSize: { w: 400, h: 500 },
pages: [
{
number: 1,
fragments: [
{
kind: 'para',
blockId: 'img-block',
fromLine: 0,
toLine: 1,
x: 0,
y: 0,
width: lineWidth,
},
],
},
],
};

const painter = createDomPainter({ blocks: [imageBlock], measures: [imageMeasure] });
painter.paint(imageLayout, mount);
};

it('renders img element with valid data URL', () => {
const imageBlock: FlowBlock = {
kind: 'paragraph',
Expand Down Expand Up @@ -6464,6 +6516,64 @@ describe('DomPainter', () => {
expect(img).toBeNull();
});

it('wraps linked inline image in anchor without clipPath', () => {
renderInlineImageRun({
kind: 'image',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 100,
height: 100,
hyperlink: { url: 'https://example.com/inline', tooltip: ' Inline tooltip ' },
});

const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
expect(anchor).toBeTruthy();
expect(anchor?.href).toBe('https://example.com/inline');
expect(anchor?.title).toBe('Inline tooltip');
expect(anchor?.firstElementChild?.tagName).toBe('IMG');
});

it('wraps linked inline image clip wrapper in anchor when clipPath uses positive dimensions', () => {
renderInlineImageRun(
{
kind: 'image',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 80,
height: 60,
clipPath: 'inset(10% 20% 30% 40%)',
hyperlink: { url: 'https://example.com/clip-wrapper' },
},
80,
60,
);

const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
expect(anchor).toBeTruthy();
expect(anchor?.querySelector('.superdoc-inline-image-clip-wrapper')).toBeTruthy();
expect(anchor?.querySelector('.superdoc-inline-image-clip-wrapper img')).toBeTruthy();
});

it('wraps linked inline image clip wrapper in anchor when clipPath falls back to wrapper return path', () => {
renderInlineImageRun(
{
kind: 'image',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 0,
height: 60,
clipPath: 'inset(10% 20% 30% 40%)',
hyperlink: { url: 'https://example.com/fallback-wrapper' },
},
1,
60,
);

const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
const wrapper = anchor?.querySelector('.superdoc-inline-image-clip-wrapper') as HTMLElement | null;
expect(anchor).toBeTruthy();
expect(wrapper).toBeTruthy();
expect(wrapper?.style.width).toBe('0px');
expect(wrapper?.querySelector('img')).toBeTruthy();
});

it('renders cropped inline image with clipPath in wrapper (overflow hidden, img with clip-path and transform)', () => {
const clipPath = 'inset(10% 20% 30% 40%)';
const imageBlock: FlowBlock = {
Expand Down Expand Up @@ -7507,6 +7617,123 @@ 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 };
return createDomPainter({ blocks: [block], measures: [measure] });
};

it('wraps linked image in <a class="superdoc-link"> 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('encodes tooltip before setting title attribute', () => {
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: ` ${'x'.repeat(600)} ` },
};
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('x'.repeat(500));
});

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', () => {
Expand Down
70 changes: 66 additions & 4 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3668,7 +3668,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) {
Expand All @@ -3677,6 +3680,65 @@ 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
* `<a class="superdoc-link">` 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');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve image selection for linked images in edit mode

Wrapping image elements with a.superdoc-link causes EditorInputManager.#handlePointerDown to take the link fast-path (closest('a.superdoc-link')) and return before the inline/block image selection paths run (#handleInlineImageClick / #handleFragmentClick). As a result, in editable documents, clicking a hyperlinked image no longer produces image node selection or resize-overlay activation, so linked images become effectively non-selectable by pointer.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@caio-pizzol caio-pizzol Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@iguit0 this is a good catch — clicking a linked image in edit mode opens the link instead of selecting the image. the link handler runs first and skips the image selection path entirely.


if (sanitized.protocol === 'http' || sanitized.protocol === 'https') {
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
}
if (hyperlink.tooltip) {
const tooltipResult = encodeTooltip(hyperlink.tooltip);
if (tooltipResult?.text) {
anchor.title = tooltipResult.text;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

text links run the tooltip through encodeTooltip() which trims whitespace and caps it at 500 chars. this skips that — a crafted docx with a huge tooltip would render it in full. worth matching the text link behavior?

Suggested change
}
if (hyperlink.tooltip) {
const tooltipResult = encodeTooltip(hyperlink.tooltip);
if (tooltipResult?.text) {
anchor.title = tooltipResult.text;
}
}


// 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,
Expand Down Expand Up @@ -5246,7 +5308,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)
Expand Down Expand Up @@ -5301,10 +5363,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');
}

/**
Expand Down
Loading