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 115beebb..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 ( @@ -10,6 +42,27 @@ 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. */} + + + + + + + + + {/* 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 2e99b598..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, 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,16 +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]; } + // 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; @@ -132,7 +247,7 @@ export default function CustomRenderer() { onPageRenderSuccess(); }) .catch(() => { - // Intentionally empty + // Render was cancelled }); return () => { @@ -147,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 5edaece2..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,9 +20,54 @@ export function erosionFilter(erosions: number): string { erosions -= 1; } } + // 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(" "); } +// 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; @@ -74,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 + } +}