From a63c661dd516a4fd497cede3a81b83dff927ec2a Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Mon, 13 Apr 2026 16:19:00 +0100 Subject: [PATCH 1/2] Improve PDF line quality: push-darks filter and contrast boost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After feMorphology erode, anti-aliased line edges become grey and blurry. This adds a two-step quality pass to counteract that: 1. A push-darks SVG filter (feComponentTransfer with gamma=2) that darkens grey midtones toward black while leaving white backgrounds unchanged. 2. A contrast(1.5) CSS filter that further separates lines from background. On Chrome/Firefox these run as part of the canvas draw filter chain: erode → url(#push-darks) → contrast(1.5) On Safari, SVG filter references on canvas CSS aren't supported, so an equivalent pixel-level pass (enhanceLineQualityFast) runs directly on the ImageData after erosion. It uses a pre-built LUT for gamma + contrast, which is 10-100× faster than per-pixel Math.pow() calls. Both paths produce visually equivalent results. --- app/_components/filters.tsx | 11 +++++++ app/_components/pdf-custom-renderer.tsx | 5 ++- app/_lib/erode.ts | 41 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/_components/filters.tsx b/app/_components/filters.tsx index 115beebb..9ef1d6ee 100644 --- a/app/_components/filters.tsx +++ b/app/_components/filters.tsx @@ -10,6 +10,17 @@ export default function Filters() { + + {/* Push dark grey pixels toward black using gamma correction. + After erosion, anti-aliased edges become grey. This filter + darkens those greys back toward black so lines appear solid. */} + + + + + + + ); } diff --git a/app/_components/pdf-custom-renderer.tsx b/app/_components/pdf-custom-renderer.tsx index 2e99b598..03fb1c47 100644 --- a/app/_components/pdf-custom-renderer.tsx +++ b/app/_components/pdf-custom-renderer.tsx @@ -8,7 +8,7 @@ import type { } from "pdfjs-dist/types/src/display/api.js"; import { PDFPageProxy } from "pdfjs-dist"; import { PDF_TO_CSS_UNITS } from "@/_lib/pixels-per-inch"; -import { erodeImageData, erosionFilter } from "@/_lib/erode"; +import { erodeImageData, enhanceLineQualityFast, erosionFilter } from "@/_lib/erode"; import useRenderContext from "@/_hooks/use-render-context"; export default function CustomRenderer() { @@ -118,6 +118,9 @@ export default function CustomRenderer() { erodeImageData(result, buffer); [result, buffer] = [buffer, result]; } + // Darken grey anti-aliased edges and boost contrast — equivalent + // to the push-darks + contrast(1.5) SVG filters used on Chrome/Firefox. + enhanceLineQualityFast(result); ctx.putImageData(result, 0, 0); } else if (offscreen.current) { // draw offscreen canvas to onscreen canvas with filter. diff --git a/app/_lib/erode.ts b/app/_lib/erode.ts index 5edaece2..fb26f459 100644 --- a/app/_lib/erode.ts +++ b/app/_lib/erode.ts @@ -15,9 +15,50 @@ export function erosionFilter(erosions: number): string { erosions -= 1; } } + // Push grey anti-aliased edges back toward black, then boost contrast. + // This counteracts the blurring/fading that feMorphology introduces. + result.push("url(#push-darks)"); + result.push("contrast(1.5)"); return result.join(" "); } +// Cached lookup table for fast gamma + contrast transformation. +let _lut: Uint8Array | null = null; +let _lutGamma: number | null = null; +let _lutContrast: number | null = null; + +/** + * Fast enhancement using a lookup table: applies gamma correction to darken + * midtones toward black, then a contrast boost to separate lines from background. + * Used on Safari where SVG filter references on canvas CSS aren't supported. + * The LUT is built once and reused, making it 10-100× faster than Math.pow() per pixel. + */ +export function enhanceLineQualityFast( + imageData: ImageData, + gamma: number = 2, + contrast: number = 1.5, +) { + if (_lut === null || _lutGamma !== gamma || _lutContrast !== contrast) { + _lut = new Uint8Array(256); + for (let i = 0; i < 256; i++) { + let v = i / 255; + v = Math.pow(v, gamma); + v = (v - 0.5) * contrast + 0.5; + _lut[i] = Math.max(0, Math.min(255, Math.round(v * 255))); + } + _lutGamma = gamma; + _lutContrast = contrast; + } + const data = imageData.data; + const len = data.length; + for (let i = 0; i < len; i += 4) { + data[i] = _lut[data[i]]; + data[i + 1] = _lut[data[i + 1]]; + data[i + 2] = _lut[data[i + 2]]; + // Alpha unchanged + } +} + export function erodeImageData(imageData: ImageData, output: ImageData) { const { width, height } = imageData; const erodedData = output.data; From a2f4e4675f3613bd538ede44aaea8fc88ffd5621 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Mon, 13 Apr 2026 16:23:21 +0100 Subject: [PATCH 2/2] Replace approximate green colour filter with exact hex colour via feColorMatrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Green theme previously used a chain of CSS filters to approximate green lines on black: invert(1) sepia(1) saturate(10000%) hue-rotate(65deg) brightness(1.5) This had several problems (tracked in issue #418): - Colours were never exact; hue-rotate is an approximation - Cross-browser inconsistencies between Chrome and Safari - Different code paths produced visually different results This commit replaces that chain with a single feColorMatrix SVG filter (the `#recolor` filter) that directly maps pixel luminance to the target colour: output = targetColour × (1 - luminance(input)) So black input → target colour at full intensity, white input → black (invisible on dark background), grey inputs → proportionally dimmer shades of the target colour. On Chrome/Firefox the filter is applied via ctx.filter at canvas draw time. On Safari, where SVG filter references are not supported on canvas contexts, pixel-level processing (recolourImageData) is used instead. Safari rendering improvements: - A back buffer canvas is used as the render target. The visible canvas only receives committed pixels once processing is complete, eliminating the grey flash that previously appeared during zoom changes. - The visible canvas starts at 0×0 on mount to avoid a white rectangle appearing before the first render. - During theme switches the canvas is hidden until the new render is ready; the parent div's backgroundColor shows through in the interim. - onPageRenderSuccess is now wrapped in useCallback so its stable reference does not cause the render effect to re-fire on every parent re-render (which was causing extremely slow panning on Safari). Changes: - filters.tsx: adds the `#recolor` feColorMatrix filter, computed from the strokeColor hex passed as a prop from the page - erode.ts: erosionFilter() always applies push-darks + contrast(1.5), even at 0 erosions; accepts an optional useRecolour flag to append url(#recolor); adds recolourImageData() for Safari pixel-level colour - display-settings.ts: Green themeFilter returns "none"; adds isColourTheme() helper; Green strokeColor set to #00FFCC - use-render-context.ts: adds recolourHex and themeFilter fields - pdf-viewer.tsx: passes recolourHex and themeFilter to RenderContext; switches blend mode to "lighten" for overlapping dark-background pages; wraps onPageRenderSuccess in useCallback - pdf-custom-renderer.tsx: Safari back-buffer render path; Chrome/Firefox ctx.filter draw path; zoom-out skip via content key; mount-zero canvas - calibrate/page.tsx: computes recolourHex from strokeColor and wires it through to Filters and PdfViewer --- app/[locale]/calibrate/page.tsx | 12 +- app/_components/filters.tsx | 44 ++++++- app/_components/pdf-custom-renderer.tsx | 158 +++++++++++++++++++++--- app/_components/pdf-viewer.tsx | 23 +++- app/_hooks/use-render-context.ts | 13 ++ app/_lib/display-settings.ts | 10 +- app/_lib/erode.ts | 52 +++++++- 7 files changed, 280 insertions(+), 32 deletions(-) diff --git a/app/[locale]/calibrate/page.tsx b/app/[locale]/calibrate/page.tsx index af424424..33d16423 100644 --- a/app/[locale]/calibrate/page.tsx +++ b/app/[locale]/calibrate/page.tsx @@ -27,6 +27,8 @@ import { DisplaySettings, getDefaultDisplaySettings, isDarkTheme, + isColourTheme, + strokeColor, themeFilter, Theme, } from "@/_lib/display-settings"; @@ -165,6 +167,13 @@ export default function Page() { const IDLE_TIMEOUT = 8000; + // When using the Green colour theme, apply colouring at canvas draw time via + // a feColorMatrix SVG filter rather than the indirect invert/sepia/hue-rotate + // chain. This gives exact colours and consistent results across browsers. + const recolourHex = isColourTheme(displaySettings.theme) + ? strokeColor(displaySettings.theme) + : undefined; + const svgStyle = { filter: filter(magnifying, lineThickness, displaySettings.theme), }; @@ -650,6 +659,7 @@ export default function Page() { lineThickness={lineThickness} stitchSettings={stitchSettings} filter={themeFilter(displaySettings.theme)} + recolourHex={recolourHex} dispatchStitchSettings={dispatchStitchSettings} setLineThicknessStatus={setLineThicknessStatus} setFileLoadStatus={setFileLoadStatus} @@ -803,7 +813,7 @@ export default function Page() { - + ); } diff --git a/app/_components/filters.tsx b/app/_components/filters.tsx index 9ef1d6ee..b71912a9 100644 --- a/app/_components/filters.tsx +++ b/app/_components/filters.tsx @@ -1,4 +1,36 @@ -export default function Filters() { +/** + * Parse a hex colour string to 0–1 RGB values. + */ +function hexToUnit(hex: string): { r: number; g: number; b: number } { + const v = hex.replace("#", ""); + const n = v.length === 3 ? `${v[0]}${v[0]}${v[1]}${v[1]}${v[2]}${v[2]}` : v; + const parsed = Number.parseInt(n, 16); + return { + r: ((parsed >> 16) & 255) / 255, + g: ((parsed >> 8) & 255) / 255, + b: (parsed & 255) / 255, + }; +} + +export default function Filters({ recolourHex }: { recolourHex?: string }) { + // Build the feColorMatrix values for the recolor filter. + // Maps black (0,0,0) → target colour, white (1,1,1) → black (0,0,0). + // Formula: output = targetColour × (1 - luminance(input)) + // row R: [-tR×0.2126, -tR×0.7152, -tR×0.0722, 0, tR] + // row G: [-tG×0.2126, -tG×0.7152, -tG×0.0722, 0, tG] + // row B: [-tB×0.2126, -tB×0.7152, -tB×0.0722, 0, tB] + // row A: [0, 0, 0, 1, 0 ] + let recolourValues = ""; + if (recolourHex) { + const { r, g, b } = hexToUnit(recolourHex); + recolourValues = [ + `${(-r * 0.2126).toFixed(4)} ${(-r * 0.7152).toFixed(4)} ${(-r * 0.0722).toFixed(4)} 0 ${r.toFixed(4)}`, + `${(-g * 0.2126).toFixed(4)} ${(-g * 0.7152).toFixed(4)} ${(-g * 0.0722).toFixed(4)} 0 ${g.toFixed(4)}`, + `${(-b * 0.2126).toFixed(4)} ${(-b * 0.7152).toFixed(4)} ${(-b * 0.0722).toFixed(4)} 0 ${b.toFixed(4)}`, + "0 0 0 1 0", + ].join("\n"); + } + return ( @@ -21,6 +53,16 @@ export default function Filters() { + + {/* Recolour filter: maps black → target colour, white → black, + with luminance-proportional shading for grey tones. + Replaces the old invert/sepia/hue-rotate chain with a single + feColorMatrix that maps to the exact target hex colour. */} + {recolourHex && ( + + + + )} ); } diff --git a/app/_components/pdf-custom-renderer.tsx b/app/_components/pdf-custom-renderer.tsx index 03fb1c47..cbb28550 100644 --- a/app/_components/pdf-custom-renderer.tsx +++ b/app/_components/pdf-custom-renderer.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useRef } from "react"; +// pdf-custom-renderer.tsx +import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; import invariant from "tiny-invariant"; import { usePageContext, useDocumentContext } from "react-pdf"; @@ -8,39 +9,107 @@ import type { } from "pdfjs-dist/types/src/display/api.js"; import { PDFPageProxy } from "pdfjs-dist"; import { PDF_TO_CSS_UNITS } from "@/_lib/pixels-per-inch"; -import { erodeImageData, enhanceLineQualityFast, erosionFilter } from "@/_lib/erode"; +import { + erodeImageData, + enhanceLineQualityFast, + recolourImageData, + erosionFilter, +} from "@/_lib/erode"; import useRenderContext from "@/_hooks/use-render-context"; export default function CustomRenderer() { - const { erosions, layers, magnifying, onPageRenderSuccess, patternScale } = - useRenderContext(); - const pageContext = usePageContext(); + const { + erosions, + layers, + magnifying, + onPageRenderSuccess, + patternScale, + recolourHex, + themeFilter, + } = useRenderContext(); + const pageContext = usePageContext(); invariant(pageContext, "Unable to find Page context."); const docContext = useDocumentContext(); - invariant(docContext, "Unable to find Document context."); const isSafari = useMemo(() => { const ua = navigator.userAgent.toLowerCase(); return ua.indexOf("safari") != -1 && ua.indexOf("chrome") == -1; }, []); - const filter = isSafari ? "none" : erosionFilter(magnifying ? 0 : erosions); - // Safari does not support the feMorphology filter in CSS. - const renderErosions = isSafari ? erosions : 0; + const useRecolour = !!recolourHex && !isSafari; + + // Chrome/Firefox: quality filter applied via ctx.filter when drawing offscreen + // → visible canvas. Theme filter (e.g. invert for Dark) stays on the container div. + const filter = isSafari + ? "none" + : erosionFilter(magnifying ? 0 : erosions, useRecolour); + + // Safari: erosion and enhancement run at pixel level, respecting magnifying. + const renderErosions = isSafari ? (magnifying ? 0 : erosions) : 0; + + // Safari: pixel-level recolour for colour themes (Green etc). Dark theme is + // handled by the container div's CSS filter: invert(1) — Safari supports this + // on a div, so no need to bake it into canvas pixels. + const safariEffectiveRecolourHex = isSafari ? recolourHex : undefined; const _className = pageContext._className; const page = pageContext.page; const pdf = docContext.pdf; + const canvasElement = useRef(null); + // Safari: back buffer canvas. page.render() writes here; processed pixels are + // written to the visible canvas only when fully ready, preventing grey flashes. + const backCanvas = useRef(null); + // Chrome/Firefox: OffscreenCanvas as render target. const offscreen = useRef(null); + + const lastLayersRef = useRef(layers); + const layersVersionRef = useRef(0); + const userUnit = (page as PDFPageProxy).userUnit || 1; invariant(page, "Unable to find page."); invariant(pdf, "Unable to find pdf."); + // Safari: zero the canvas on mount so no blank rectangle is visible before + // the first render completes. + useLayoutEffect(() => { + if (!isSafari) return; + const canvas = canvasElement.current; + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + }, [isSafari]); + + // Safari: when the recolour target changes (theme switch), hide the canvas + // before the browser paints so the parent div's backgroundColor shows through + // until the new render is ready. + const lastSafariThemeRef = useRef(""); + const safariThemeKey = isSafari + ? (safariEffectiveRecolourHex ?? "none") + : ""; + useLayoutEffect(() => { + if (!isSafari) return; + const canvas = canvasElement.current; + if (!canvas || canvas.width === 0 || canvas.height === 0) return; + if ( + lastSafariThemeRef.current !== "" && + lastSafariThemeRef.current !== safariThemeKey + ) { + canvas.style.visibility = "hidden"; + } + lastSafariThemeRef.current = safariThemeKey; + }, [isSafari, safariThemeKey]); + + // Ensure back canvas for Safari pixel processing. + if (isSafari && backCanvas.current === null) { + backCanvas.current = document.createElement("canvas"); + } + const viewport = useMemo(() => page.getViewport({ scale: 1 }), [page]); const renderViewport = useMemo( @@ -64,7 +133,7 @@ export default function CustomRenderer() { offscreen.current.width !== renderWidth || offscreen.current.height !== renderHeight ) { - // Some iPad's don't support OffscreenCanvas. + // Some iPads don't support OffscreenCanvas. if (!isSafari) { offscreen.current = new OffscreenCanvas(renderWidth, renderHeight); } @@ -77,10 +146,28 @@ export default function CustomRenderer() { page.cleanup(); - const canvas = offscreen.current ?? canvasElement.current; + if (lastLayersRef.current !== layers) { + lastLayersRef.current = layers; + layersVersionRef.current++; + } + + // Render target: + // - Safari: backCanvas so the visible canvas only receives finished pixels. + // - Chrome/Firefox: offscreen canvas, then drawImage to the visible canvas. + const canvas = isSafari + ? backCanvas.current + : offscreen.current ?? canvasElement.current; if (!canvas) { return; } + + // Size the back buffer for Safari (Chrome offscreen is sized at component + // render time via the OffscreenCanvas constructor). + if (canvas instanceof HTMLCanvasElement) { + canvas.width = renderWidth; + canvas.height = renderHeight; + } + async function optionalContentConfigPromise(pdf: PDFDocumentProxy) { const optionalContentConfig = await pdf.getOptionalContentConfig(); for (const layer of Object.values(layers)) { @@ -98,6 +185,7 @@ export default function CustomRenderer() { if (!ctx) { return; } + const renderContext: RenderParameters = { canvasContext: ctx as any, viewport: renderViewport, @@ -111,19 +199,43 @@ export default function CustomRenderer() { cancellable.promise .then(() => { - if (renderErosions > 0) { + if (isSafari) { + // Process pixels on the back buffer, then commit to visible canvas. let result = ctx.getImageData(0, 0, renderWidth, renderHeight); let buffer = new ImageData(renderWidth, renderHeight); for (let i = 0; i < renderErosions; i++) { erodeImageData(result, buffer); [result, buffer] = [buffer, result]; } - // Darken grey anti-aliased edges and boost contrast — equivalent - // to the push-darks + contrast(1.5) SVG filters used on Chrome/Firefox. + // Always apply quality enhancement: darkens grey anti-aliased edges + // and boosts contrast — equivalent to push-darks + contrast(1.5). enhanceLineQualityFast(result); + if (safariEffectiveRecolourHex) { + recolourImageData(result, safariEffectiveRecolourHex); + } + // Write processed pixels back into the backCanvas, then draw canvas- + // to-canvas onto the visible canvas. This keeps the visible canvas on + // the GPU-compositing path (drawImage is hardware-accelerated; + // putImageData directly to the visible canvas can deoptimise it). ctx.putImageData(result, 0, 0); + const visibleCanvas = canvasElement.current; + if (!visibleCanvas) return; + if ( + visibleCanvas.width !== renderWidth || + visibleCanvas.height !== renderHeight + ) { + visibleCanvas.width = renderWidth; + visibleCanvas.height = renderHeight; + } + const dest = visibleCanvas.getContext("2d", { alpha: false }); + if (dest && backCanvas.current) { + dest.drawImage(backCanvas.current, 0, 0); + } + // Reveal the canvas (hidden on mount or during theme transition). + visibleCanvas.style.visibility = ""; + lastSafariThemeRef.current = safariThemeKey; } else if (offscreen.current) { - // draw offscreen canvas to onscreen canvas with filter. + // Chrome/Firefox: draw offscreen to (JSX-sized) visible canvas with filter. const dest = canvasElement.current?.getContext("2d"); if (!dest) { return; @@ -135,7 +247,7 @@ export default function CustomRenderer() { onPageRenderSuccess(); }) .catch(() => { - // Intentionally empty + // Render was cancelled }); return () => { @@ -150,18 +262,28 @@ export default function CustomRenderer() { layers, pdf, erosions, + magnifying, filter, + safariEffectiveRecolourHex, + recolourHex, renderErosions, renderWidth, renderHeight, + isSafari, + safariThemeKey, + themeFilter, + onPageRenderSuccess, ]); return ( >; setLineThicknessStatus: Dispatch>; @@ -116,10 +119,10 @@ export default function PdfViewer({ } } - function onPageRenderSuccess() { + const onPageRenderSuccess = useCallback(() => { setFileLoadStatus(LoadStatusEnum.SUCCESS); setLineThicknessStatus(LoadStatusEnum.SUCCESS); - } + }, [setFileLoadStatus, setLineThicknessStatus]); const customTextRenderer = useCallback(({ str }: { str: string }) => { return `${str}`; @@ -200,10 +203,16 @@ export default function PdfViewer({ style={{ width: insetWidth, height: insetHeight, + backgroundColor: + recolourHex || filter !== "none" ? "#000000" : "#ffffff", mixBlendMode: cssEdgeInsets.horizontal == 0 && cssEdgeInsets.vertical == 0 ? "normal" - : "darken", + // Dark canvas background (dark/green theme): use "lighten" so + // pages composite correctly when overlapping. + : recolourHex || filter !== "none" + ? "lighten" + : "darken", transform: stitchSettings.verticalAlignment == VerticalAlignment.Top ? `scaleY(1)` @@ -218,10 +227,16 @@ export default function PdfViewer({ magnifying, onPageRenderSuccess, patternScale, + recolourHex, + themeFilter: filter, }} > void; patternScale: number; + /** + * When set, the recolor SVG filter maps black → this hex colour via a + * feColorMatrix SVG filter. + */ + recolourHex?: string; + /** + * CSS filter string for the active theme (e.g. "invert(1)" for Dark). + * Applied to the container div on Chrome/Firefox; on Safari, where canvas + * rendering is pixel-based, it is handled separately per platform. + */ + themeFilter?: string; } export const RenderContext = createContext({ @@ -16,6 +27,8 @@ export const RenderContext = createContext({ magnifying: false, onPageRenderSuccess: () => {}, patternScale: 1, + recolourHex: undefined, + themeFilter: undefined, }); export default function useRenderContext() { diff --git a/app/_lib/display-settings.ts b/app/_lib/display-settings.ts index b749110e..609ce534 100644 --- a/app/_lib/display-settings.ts +++ b/app/_lib/display-settings.ts @@ -43,12 +43,18 @@ export function isDarkTheme(theme: Theme) { return [Theme.Dark, Theme.Green].includes(theme); } +export function isColourTheme(theme: Theme) { + return theme === Theme.Green; +} + export function themeFilter(theme: Theme): string { switch (theme) { case Theme.Dark: return "invert(1)"; case Theme.Green: - return "invert(1) sepia(1) saturate(10000%) hue-rotate(65deg) brightness(1.5)"; + // Colouring is handled by the recolor canvas filter (via recolourHex), + // so no container CSS filter is needed. + return "none"; case Theme.Light: return "none"; } @@ -59,7 +65,7 @@ export function strokeColor(theme: Theme) { case Theme.Dark: return "#fff"; case Theme.Green: - return "#32CD32"; + return "#00FFCC"; case Theme.Light: return "#000"; } diff --git a/app/_lib/erode.ts b/app/_lib/erode.ts index fb26f459..d278e991 100644 --- a/app/_lib/erode.ts +++ b/app/_lib/erode.ts @@ -1,7 +1,12 @@ -export function erosionFilter(erosions: number): string { - if (erosions <= 0) { - return "none"; - } +export function erosionFilter( + erosions: number, + /** + * When true, append `url(#recolor)` to the filter chain. + * The recolor filter maps black → the target colour and white → black + * via a single feColorMatrix SVG filter. + */ + useRecolour: boolean = false, +): string { const result = []; while (erosions > 0) { if (erosions >= 3) { @@ -15,10 +20,14 @@ export function erosionFilter(erosions: number): string { erosions -= 1; } } - // Push grey anti-aliased edges back toward black, then boost contrast. - // This counteracts the blurring/fading that feMorphology introduces. + // Always push dark grey pixels toward black and boost contrast. + // At 0 erosions this cleans up grey anti-aliasing in the raw PDF render. + // At >0 erosions it counteracts the blurring/fading that feMorphology introduces. result.push("url(#push-darks)"); result.push("contrast(1.5)"); + if (useRecolour) { + result.push("url(#recolor)"); + } return result.join(" "); } @@ -115,3 +124,34 @@ function erodeAtIndex( } return c; } + +/** + * Recolour ImageData in-place: maps black pixels to the target hex colour and + * white pixels to black, with luminance-proportional shading for grey tones. + * This is the pixel-level equivalent of the `#recolor` feColorMatrix SVG filter. + * + * Formula: output = targetColour × (1 - luminance(input)) + * Black input (luminance=0) → full target colour + * White input (luminance=1) → black (invisible on dark background) + * Grey inputs → proportionally dimmer shades of the target colour + */ +export function recolourImageData(imageData: ImageData, hex: string) { + const v = hex.replace("#", ""); + const n = v.length === 3 ? `${v[0]}${v[0]}${v[1]}${v[1]}${v[2]}${v[2]}` : v; + const parsed = Number.parseInt(n, 16); + const tR = ((parsed >> 16) & 255) / 255; + const tG = ((parsed >> 8) & 255) / 255; + const tB = (parsed & 255) / 255; + + const data = imageData.data; + const len = data.length; + for (let i = 0; i < len; i += 4) { + const luminance = + (data[i] * 0.2126 + data[i + 1] * 0.7152 + data[i + 2] * 0.0722) / 255; + const intensity = 1 - luminance; + data[i] = Math.round(intensity * tR * 255); + data[i + 1] = Math.round(intensity * tG * 255); + data[i + 2] = Math.round(intensity * tB * 255); + // Alpha unchanged + } +}