Skip to content

Commit e1f5bc6

Browse files
iguit0caio-pizzol
andauthored
fix: support hyperlinks on DrawingML images (a:hlinkClick) (#2552)
* fix: support hyperlinks on DrawingML images (a:hlinkClick) * fix: normalize image hyperlink tool * fix: handle image hyperlinks correctly in editing mode --------- Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
1 parent cf0da0a commit e1f5bc6

14 files changed

Lines changed: 608 additions & 66 deletions

File tree

apps/create/src/index.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,7 @@ async function main() {
5656
let agentTool = 'claude-code';
5757

5858
if (!nonInteractive) {
59-
const agentIdx = await select('Which agent tool do you use?', [
60-
'Claude Code',
61-
'Cursor',
62-
'Windsurf',
63-
'None / Skip',
64-
]);
59+
const agentIdx = await select('Which agent tool do you use?', ['Claude Code', 'Cursor', 'Windsurf', 'None / Skip']);
6560
agentTool = ['claude-code', 'cursor', 'windsurf', 'none'][agentIdx];
6661
setupMcp = agentTool !== 'none';
6762
}

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: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6050,6 +6050,58 @@ describe('DomPainter', () => {
60506050
});
60516051

60526052
describe('renderImageRun (inline image runs)', () => {
6053+
const renderInlineImageRun = (
6054+
run: Extract<FlowBlock, { kind: 'paragraph' }>['runs'][number],
6055+
lineWidth = 100,
6056+
lineHeight = 100,
6057+
) => {
6058+
const imageBlock: FlowBlock = {
6059+
kind: 'paragraph',
6060+
id: 'img-block',
6061+
runs: [run],
6062+
};
6063+
6064+
const imageMeasure: Measure = {
6065+
kind: 'paragraph',
6066+
lines: [
6067+
{
6068+
fromRun: 0,
6069+
fromChar: 0,
6070+
toRun: 0,
6071+
toChar: 0,
6072+
width: lineWidth,
6073+
ascent: lineHeight,
6074+
descent: 0,
6075+
lineHeight,
6076+
},
6077+
],
6078+
totalHeight: lineHeight,
6079+
};
6080+
6081+
const imageLayout: Layout = {
6082+
pageSize: { w: 400, h: 500 },
6083+
pages: [
6084+
{
6085+
number: 1,
6086+
fragments: [
6087+
{
6088+
kind: 'para',
6089+
blockId: 'img-block',
6090+
fromLine: 0,
6091+
toLine: 1,
6092+
x: 0,
6093+
y: 0,
6094+
width: lineWidth,
6095+
},
6096+
],
6097+
},
6098+
],
6099+
};
6100+
6101+
const painter = createDomPainter({ blocks: [imageBlock], measures: [imageMeasure] });
6102+
painter.paint(imageLayout, mount);
6103+
};
6104+
60536105
it('renders img element with valid data URL', () => {
60546106
const imageBlock: FlowBlock = {
60556107
kind: 'paragraph',
@@ -6518,6 +6570,84 @@ describe('DomPainter', () => {
65186570
expect(img).toBeNull();
65196571
});
65206572

6573+
it('wraps linked inline image in anchor without clipPath', () => {
6574+
renderInlineImageRun({
6575+
kind: 'image',
6576+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
6577+
width: 100,
6578+
height: 100,
6579+
title: 'Image',
6580+
hyperlink: { url: 'https://example.com/inline', tooltip: ' Inline tooltip ' },
6581+
});
6582+
6583+
const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
6584+
const img = anchor?.querySelector('img') as HTMLImageElement | null;
6585+
expect(anchor).toBeTruthy();
6586+
expect(anchor?.href).toBe('https://example.com/inline');
6587+
expect(anchor?.title).toBe('Inline tooltip');
6588+
expect(img?.getAttribute('title')).toBeNull();
6589+
expect(anchor?.firstElementChild?.tagName).toBe('IMG');
6590+
});
6591+
6592+
it('falls back to hyperlink URL for linked inline image title', () => {
6593+
renderInlineImageRun({
6594+
kind: 'image',
6595+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
6596+
width: 100,
6597+
height: 100,
6598+
title: 'Image',
6599+
hyperlink: { url: 'https://superdoc.dev' },
6600+
});
6601+
6602+
const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
6603+
const img = anchor?.querySelector('img') as HTMLImageElement | null;
6604+
expect(anchor).toBeTruthy();
6605+
expect(anchor?.title).toBe('https://superdoc.dev');
6606+
expect(img?.getAttribute('title')).toBeNull();
6607+
});
6608+
6609+
it('wraps linked inline image clip wrapper in anchor when clipPath uses positive dimensions', () => {
6610+
renderInlineImageRun(
6611+
{
6612+
kind: 'image',
6613+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
6614+
width: 80,
6615+
height: 60,
6616+
clipPath: 'inset(10% 20% 30% 40%)',
6617+
hyperlink: { url: 'https://example.com/clip-wrapper' },
6618+
},
6619+
80,
6620+
60,
6621+
);
6622+
6623+
const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
6624+
expect(anchor).toBeTruthy();
6625+
expect(anchor?.querySelector('.superdoc-inline-image-clip-wrapper')).toBeTruthy();
6626+
expect(anchor?.querySelector('.superdoc-inline-image-clip-wrapper img')).toBeTruthy();
6627+
});
6628+
6629+
it('wraps linked inline image clip wrapper in anchor when clipPath falls back to wrapper return path', () => {
6630+
renderInlineImageRun(
6631+
{
6632+
kind: 'image',
6633+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
6634+
width: 0,
6635+
height: 60,
6636+
clipPath: 'inset(10% 20% 30% 40%)',
6637+
hyperlink: { url: 'https://example.com/fallback-wrapper' },
6638+
},
6639+
1,
6640+
60,
6641+
);
6642+
6643+
const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
6644+
const wrapper = anchor?.querySelector('.superdoc-inline-image-clip-wrapper') as HTMLElement | null;
6645+
expect(anchor).toBeTruthy();
6646+
expect(wrapper).toBeTruthy();
6647+
expect(wrapper?.style.width).toBe('0px');
6648+
expect(wrapper?.querySelector('img')).toBeTruthy();
6649+
});
6650+
65216651
it('renders cropped inline image with clipPath in wrapper (overflow hidden, img with clip-path and transform)', () => {
65226652
const clipPath = 'inset(10% 20% 30% 40%)';
65236653
const imageBlock: FlowBlock = {
@@ -7561,6 +7691,123 @@ describe('ImageFragment (block-level images)', () => {
75617691
expect(metadataAttr).toBeTruthy();
75627692
});
75637693
});
7694+
7695+
describe('hyperlink (DrawingML a:hlinkClick)', () => {
7696+
const makePainter = (hyperlink?: { url: string; tooltip?: string }) => {
7697+
const block: FlowBlock = {
7698+
kind: 'image',
7699+
id: 'linked-img',
7700+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
7701+
width: 100,
7702+
height: 50,
7703+
...(hyperlink ? { hyperlink } : {}),
7704+
};
7705+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
7706+
return createDomPainter({ blocks: [block], measures: [measure] });
7707+
};
7708+
7709+
it('wraps linked image in <a class="superdoc-link"> with correct href', () => {
7710+
const painter = makePainter({ url: 'https://example.com' });
7711+
const layout: Layout = {
7712+
pageSize: { w: 400, h: 300 },
7713+
pages: [
7714+
{
7715+
number: 1,
7716+
fragments: [
7717+
{
7718+
kind: 'image' as const,
7719+
blockId: 'linked-img',
7720+
x: 20,
7721+
y: 20,
7722+
width: 100,
7723+
height: 50,
7724+
},
7725+
],
7726+
},
7727+
],
7728+
};
7729+
painter.paint(layout, mount);
7730+
7731+
const fragmentEl = mount.querySelector('.superdoc-image-fragment');
7732+
expect(fragmentEl).toBeTruthy();
7733+
7734+
const anchor = fragmentEl?.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
7735+
expect(anchor).toBeTruthy();
7736+
expect(anchor?.href).toBe('https://example.com/');
7737+
expect(anchor?.target).toBe('_blank');
7738+
expect(anchor?.rel).toContain('noopener');
7739+
expect(anchor?.getAttribute('role')).toBe('link');
7740+
});
7741+
7742+
it('encodes tooltip before setting title attribute', () => {
7743+
const block: FlowBlock = {
7744+
kind: 'image',
7745+
id: 'tip-img',
7746+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
7747+
width: 100,
7748+
height: 50,
7749+
hyperlink: { url: 'https://example.com', tooltip: ` ${'x'.repeat(600)} ` },
7750+
};
7751+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
7752+
const fragment = { kind: 'image' as const, blockId: 'tip-img', x: 0, y: 0, width: 100, height: 50 };
7753+
const layout: Layout = {
7754+
pageSize: { w: 400, h: 300 },
7755+
pages: [{ number: 1, fragments: [fragment] }],
7756+
};
7757+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
7758+
painter.paint(layout, mount);
7759+
7760+
const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
7761+
expect(anchor?.title).toBe('x'.repeat(500));
7762+
});
7763+
7764+
it('does NOT wrap unlinked image in anchor', () => {
7765+
const block: FlowBlock = {
7766+
kind: 'image',
7767+
id: 'plain-img',
7768+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
7769+
width: 100,
7770+
height: 50,
7771+
};
7772+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
7773+
const fragment = { kind: 'image' as const, blockId: 'plain-img', x: 0, y: 0, width: 100, height: 50 };
7774+
const layout: Layout = {
7775+
pageSize: { w: 400, h: 300 },
7776+
pages: [{ number: 1, fragments: [fragment] }],
7777+
};
7778+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
7779+
painter.paint(layout, mount);
7780+
7781+
const anchor = mount.querySelector('a.superdoc-link');
7782+
expect(anchor).toBeNull();
7783+
7784+
// Image element should still be present
7785+
const img = mount.querySelector('.superdoc-image-fragment img');
7786+
expect(img).toBeTruthy();
7787+
});
7788+
7789+
it('does NOT wrap image when hyperlink URL fails sanitization', () => {
7790+
const block: FlowBlock = {
7791+
kind: 'image',
7792+
id: 'unsafe-img',
7793+
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
7794+
width: 100,
7795+
height: 50,
7796+
hyperlink: { url: 'javascript:alert(1)' },
7797+
};
7798+
const measure: Measure = { kind: 'image', width: 100, height: 50 };
7799+
const fragment = { kind: 'image' as const, blockId: 'unsafe-img', x: 0, y: 0, width: 100, height: 50 };
7800+
const layout: Layout = {
7801+
pageSize: { w: 400, h: 300 },
7802+
pages: [{ number: 1, fragments: [fragment] }],
7803+
};
7804+
const painter = createDomPainter({ blocks: [block], measures: [measure] });
7805+
painter.paint(layout, mount);
7806+
7807+
const anchor = mount.querySelector('a.superdoc-link');
7808+
expect(anchor).toBeNull();
7809+
});
7810+
});
75647811
});
75657812

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

0 commit comments

Comments
 (0)