251 lines
6.8 KiB
TypeScript
251 lines
6.8 KiB
TypeScript
import qrcodegen from "./codegen";
|
|
import {
|
|
DEFAULT_BGCOLOR,
|
|
DEFAULT_FGCOLOR,
|
|
DEFAULT_IMG_SCALE,
|
|
DEFAULT_LEVEL,
|
|
DEFAULT_MARGIN,
|
|
DEFAULT_SIZE,
|
|
ERROR_LEVEL_MAP,
|
|
} from "./constants";
|
|
import { Excavation, ImageSettings, Modules, QRPropsSVG } from "./types";
|
|
|
|
// We could just do this in generatePath, except that we want to support
|
|
// non-Path2D canvas, so we need to keep it an explicit step.
|
|
export function excavateModules(
|
|
modules: Modules,
|
|
excavation: Excavation,
|
|
): Modules {
|
|
return modules.slice().map((row, y) => {
|
|
if (y < excavation.y || y >= excavation.y + excavation.h) {
|
|
return row;
|
|
}
|
|
return row.map((cell, x) => {
|
|
if (x < excavation.x || x >= excavation.x + excavation.w) {
|
|
return cell;
|
|
}
|
|
return false;
|
|
});
|
|
});
|
|
}
|
|
|
|
export function generatePath(modules: Modules, margin = 0): string {
|
|
const ops: Array<string> = [];
|
|
modules.forEach(function (row, y) {
|
|
let start: number | null = null;
|
|
row.forEach(function (cell, x) {
|
|
if (!cell && start !== null) {
|
|
// M0 0h7v1H0z injects the space with the move and drops the comma,
|
|
// saving a char per operation
|
|
ops.push(
|
|
`M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z`,
|
|
);
|
|
start = null;
|
|
return;
|
|
}
|
|
|
|
// end of row, clean up or skip
|
|
if (x === row.length - 1) {
|
|
if (!cell) {
|
|
// We would have closed the op above already so this can only mean
|
|
// 2+ light modules in a row.
|
|
return;
|
|
}
|
|
if (start === null) {
|
|
// Just a single dark module.
|
|
ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`);
|
|
} else {
|
|
// Otherwise finish the current line.
|
|
ops.push(
|
|
`M${start + margin},${y + margin} h${x + 1 - start}v1H${
|
|
start + margin
|
|
}z`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (cell && start === null) {
|
|
start = x;
|
|
}
|
|
});
|
|
});
|
|
return ops.join("");
|
|
}
|
|
|
|
export function getImageSettings(
|
|
cells: Modules,
|
|
size: number,
|
|
margin: number,
|
|
imageSettings?: ImageSettings,
|
|
): null | {
|
|
x: number;
|
|
y: number;
|
|
h: number;
|
|
w: number;
|
|
excavation: Excavation | null;
|
|
} {
|
|
if (imageSettings == null) {
|
|
return null;
|
|
}
|
|
|
|
const qrCodeSize = cells.length;
|
|
const defaultSize = Math.floor(size * DEFAULT_IMG_SCALE);
|
|
const scale = qrCodeSize / size;
|
|
const w = (imageSettings.width || defaultSize) * scale;
|
|
const h = (imageSettings.height || defaultSize) * scale;
|
|
|
|
// Center the image in the QR code area (without margins)
|
|
const x =
|
|
imageSettings.x == null ? qrCodeSize / 2 - w / 2 : imageSettings.x * scale;
|
|
const y =
|
|
imageSettings.y == null ? qrCodeSize / 2 - h / 2 : imageSettings.y * scale;
|
|
|
|
let excavation: Excavation | null = null;
|
|
if (imageSettings.excavate) {
|
|
const floorX = Math.floor(x);
|
|
const floorY = Math.floor(y);
|
|
const ceilW = Math.ceil(w + x - floorX);
|
|
const ceilH = Math.ceil(h + y - floorY);
|
|
excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH };
|
|
}
|
|
|
|
return { x, y, h, w, excavation };
|
|
}
|
|
|
|
export function convertImageSettingsToPixels(
|
|
calculatedImageSettings: {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
excavation: Excavation | null;
|
|
},
|
|
size: number,
|
|
numCells: number,
|
|
margin: number,
|
|
) {
|
|
const pixelRatio = size / numCells;
|
|
const imgWidth = calculatedImageSettings.w * pixelRatio;
|
|
const imgHeight = calculatedImageSettings.h * pixelRatio;
|
|
const imgLeft = (calculatedImageSettings.x + margin) * pixelRatio;
|
|
const imgTop = (calculatedImageSettings.y + margin) * pixelRatio;
|
|
|
|
return { imgWidth, imgHeight, imgLeft, imgTop };
|
|
}
|
|
|
|
export function QRCodeSVG(props: QRPropsSVG) {
|
|
const {
|
|
value,
|
|
size = DEFAULT_SIZE,
|
|
level = DEFAULT_LEVEL,
|
|
bgColor = DEFAULT_BGCOLOR,
|
|
fgColor = DEFAULT_FGCOLOR,
|
|
margin = DEFAULT_MARGIN,
|
|
isOGContext = false,
|
|
imageSettings,
|
|
...otherProps
|
|
} = props;
|
|
|
|
const shouldUseHigherErrorLevel =
|
|
isOGContext && imageSettings?.excavate && (level === "L" || level === "M");
|
|
|
|
// Use a higher error correction level 'Q' when excavation is enabled
|
|
// to ensure the QR code remains scannable despite the removed modules.
|
|
const effectiveLevel = shouldUseHigherErrorLevel ? "Q" : level;
|
|
|
|
let cells = qrcodegen.QrCode.encodeText(
|
|
value,
|
|
ERROR_LEVEL_MAP[effectiveLevel],
|
|
).getModules();
|
|
|
|
const numCells = cells.length + margin * 2;
|
|
const calculatedImageSettings = getImageSettings(
|
|
cells,
|
|
size,
|
|
margin,
|
|
imageSettings,
|
|
);
|
|
|
|
let image: null | JSX.Element = null;
|
|
if (imageSettings != null && calculatedImageSettings != null) {
|
|
if (calculatedImageSettings.excavation != null) {
|
|
cells = excavateModules(cells, calculatedImageSettings.excavation);
|
|
}
|
|
|
|
if (isOGContext) {
|
|
const { imgWidth, imgHeight, imgLeft, imgTop } =
|
|
convertImageSettingsToPixels(
|
|
calculatedImageSettings,
|
|
size,
|
|
numCells,
|
|
margin,
|
|
);
|
|
|
|
image = (
|
|
<img
|
|
src={imageSettings.src}
|
|
alt="Logo"
|
|
style={{
|
|
position: "absolute",
|
|
left: `${imgLeft}px`,
|
|
top: `${imgTop}px`,
|
|
width: `${imgWidth}px`,
|
|
height: `${imgHeight}px`,
|
|
}}
|
|
/>
|
|
);
|
|
} else {
|
|
image = (
|
|
<image
|
|
href={imageSettings.src}
|
|
height={calculatedImageSettings.h}
|
|
width={calculatedImageSettings.w}
|
|
x={calculatedImageSettings.x + margin}
|
|
y={calculatedImageSettings.y + margin}
|
|
preserveAspectRatio="none"
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
// Drawing strategy: instead of a rect per module, we're going to create a
|
|
// single path for the dark modules and layer that on top of a light rect,
|
|
// for a total of 2 DOM nodes. We pay a bit more in string concat but that's
|
|
// way faster than DOM ops.
|
|
// For level 1, 441 nodes -> 2
|
|
// For level 40, 31329 -> 2
|
|
const fgPath = generatePath(cells, margin);
|
|
|
|
return (
|
|
<svg
|
|
height={size}
|
|
width={size}
|
|
viewBox={`0 0 ${numCells} ${numCells}`}
|
|
{...otherProps}
|
|
>
|
|
<path
|
|
fill={bgColor}
|
|
d={`M0,0 h${numCells}v${numCells}H0z`}
|
|
shapeRendering="crispEdges"
|
|
/>
|
|
<path fill={fgColor} d={fgPath} shapeRendering="crispEdges" />
|
|
{image}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// For canvas we're going to switch our drawing mode based on whether or not
|
|
// the environment supports Path2D. We only need the constructor to be
|
|
// supported, but Edge doesn't actually support the path (string) type
|
|
// argument. Luckily it also doesn't support the addPath() method. We can
|
|
// treat that as the same thing.
|
|
export const SUPPORTS_PATH2D = (function () {
|
|
try {
|
|
new Path2D().addPath(new Path2D());
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
return true;
|
|
})();
|