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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js';
import type { FlowBlock, SourceAnchor, TextRun } from '@superdoc/contracts';
import type { FlowBlock, ImageBlock, ImageRun, SourceAnchor, TableBlock, TextRun } from '@superdoc/contracts';

describe('sourceAnchorSignature', () => {
it('is stable for equivalent source anchors with different object key order', () => {
Expand Down Expand Up @@ -66,3 +66,142 @@ describe('deriveBlockVersion - bidi', () => {
expect(a).toBe(b);
});
});

describe('deriveBlockVersion - table image content', () => {
const makeTableWithImage = (image: ImageBlock): TableBlock => ({
kind: 'table',
id: 'table-with-image',
rows: [
{
id: 'row-1',
cells: [
{
id: 'cell-1',
blocks: [image],
},
],
},
],
});

const baseImage: ImageBlock = {
kind: 'image',
id: 'image-1',
src: 'data:image/png;base64,AAA',
width: 40,
height: 20,
};

it('changes when a table image filter changes', () => {
const plain = deriveBlockVersion(makeTableWithImage(baseImage));
const filtered = deriveBlockVersion(makeTableWithImage({ ...baseImage, grayscale: true }));

expect(filtered).not.toBe(plain);
});

it('changes when a table image hyperlink changes', () => {
const unlinked = deriveBlockVersion(makeTableWithImage(baseImage));
const linked = deriveBlockVersion(
makeTableWithImage({
...baseImage,
hyperlink: { url: 'https://example.com/image', tooltip: 'Open image' },
}),
);

expect(linked).not.toBe(unlinked);
});

it('does not collide when image hyperlink URL and tooltip contain separators', () => {
const first = deriveBlockVersion(
makeTableWithImage({
...baseImage,
hyperlink: { url: 'https://example.com/a', tooltip: 'b:c' },
}),
);
const second = deriveBlockVersion(
makeTableWithImage({
...baseImage,
hyperlink: { url: 'https://example.com/a:b', tooltip: 'c' },
}),
);

expect(second).not.toBe(first);
});
});

describe('deriveBlockVersion - inline image runs', () => {
const baseImageRun: ImageRun = {
kind: 'image',
src: 'data:image/png;base64,AAA',
width: 40,
height: 20,
};

const makeParagraphWithImageRun = (image: ImageRun): FlowBlock => ({
kind: 'paragraph',
id: 'paragraph-with-image-run',
runs: [image],
});

const makeTableWithImageRun = (image: ImageRun): TableBlock => ({
kind: 'table',
id: 'table-with-inline-image-run',
rows: [
{
id: 'row-1',
cells: [
{
id: 'cell-1',
blocks: [makeParagraphWithImageRun(image)],
},
],
},
],
});

it('changes when an inline image filter changes', () => {
const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun));
const filtered = deriveBlockVersion(
makeParagraphWithImageRun({ ...baseImageRun, grayscale: true, lum: { bright: 25000 } }),
);

expect(filtered).not.toBe(plain);
});

it('changes when an inline image transform changes', () => {
const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun));
const transformed = deriveBlockVersion(makeParagraphWithImageRun({ ...baseImageRun, rotation: 45, flipH: true }));

expect(transformed).not.toBe(plain);
});

it('changes when an inline image hyperlink changes', () => {
const unlinked = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun));
const linked = deriveBlockVersion(
makeParagraphWithImageRun({ ...baseImageRun, hyperlink: { url: 'https://example.com/inline-image' } }),
);

expect(linked).not.toBe(unlinked);
});

it('changes when an inline image raw clip path changes', () => {
const clipA = { ...baseImageRun, clipPath: 'url(#clip-a)' };
const clipB = { ...baseImageRun, clipPath: 'url(#clip-b)' };

expect(deriveBlockVersion(makeParagraphWithImageRun(clipA))).not.toBe(
deriveBlockVersion(makeParagraphWithImageRun(clipB)),
);
expect(deriveBlockVersion(makeTableWithImageRun(clipA))).not.toBe(deriveBlockVersion(makeTableWithImageRun(clipB)));
});

