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
7 changes: 1 addition & 6 deletions apps/create/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,7 @@ async function main() {
let agentTool = 'claude-code';

if (!nonInteractive) {
const agentIdx = await select('Which agent tool do you use?', [
'Claude Code',
'Cursor',
'Windsurf',
'None / Skip',
]);
const agentIdx = await select('Which agent tool do you use?', ['Claude Code', 'Cursor', 'Windsurf', 'None / Skip']);
agentTool = ['claude-code', 'cursor', 'windsurf', 'none'][agentIdx];
setupMcp = agentTool !== 'none';
}
Expand Down
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
247 changes: 247 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,84 @@ 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,
title: 'Image',
hyperlink: { url: 'https://example.com/inline', tooltip: ' Inline tooltip ' },
});

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

it('falls back to hyperlink URL for linked inline image title', () => {
renderInlineImageRun({
kind: 'image',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 100,
height: 100,
title: 'Image',
hyperlink: { url: 'https://superdoc.dev' },
});

const anchor = mount.querySelector('a.superdoc-link') as HTMLAnchorElement | null;
const img = anchor?.querySelector('img') as HTMLImageElement | null;
expect(anchor).toBeTruthy();
expect(anchor?.title).toBe('https://superdoc.dev');
expect(img?.getAttribute('title')).toBeNull();
});

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 +7637,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
Loading