Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 };
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.

{ url: string; tooltip?: string } is copy-pasted in ~5 places. worth pulling into a shared type so they don't drift apart? not blocking.

};

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.
Comment thread
caio-pizzol marked this conversation as resolved.
* @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');
Comment thread
caio-pizzol marked this conversation as resolved.

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;
}
}
Comment thread
caio-pizzol marked this conversation as resolved.

// 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