Skip to content
Merged
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
36 changes: 36 additions & 0 deletions assets/js/__tests__/duplicate-reports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { setupDupeReports } from '../duplicate-reports';
import { needsChromiumJpegNormalization, replaceFilterImageHrefIfNeeded } from '../utils/browser-workarounds';
import { fixEventListeners } from '../../test/fix-event-listeners';

vi.mock('../utils/browser-workarounds');
fixEventListeners(document);

afterEach(() => {
document.body.innerHTML = '';
vi.resetAllMocks();
});

it.each([true, false])('normalizes both difference filter images only for Chromium: %s', chromium => {
document.body.innerHTML = `
<svg><filter><feImage id="source"/><feImage id="target"/></filter></svg>
<svg><image id="source"/><image id="target"/></svg>`;
vi.mocked(needsChromiumJpegNormalization).mockReturnValue(chromium);

setupDupeReports();

expect(needsChromiumJpegNormalization).toHaveBeenCalledOnce();
const expectedCalls = chromium
? [[document.querySelector('feImage#source')], [document.querySelector('feImage#target')]]
: [];
expect(vi.mocked(replaceFilterImageHrefIfNeeded).mock.calls).toEqual(expectedCalls);
});

it.each(['', '<feImage id="source"/>', '<feImage id="target"/>'])(
'skips normalization without a complete filter pair: %s',
markup => {
document.body.innerHTML = `<svg><filter>${markup}</filter></svg>`;
setupDupeReports();
expect(needsChromiumJpegNormalization).not.toHaveBeenCalled();
expect(replaceFilterImageHrefIfNeeded).not.toHaveBeenCalled();
},
);
13 changes: 13 additions & 0 deletions assets/js/duplicate-reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
*/

import { assertNotNull } from './utils/assert';
import { needsChromiumJpegNormalization, replaceFilterImageHrefIfNeeded } from './utils/browser-workarounds';
import { $, $$, makeEl } from './utils/dom';
import { normalizedKeyboardKey, keys } from './utils/keyboard';

export function setupDupeReports() {
const diffImageSource = $<SVGFEImageElement>('feImage#source');
const diffImageTarget = $<SVGFEImageElement>('feImage#target');
if (diffImageSource && diffImageTarget) {
setupBrowserWorkarounds(diffImageSource, diffImageTarget);
}

const onion = $<SVGSVGElement>('.onion-skin__image');
const slider = $<HTMLInputElement>('.onion-skin__slider');
const swipe = $<SVGSVGElement>('.swipe__image');
Expand All @@ -19,6 +26,12 @@ export function setupDupeReports() {
document.addEventListener('fetchcomplete', mergeDuplicateReportTable);
}

function setupBrowserWorkarounds(source: SVGFEImageElement, target: SVGFEImageElement) {
if (!needsChromiumJpegNormalization()) return;
replaceFilterImageHrefIfNeeded(source);
replaceFilterImageHrefIfNeeded(target);
}

function setupSwipe(swipe: SVGSVGElement) {
const [clip, divider] = $$<SVGRectElement>('#clip rect, #divider', swipe);
const { width } = swipe.viewBox.baseVal;
Expand Down
127 changes: 127 additions & 0 deletions assets/js/utils/__tests__/browser-workarounds.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { needsChromiumJpegNormalization, replaceFilterImageHrefIfNeeded } from '../browser-workarounds';

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe('needsChromiumJpegNormalization', () => {
it.each([
['Chromium', true],
['Google Chrome', false],
['Not A Brand', false],
])('uses client-hint brand %s instead of the user agent', (brand, expected) => {
vi.stubGlobal('navigator', {
userAgentData: { brands: [{ brand, version: '123' }] },
userAgent: 'Chrome/123',
});
expect(needsChromiumJpegNormalization()).toBe(expected);
});

it('finds Chromium among multiple brands', () => {
vi.stubGlobal('navigator', {
userAgentData: { brands: [{ brand: 'Not A Brand' }, { brand: 'Chromium' }] },
});
expect(needsChromiumJpegNormalization()).toBe(true);
});

it('treats an empty brand list as authoritative', () => {
vi.stubGlobal('navigator', { userAgentData: { brands: [] }, userAgent: 'Chrome/123' });
expect(needsChromiumJpegNormalization()).toBe(false);
});

it.each([undefined, {}])('falls back when client-hint brands are unavailable: %j', userAgentData => {
vi.stubGlobal('navigator', { userAgentData, userAgent: 'Chrome/123' });
expect(needsChromiumJpegNormalization()).toBe(true);
});

it.each([
['Mozilla/5.0 Chrome/123.0 Safari/537.36', true],
['Chromium/123.0', true],
['Edg/123.0', true],
['OPR/108.0', true],
['Mozilla/5.0 Firefox/124.0', false],
['Version/17.0 Safari/605.1.15', false],
['CriOS/123.0 Mobile/15E148 Safari/604.1', false],
['NotChrome/123.0', false],
['', false],
])('detects the fallback user agent %s', (userAgent, expected) => {
vi.stubGlobal('navigator', { userAgent });
expect(needsChromiumJpegNormalization()).toBe(expected);
});
});

describe('replaceFilterImageHrefIfNeeded', () => {
const source = 'https://cdn.example/image.jpg';
let image: HTMLImageElement;
let element: SVGFEImageElement;

beforeEach(() => {
image = document.createElement('img');
Object.defineProperties(image, {
naturalWidth: { value: 640 },
naturalHeight: { value: 480 },
});
function createImage() {
return image;
}
vi.stubGlobal('Image', vi.fn(createImage));
element = document.createElementNS('http://www.w3.org/2000/svg', 'feImage');
// jsdom does not implement SVGAnimatedString.
Object.defineProperty(element, 'href', { value: { baseVal: source } });
});

it.each(['image.png', 'image.gif', 'image.webp', 'data:image/png;base64,abc'])('leaves %s untouched', async href => {
element.href.baseVal = href;
await replaceFilterImageHrefIfNeeded(element);
expect(element.href.baseVal).toBe(href);
expect(Image).not.toHaveBeenCalled();
});

it('loads anonymously and converts at natural dimensions using the CPU canvas path', async () => {
const drawImage = vi.fn();
const getContext = vi
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D);
const toBlob = vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback, _type) => {
callback(new Blob());
});
const srcSetter = vi.spyOn(image, 'src', 'set');
const corsSetter = vi.spyOn(image, 'crossOrigin', 'set');

const replacement = replaceFilterImageHrefIfNeeded(element);
expect(image.crossOrigin).toBe('anonymous');
expect(image.src).toBe(source);
expect(corsSetter).toHaveBeenCalledBefore(srcSetter);
expect(element.href.baseVal).toBe(source);
expect(getContext).not.toHaveBeenCalled();

image.dispatchEvent(new Event('load'));
await replacement;

expect(getContext).toHaveBeenCalledWith('2d', { willReadFrequently: true });
const canvas = getContext.mock.contexts[0] as HTMLCanvasElement;
expect(canvas.width).toBe(640);
expect(canvas.height).toBe(480);
expect(drawImage).toHaveBeenCalledWith(image, 0, 0);
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png');
expect(element.href.baseVal).toMatch(/^blob:/);
});

it('rejects failed image loads without changing the href', async () => {
const replacement = replaceFilterImageHrefIfNeeded(element);
const rejection = expect(replacement).rejects.toBeUndefined();
image.dispatchEvent(new Event('error'));
await rejection;
expect(element.href.baseVal).toBe(source);
});

it('preserves the href when no canvas context is available', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const replacement = replaceFilterImageHrefIfNeeded(element);
const rejection = expect(replacement).rejects.toThrow();
image.dispatchEvent(new Event('load'));
await rejection;
expect(element.href.baseVal).toBe(source);
});
});
46 changes: 46 additions & 0 deletions assets/js/utils/browser-workarounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { assertNotNull } from './assert';

