diff --git a/assets/js/__tests__/duplicate-reports.spec.ts b/assets/js/__tests__/duplicate-reports.spec.ts
new file mode 100644
index 000000000..9fd489de2
--- /dev/null
+++ b/assets/js/__tests__/duplicate-reports.spec.ts
@@ -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 = `
+
+ `;
+ 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(['', '', ''])(
+ 'skips normalization without a complete filter pair: %s',
+ markup => {
+ document.body.innerHTML = ``;
+ setupDupeReports();
+ expect(needsChromiumJpegNormalization).not.toHaveBeenCalled();
+ expect(replaceFilterImageHrefIfNeeded).not.toHaveBeenCalled();
+ },
+);
diff --git a/assets/js/duplicate-reports.ts b/assets/js/duplicate-reports.ts
index 668438fe3..3de70c0ef 100644
--- a/assets/js/duplicate-reports.ts
+++ b/assets/js/duplicate-reports.ts
@@ -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 = $('feImage#source');
+ const diffImageTarget = $('feImage#target');
+ if (diffImageSource && diffImageTarget) {
+ setupBrowserWorkarounds(diffImageSource, diffImageTarget);
+ }
+
const onion = $('.onion-skin__image');
const slider = $('.onion-skin__slider');
const swipe = $('.swipe__image');
@@ -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] = $$('#clip rect, #divider', swipe);
const { width } = swipe.viewBox.baseVal;
diff --git a/assets/js/utils/__tests__/browser-workarounds.spec.ts b/assets/js/utils/__tests__/browser-workarounds.spec.ts
new file mode 100644
index 000000000..7db4b5db0
--- /dev/null
+++ b/assets/js/utils/__tests__/browser-workarounds.spec.ts
@@ -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);
+ });
+});
diff --git a/assets/js/utils/browser-workarounds.ts b/assets/js/utils/browser-workarounds.ts
new file mode 100644
index 000000000..76822ffc9
--- /dev/null
+++ b/assets/js/utils/browser-workarounds.ts
@@ -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 {
+ 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 {
+ 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');
+ });
+}
diff --git a/lib/philomena_web/templates/duplicate_report/show.html.slime b/lib/philomena_web/templates/duplicate_report/show.html.slime
index 48033e753..6faff434d 100644
--- a/lib/philomena_web/templates/duplicate_report/show.html.slime
+++ b/lib/philomena_web/templates/duplicate_report/show.html.slime
@@ -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]
@@ -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
@@ -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