Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/[locale]/calibrate/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
DisplaySettings,
getDefaultDisplaySettings,
isDarkTheme,
isColourTheme,
strokeColor,
themeFilter,
Theme,
} from "@/_lib/display-settings";
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -650,6 +659,7 @@ export default function Page() {
lineThickness={lineThickness}
stitchSettings={stitchSettings}
filter={themeFilter(displaySettings.theme)}
recolourHex={recolourHex}
dispatchStitchSettings={dispatchStitchSettings}
setLineThicknessStatus={setLineThicknessStatus}
setFileLoadStatus={setFileLoadStatus}
Expand Down Expand Up @@ -803,7 +813,7 @@ export default function Page() {
</Transformable>
</FullScreen>
</div>
<Filters />
<Filters recolourHex={recolourHex} />
</main>
);
}
55 changes: 54 additions & 1 deletion app/_components/filters.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg xmlns="http://www.w3.org/2000/svg">
<filter id="erode-1">
Expand All @@ -10,6 +42,27 @@ export default function Filters() {
<filter id="erode-3">
<feMorphology operator="erode" radius="3" />
</filter>

{/* 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. */}
<filter id="push-darks">
<feComponentTransfer>
<feFuncR type="gamma" amplitude="1" exponent="2" offset="0" />
<feFuncG type="gamma" amplitude="1" exponent="2" offset="0" />
<feFuncB type="gamma" amplitude="1" exponent="2" offset="0" />
</feComponentTransfer>
</filter>

{/* 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 && (
<filter id="recolor" colorInterpolationFilters="sRGB">
<feColorMatrix type="matrix" values={recolourValues} />
</filter>
)}
</svg>
);
}
157 changes: 141 additions & 16 deletions app/_components/pdf-custom-renderer.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<HTMLCanvasElement>(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<HTMLCanvasElement | null>(null);
// Chrome/Firefox: OffscreenCanvas as render target.
const offscreen = useRef<OffscreenCanvas | null>(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(
Expand All @@ -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);
}
Expand All @@ -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)) {
Expand All @@ -98,6 +185,7 @@ export default function CustomRenderer() {
if (!ctx) {
return;
}

const renderContext: RenderParameters = {
canvasContext: ctx as any,
viewport: renderViewport,
Expand All @@ -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;
Expand All @@ -132,7 +247,7 @@ export default function CustomRenderer() {
onPageRenderSuccess();
})
.catch(() => {
// Intentionally empty
// Render was cancelled
});

return () => {
Expand All @@ -147,18 +262,28 @@ export default function CustomRenderer() {
layers,
pdf,
erosions,
magnifying,
filter,
safariEffectiveRecolourHex,
recolourHex,
renderErosions,
renderWidth,
renderHeight,
isSafari,
safariThemeKey,
themeFilter,
onPageRenderSuccess,
]);

return (
<canvas
className={`${_className}__canvas`}
ref={canvasElement}
width={renderWidth}
height={renderHeight}
// Chrome/Firefox: React sets canvas pixel dimensions directly.
// Safari: undefined — dimensions are managed in the render callback
// (the canvas starts at 0×0 on mount and is sized when pixels are ready).
width={isSafari ? undefined : renderWidth}
height={isSafari ? undefined : renderHeight}
style={{
width:
Math.floor(
Expand Down
Loading