it('changes when a table-cell inline image visual property changes', () => {
const plain = deriveBlockVersion(makeTableWithImageRun(baseImageRun));
const filtered = deriveBlockVersion(makeTableWithImageRun({ ...baseImageRun, grayscale: true }));
const linked = deriveBlockVersion(
makeTableWithImageRun({ ...baseImageRun, hyperlink: { url: 'https://example.com/table-inline-image' } }),
);

expect(filtered).not.toBe(plain);
expect(linked).not.toBe(plain);
});
});
98 changes: 66 additions & 32 deletions packages/layout-engine/layout-resolved/src/versionSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,60 @@ const resolveBlockClipPath = (block: unknown): string => {
return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs);
};

const imageHyperlinkVersion = (hyperlink: ImageBlock['hyperlink'] | undefined): string => {
if (!hyperlink) return '';
return JSON.stringify([hyperlink.url ?? '', hyperlink.tooltip ?? '']);
};

const imageLuminanceVersion = (lum: ImageBlock['lum'] | undefined): string => {
if (!lum) return '';
return [lum.bright ?? '', lum.contrast ?? ''].join(':');
};

const renderedBlockImageVersion = (image: ImageBlock | ImageDrawing): string =>
[
image.src ?? '',
image.width ?? '',
image.height ?? '',
image.alt ?? '',
image.title ?? '',
image.objectFit ?? '',
image.display ?? '',
image.gain ?? '',
image.blacklevel ?? '',
image.grayscale ? 1 : 0,
imageLuminanceVersion(image.lum),
image.rotation ?? '',
image.flipH ? 1 : 0,
image.flipV ? 1 : 0,
imageHyperlinkVersion(image.hyperlink),
resolveBlockClipPath(image),
].join('|');

const renderedInlineImageRunVersion = (image: ImageRun): string =>
[
'img',
image.src ?? '',
image.width ?? '',
image.height ?? '',
image.alt ?? '',
image.title ?? '',
typeof image.clipPath === 'string' ? image.clipPath.trim() : '',
image.distTop ?? '',
image.distBottom ?? '',
image.distLeft ?? '',
image.distRight ?? '',
image.verticalAlign ?? '',
image.gain ?? '',
image.blacklevel ?? '',
image.grayscale ? 1 : 0,
imageLuminanceVersion(image.lum),
image.rotation ?? '',
image.flipH ? 1 : 0,
image.flipV ? 1 : 0,
imageHyperlinkVersion(image.hyperlink),
].join('|');