export function needsChromiumJpegNormalization() {
if (navigator.userAgentData?.brands) {
return navigator.userAgentData.brands.some(({ brand }) => brand === 'Chromium');
}

return /\b(?:Chrome|Chromium|Edg|OPR)\//.test(navigator.userAgent);
}

export async function replaceFilterImageHrefIfNeeded(element: SVGFEImageElement) {
if (!element.href.baseVal.endsWith('.jpg')) return;
element.href.baseVal = await rasterizeToBlobURL(element.href.baseVal);
}

async function rasterizeToBlobURL(source: string) {
const image = await loadCORSImage(source);
return await rasterizeCanvas(image);
}

function loadCORSImage(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject();

image.crossOrigin = 'anonymous';
image.src = source;
});
}

function rasterizeCanvas(image: HTMLImageElement): Promise<string> {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;

// Set willReadFrequently to avoid bugged Chromium GPU decode path
const context = assertNotNull(canvas.getContext('2d', { willReadFrequently: true }));
context.drawImage(image, 0, 0);

return new Promise(resolve => {
canvas.toBlob(blob => {
resolve(URL.createObjectURL(assertNotNull(blob)));
}, 'image/png');
});
}
14 changes: 7 additions & 7 deletions lib/philomena_web/templates/duplicate_report/show.html.slime
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ h1 Difference
.difference#subtractive
svg.difference__image viewBox="0 0 #{width} #{height}" height=height
defs
filter#overlay-diff color-interpolation-filters="sRGB"
feImage#source xlink:href=source_url result="source" width="100%" height="100%" x="0" y="0"
feImage#target xlink:href=target_url result="target" width="100%" height="100%" x="0" y="0"
filter#overlay-diff
feImage#source xlink:href=source_url crossorigin="anonymous" result="source" width="100%" height="100%" x="0" y="0"
feImage#target xlink:href=target_url crossorigin="anonymous" result="target" width="100%" height="100%" x="0" y="0"
feBlend in="source" in2="target" mode="difference" result="diff"

/ Contrast-boost matrix = (5I|0) [4x5]
Expand All @@ -41,8 +41,8 @@ h1 Swipe
clipPath#clip
rect width=div(width, 2) height=height
rect width=width height=height fill="url(#checkerboard)"
image#target width="100%" height="100%" xlink:href=target_url
image#source width="100%" height="100%" xlink:href=source_url clip-path="url(#clip)"
image#target width="100%" height="100%" crossorigin="anonymous" xlink:href=target_url
image#source width="100%" height="100%" crossorigin="anonymous" xlink:href=source_url clip-path="url(#clip)"
rect#divider width="3" height=height x=div(width, 2) fill="#000" stroke="#fff" stroke-width="1"

h1 Onion Skin
Expand All @@ -55,8 +55,8 @@ h1 Onion Skin
rect width="8" height="8" x="8" y="0" fill="#00000044"
rect width="8" height="8" x="8" y="8" fill="#ffffff44"
rect width=width height=height fill="url(#checkerboard)"
image#source width="100%" height="100%" xlink:href=source_url
image#target width="100%" height="100%" xlink:href=target_url
image#source width="100%" height="100%" crossorigin="anonymous" xlink:href=source_url
image#target width="100%" height="100%" crossorigin="anonymous" xlink:href=target_url
.onion-skin__slider-box
input.onion-skin__slider type="range" min="0" max="1" step="0.01"
button.button.onion-skin__button
Expand Down
Loading