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 (
);
}
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 (