// ---------------------------------------------------------------------------
// List marker validation
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -200,21 +254,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
const runsVersion = block.runs
.map((run) => {
if (run.kind === 'image') {
const imgRun = run as ImageRun;
return [
'img',
imgRun.src,
imgRun.width,
imgRun.height,
imgRun.alt ?? '',
imgRun.title ?? '',
imgRun.clipPath ?? '',
imgRun.distTop ?? '',
imgRun.distBottom ?? '',
imgRun.distLeft ?? '',
imgRun.distRight ?? '',
readClipPathValue((imgRun as { clipPath?: unknown }).clipPath),
].join(',');
return renderedInlineImageRunVersion(run as ImageRun);
}

if (run.kind === 'lineBreak') {
Expand Down Expand Up @@ -313,28 +353,13 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
if (block.kind === 'image') {
const imgSdt = (block as ImageBlock).attrs?.sdt;
const imgSdtVersion = getSdtMetadataVersion(imgSdt);
return [
block.src ?? '',
block.width ?? '',
block.height ?? '',
block.alt ?? '',
block.title ?? '',
resolveBlockClipPath(block),
imgSdtVersion,
].join('|');
return [renderedBlockImageVersion(block), imgSdtVersion].join('|');
}

if (block.kind === 'drawing') {
if (block.drawingKind === 'image') {
const imageLike = block as ImageDrawing;
return [
'drawing:image',
imageLike.src ?? '',
imageLike.width ?? '',
imageLike.height ?? '',
imageLike.alt ?? '',
resolveBlockClipPath(imageLike),
].join('|');
return ['drawing:image', renderedBlockImageVersion(imageLike)].join('|');
}
if (block.drawingKind === 'vectorShape') {
const vector = block as VectorShapeDrawing;
Expand Down Expand Up @@ -445,6 +470,13 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
}

for (const run of runs) {
if (run.kind === 'image') {
hash = hashString(hash, renderedInlineImageRunVersion(run as ImageRun));
hash = hashNumber(hash, run.pmStart ?? -1);
hash = hashNumber(hash, run.pmEnd ?? -1);
continue;
}

if ('text' in run && typeof run.text === 'string') {
hash = hashString(hash, run.text);
}
Expand All @@ -466,6 +498,8 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
const bidi = (run as { bidi?: unknown }).bidi;
hash = hashString(hash, bidi ? JSON.stringify(bidi) : '');
}
} else if (cellBlock?.kind) {
hash = hashString(hash, deriveBlockVersion(cellBlock as FlowBlock));
}
}
}
Expand Down
51 changes: 51 additions & 0 deletions packages/layout-engine/painters/dom/src/images/drawing-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type {
DrawingBlock,
ImageDrawing,
PositionedDrawingGeometry,
ShapeGroupChild,
TextPart,
} from '@superdoc/contracts';
import { applyImageClipPath } from './image-clip-path.js';
import { createBlockImageContent } from './image-block.js';
import type { BuildImageHyperlinkAnchor } from './types.js';

export const createDrawingImageElement = (
doc: Document,
block: DrawingBlock,
buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor,
): HTMLElement => {
const drawing = block as ImageDrawing;
return createBlockImageContent({
doc,
block: drawing,
className: 'superdoc-drawing-image',
imageDisplay: 'block',
buildImageHyperlinkAnchor,
});
};

export const createShapeGroupImageElement = (doc: Document, child: ShapeGroupChild): HTMLElement => {
const attrs = child.attrs as PositionedDrawingGeometry & {
src: string;
alt?: string;
clipPath?: string;
};
const img = doc.createElement('img');
img.src = attrs.src;
img.alt = attrs.alt ?? '';
img.style.objectFit = 'contain';
img.style.display = 'block';
applyImageClipPath(img, attrs.clipPath);
return img;
};

export const createShapeTextImageElement = (doc: Document, part: TextPart): HTMLElement => {
const img = doc.createElement('img');
img.src = part.src!;
img.alt = part.alt ?? '';
if (typeof part.width === 'number') img.style.width = `${part.width}px`;
if (typeof part.height === 'number') img.style.height = `${part.height}px`;
img.style.display = 'inline-block';
img.style.verticalAlign = 'bottom';
return img;
};
49 changes: 49 additions & 0 deletions packages/layout-engine/painters/dom/src/images/hyperlink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { ImageHyperlink } from '@superdoc/contracts';
import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation';

export const buildImageHyperlinkAnchor = (
doc: Document,
imageEl: HTMLElement,
hyperlink: ImageHyperlink | undefined,
display: 'block' | 'inline-block',
): HTMLElement => {
if (!hyperlink?.url) return imageEl;

const sanitized = sanitizeHref(hyperlink.url);
if (!sanitized?.href) return imageEl;

const anchor = 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';
}

const tooltipSource =
typeof hyperlink.tooltip === 'string' && hyperlink.tooltip.trim().length > 0 ? hyperlink.tooltip : hyperlink.url;
const tooltipResult = encodeTooltip(tooltipSource);
if (tooltipResult?.text) {
anchor.title = tooltipResult.text;
}

for (const titledElement of [imageEl, ...Array.from(imageEl.querySelectorAll('[title]'))]) {
titledElement.removeAttribute('title');
}

anchor.setAttribute('role', 'link');
anchor.setAttribute('tabindex', '0');

if (display === 'block') {
anchor.style.cssText = 'display: block; width: 100%; height: 100%; cursor: pointer;';
} else {
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;
};
Loading
Loading