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 = `${markup}`; + 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