diff --git a/.prettierrc b/.prettierrc index 34e457d..b5d90ce 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { "tabWidth": 4, "useTabs": false, - "singleQuote": false + "singleQuote": false, + "printWidth": 120 } diff --git a/src/matrix.ts b/src/matrix.ts index fa6da66..96e0bad 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -391,3 +391,24 @@ export function getMatrix(data: Data) { return matrix.map((row) => row.map((cell) => cell & 1)); } + +export function clearMatrixCenter(matrix: Matrix, widthPct: number, heightPct: number): Matrix { + matrix = matrix.map((x) => x.slice()); // avoid mutating input arg + + // TODO: Here's a homegrown formula, perhaps could be simplified + const mW = matrix.length; + const cW = Math.ceil(((mW * widthPct) / 100 + (mW % 2)) / 2) * 2 - (mW % 2); + const mH = matrix[0]?.length ?? 0; + const cH = Math.ceil(((mH * heightPct) / 100 + (mH % 2)) / 2) * 2 - (mH % 2); + + // Given the formula, these must be whole numbers, but round anyway to account for js EPSILON + const clearStartX = Math.round((mW - cW) / 2); + const clearStartY = Math.round((mH - cH) / 2); + + for (let x = clearStartX; x < clearStartX + cW; x += 1) { + for (let y = clearStartY; y < clearStartY + cH; y += 1) { + matrix[x][y] = 0; + } + } + return matrix; +} diff --git a/src/pdf.ts b/src/pdf.ts index 799bfb1..5bbc0e0 100644 --- a/src/pdf.ts +++ b/src/pdf.ts @@ -3,12 +3,18 @@ import { QR } from "./qr-base.js"; import { ImageOptions, Matrix } from "./typing/types"; import { getOptions, getSVGPath } from "./utils.js"; import colorString from "color-string"; +import { clearMatrixCenter } from "./matrix.js"; const textDec = new TextDecoder(); export async function getPDF(text: string, inOptions: ImageOptions) { const options = getOptions(inOptions); - const matrix = QR(text, options.ec_level, options.parse_url); + + let matrix = QR(text, options.ec_level, options.parse_url); + if (options.logo && options.logoWidth && options.logoHeight) { + matrix = clearMatrixCenter(matrix, options.logoWidth, options.logoHeight); + } + return PDF({ matrix, ...options }); } @@ -17,18 +23,14 @@ function colorToRGB(color: string | number): [number, number, number] { const [red, green, blue] = colorString.get.rgb(color); return [red / 255, green / 255, blue / 255]; } - return [ - ((color >>> 24) % 256) / 255, - ((color >>> 16) % 256) / 255, - ((color >>> 8) % 256) / 255, - ]; + return [((color >>> 24) % 256) / 255, ((color >>> 16) % 256) / 255, ((color >>> 8) % 256) / 255]; } function getOpacity(color: string | number): number { if (typeof color === "string") { return colorString.get.rgb(color)[3]; } - return ((color % 256) / 255); + return (color % 256) / 255; } async function PDF({ @@ -44,15 +46,19 @@ async function PDF({ matrix: Matrix; }) { const size = 9; + const marginPx = margin * size; + const matrixSizePx = matrix.length * size; + const imageSizePx = matrixSizePx + 2 * marginPx; + const document = await PDFDocument.create(); - const pageSize = (matrix.length + 2 * margin) * size; - const page = document.addPage([pageSize, pageSize]); + const page = document.addPage([imageSizePx, imageSizePx]); page.drawSquare({ - size: pageSize, + size: imageSizePx, color: rgb(...colorToRGB(bgColor)), }); page.moveTo(0, page.getHeight()); - const path = getSVGPath(matrix, size, margin * size, borderRadius); + + const path = getSVGPath(matrix, size, marginPx, borderRadius); page.drawSvgPath(path, { color: rgb(...colorToRGB(color)), opacity: getOpacity(color), @@ -67,13 +73,13 @@ async function PDF({ } else { logoData = await document.embedJpg(logo); } + const logoWidthPx = (logoWidth / 100) * matrixSizePx; + const logoHeightPx = (logoHeight / 100) * matrixSizePx; page.drawImage(logoData, { - x: page.getWidth() / 2 - (logoWidth / 100) * (page.getWidth() / 2), - y: - page.getHeight() / 2 - - (logoHeight / 100) * (page.getWidth() / 2), - width: (logoWidth / 100) * page.getWidth(), - height: (logoHeight / 100) * page.getHeight(), + x: (imageSizePx - logoWidthPx) / 2, + y: (imageSizePx - logoHeightPx) / 2, + width: logoWidthPx, + height: logoHeightPx, }); } return document.save(); diff --git a/src/png.ts b/src/png.ts index 6410e56..b244d8e 100644 --- a/src/png.ts +++ b/src/png.ts @@ -1,13 +1,19 @@ import { ImageOptions, Matrix } from "./typing/types"; import { QR } from "./qr-base.js"; -import { createSVG } from './svg.js'; +import { createSVG } from "./svg.js"; import { getOptions } from "./utils.js"; import sharp from "sharp"; +import { clearMatrixCenter } from "./matrix.js"; export async function getPNG(text: string, inOptions: ImageOptions = {}) { - const options = getOptions({...inOptions, type: 'png'}); - const matrix = QR(text, options.ec_level, options.parse_url); - return generateImage({ matrix, ...options, type: 'png' }); + const options = getOptions({ ...inOptions, type: "png" }); + + let matrix = QR(text, options.ec_level, options.parse_url); + if (options.logo && options.logoWidth && options.logoHeight) { + matrix = clearMatrixCenter(matrix, options.logoWidth, options.logoHeight); + } + + return generateImage({ matrix, ...options, type: "png" }); } export async function generateImage({ @@ -22,27 +28,41 @@ export async function generateImage({ borderRadius, }: ImageOptions & { matrix: Matrix }) { const marginPx = margin * size; - const imageSize = matrix.length * size + marginPx * 2; + const matrixSizePx = matrix.length * size; + const imageSizePx = matrixSizePx + marginPx * 2; + if (size > 200) { - throw new Error('Module size is too big, resulting image is too large: ' + imageSize); + throw new Error("Module size is too big, resulting image is too large: " + imageSizePx); } + const svg = await createSVG({ - matrix, size, margin, color, bgColor, - imageWidth: imageSize, imageHeight: imageSize, + matrix, + size, + margin, + color, + bgColor, + imageWidth: imageSizePx, + imageHeight: imageSizePx, + logoWidth: logo && logoWidth, + logoHeight: logo && logoHeight, borderRadius, }); const qrImage = sharp(svg); const layers: sharp.OverlayOptions[] = []; if (logo) { - const sharpLogo = sharp(logo).resize(Math.round(imageSize * logoWidth / 100), Math.round(imageSize * logoHeight / 100), {fit: 'contain'}); - const data = await sharpLogo.toBuffer() + const sharpLogo = sharp(logo).resize( + Math.round((matrixSizePx * logoWidth) / 100), + Math.round((matrixSizePx * logoHeight) / 100), + { fit: "contain" } + ); + const data = await sharpLogo.toBuffer(); layers.push({ input: data, - }) + }); qrImage.composite(layers); } - const { data } = await qrImage.png({ - palette: !logo, // no logo results in much less colors - }).toBuffer({ resolveWithObject: true}); + const { data } = await qrImage + .png({ palette: !logo }) // no logo results in much less colors + .toBuffer({ resolveWithObject: true }); return new Uint8ClampedArray(data.buffer); } diff --git a/src/png_browser.ts b/src/png_browser.ts index 0a31dfe..691d007 100644 --- a/src/png_browser.ts +++ b/src/png_browser.ts @@ -1,23 +1,31 @@ import { QR } from "./qr-base.js"; -import { getOptions, colorToHex, getSVGPath } from "./utils.js"; +import { colorToHex, getOptions, getSVGPath } from "./utils.js"; import { ImageOptions, Matrix } from "./typing/types"; import { Base64 } from "js-base64"; +import { clearMatrixCenter } from "./matrix.js"; export async function getPNG(text: string, inOptions: ImageOptions) { const options = getOptions(inOptions); - const matrix = QR(text, options.ec_level, options.parse_url); - return generateImage({ matrix, ...options, type: 'png' }); + + let matrix = QR(text, options.ec_level, options.parse_url); + if (options.logo && options.logoWidth && options.logoHeight) { + matrix = clearMatrixCenter(matrix, options.logoWidth, options.logoHeight); + } + + return generateImage({ matrix, ...options, type: "png" }); } function dataURItoArrayBuffer(dataURI: string) { - return Base64.toUint8Array(dataURI.split(',')[1]); + return Base64.toUint8Array(dataURI.split(",")[1]); } function blobToDataURL(blob: Blob) { return new Promise((resolve, reject) => { try { var a = new FileReader(); - a.onload = function(e) {resolve(e.target.result as string);} + a.onload = function (e) { + resolve(e.target.result as string); + }; a.onerror = reject; a.readAsDataURL(blob); } catch (e) { @@ -26,7 +34,6 @@ function blobToDataURL(blob: Blob) { }); } - export async function generateImage({ matrix, size, @@ -39,14 +46,16 @@ export async function generateImage({ borderRadius, }: ImageOptions & { matrix: Matrix }) { const marginPx = margin * size; - const imageSize = matrix.length * size + marginPx * 2; + const matrixSizePx = matrix.length * size; + const imageSizePx = matrixSizePx + marginPx * 2; - const canvas = document.createElement('canvas'); - canvas.width = imageSize; - canvas.height = imageSize; - const context = canvas.getContext('2d'); + const canvas = document.createElement("canvas"); + canvas.width = imageSizePx; + canvas.height = imageSizePx; + const context = canvas.getContext("2d"); context.fillStyle = colorToHex(bgColor); - context.fillRect(0, 0, imageSize, imageSize); + context.fillRect(0, 0, imageSizePx, imageSizePx); + const path = new Path2D(getSVGPath(matrix, size, marginPx, borderRadius)); context.fillStyle = colorToHex(color); context.fill(path); @@ -61,13 +70,15 @@ export async function generateImage({ reject(e); } }); + const logoWidthPx = (logoWidth / 100) * matrixSizePx; + const logoHeightPx = (logoHeight / 100) * matrixSizePx; context.drawImage( logoImage, - imageSize / 2 - (logoWidth / 2 / 100) * imageSize, - imageSize / 2 - (logoHeight / 2 / 100) * imageSize, - (logoWidth / 100) * imageSize, - (logoHeight / 100) * imageSize + (imageSizePx - logoWidthPx) / 2, + (imageSizePx - logoHeightPx) / 2, + logoWidthPx, + logoHeightPx ); } - return dataURItoArrayBuffer(canvas.toDataURL('image/png')); + return dataURItoArrayBuffer(canvas.toDataURL("image/png")); } diff --git a/src/svg.ts b/src/svg.ts index 9e70960..e248b4a 100644 --- a/src/svg.ts +++ b/src/svg.ts @@ -1,16 +1,21 @@ +import { clearMatrixCenter } from "./matrix.js"; import { QR } from "./qr-base.js"; import { ImageOptions, Matrix } from "./typing/types"; -import { getOptions, colorToHex, getSVGPath } from "./utils.js"; -import { Base64 } from 'js-base64'; +import { colorToHex, getOptions, getSVGPath } from "./utils.js"; +import { Base64 } from "js-base64"; -interface FillSVGOptions - extends Pick { +interface FillSVGOptions extends Pick { blockSize?: number; } export async function getSVG(text: string, inOptions: ImageOptions = {}) { - const options = getOptions({...inOptions, type: "svg"}); - const matrix = QR(text, options.ec_level, options.parse_url); + const options = getOptions({ ...inOptions, type: "svg" }); + + let matrix = QR(text, options.ec_level, options.parse_url); + if (options.logo && options.logoWidth && options.logoHeight) { + matrix = clearMatrixCenter(matrix, options.logoWidth, options.logoHeight); + } + return createSVG({ matrix, ...options }); } @@ -33,53 +38,56 @@ export async function createSVG({ imageWidth?: number; imageHeight?: number; }): Promise { - const actualSize = size || 9; - const X = matrix.length + 2 * margin; - const XY = X * (actualSize || 1); + const actualBlockSize = size || 9; + const matrixSizePx = matrix.length * actualBlockSize; + const marginPx = margin * actualBlockSize; + const imageSizePx = matrixSizePx + 2 * marginPx; const imageWidthStr = imageWidth ? ` width="${imageWidth}"` : ""; - const imageHeightStr = imageHeight ? `height="${imageWidth}" ` : ""; + const imageHeightStr = imageHeight ? ` height="${imageWidth}"` : ""; const xmlTag = ``; - const svgOpeningTag = ``; + const svgOpeningTag = ``; + const svgBody = getSVGBody(matrix, { color, bgColor, - size: XY, + size: imageSizePx, margin, - blockSize: actualSize, + blockSize: actualBlockSize, borderRadius, }); const svgEndTag = ""; - const logoImage = logo ? getLogoImage(logo, XY, logoWidth, logoHeight) : ""; + const logoImage = logo ? getLogoImage(logo, marginPx, matrixSizePx, logoWidth, logoHeight) : ""; - return te.encode( - xmlTag + svgOpeningTag + svgBody + logoImage + svgEndTag - ); + return te.encode(xmlTag + svgOpeningTag + svgBody + logoImage + svgEndTag); } function getSVGBody(matrix: Matrix, options: FillSVGOptions): string { const path = getSVGPath(matrix, options.blockSize, options.margin * options.blockSize, options.borderRadius); - let svgBody = - ``; - svgBody += ''; + let svgBody = ``; + svgBody += ``; return svgBody; } function getLogoImage( logo: ImageOptions["logo"], - XY: number, + marginPx: number, + matrixSizePx: number, logoWidth: ImageOptions["logoWidth"], logoHeight: ImageOptions["logoHeight"] ): string { const imageBase64 = `data:image/png;base64,${Base64.fromUint8Array(new Uint8Array(logo))}`; + const logoWidthPx = (logoWidth / 100) * matrixSizePx; + const logoHeightPx = (logoHeight / 100) * matrixSizePx; return ( `` + + `x="${marginPx + (matrixSizePx - logoWidthPx) / 2}" ` + + `y="${marginPx + (matrixSizePx - logoHeightPx) / 2}">` + `` ); } diff --git a/src/utils.ts b/src/utils.ts index 8c1b47e..6c83607 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,24 +25,24 @@ export function getSVGPath(matrix: Matrix, size: number, margin: number = 0, bor const topY = y * size + margin; const bottomY = (y + 1) * size + margin; const rectangle = []; - rectangle.push(`M ${leftX} ${topY + borderRadius}`) - rectangle.push(`L ${leftX} ${bottomY - borderRadius}`) + rectangle.push(`M ${leftX} ${topY + borderRadius}`); + rectangle.push(`L ${leftX} ${bottomY - borderRadius}`); if (borderRadius > 0) { - rectangle.push(`A ${borderRadius} ${borderRadius} 0 0 0 ${leftX + borderRadius} ${bottomY} `) + rectangle.push(`A ${borderRadius} ${borderRadius} 0 0 0 ${leftX + borderRadius} ${bottomY}`); } - rectangle.push(`L ${rightX - borderRadius} ${bottomY}`) + rectangle.push(`L ${rightX - borderRadius} ${bottomY}`); if (borderRadius > 0) { - rectangle.push(`A ${borderRadius} ${borderRadius} 0 0 0 ${rightX} ${bottomY - borderRadius}`) + rectangle.push(`A ${borderRadius} ${borderRadius} 0 0 0 ${rightX} ${bottomY - borderRadius}`); } - rectangle.push(`L ${rightX} ${topY + borderRadius}`) + rectangle.push(`L ${rightX} ${topY + borderRadius}`); if (borderRadius > 0) { - rectangle.push(`A ${borderRadius} ${borderRadius} 0 0 0 ${rightX - borderRadius} ${topY}`) + rectangle.push(`A ${borderRadius} ${borderRadius} 0 0 0 ${rightX - borderRadius} ${topY}`); } - rectangle.push(`L ${leftX + borderRadius} ${topY}`) + rectangle.push(`L ${leftX + borderRadius} ${topY}`); if (borderRadius > 0) { - rectangle.push(`A ${borderRadius} ${borderRadius} 0 0 0 ${leftX} ${topY + borderRadius}`) + rectangle.push(`A ${borderRadius} ${borderRadius} 0 0 0 ${leftX} ${topY + borderRadius}`); } - rectangle.push(`z`) + rectangle.push(`z`); rectangles.push(rectangle.join(" ")); } } diff --git a/test_data/golden/browser_qr_logo_arraybuffer.pdf b/test_data/golden/browser_qr_logo_arraybuffer.pdf index 98e75ab..562ddf3 100644 Binary files a/test_data/golden/browser_qr_logo_arraybuffer.pdf and b/test_data/golden/browser_qr_logo_arraybuffer.pdf differ diff --git a/test_data/golden/browser_qr_with_border_radius.svg b/test_data/golden/browser_qr_with_border_radius.svg index 18b2c77..689988a 100644 --- a/test_data/golden/browser_qr_with_border_radius.svg +++ b/test_data/golden/browser_qr_with_border_radius.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test_data/golden/browser_qr_with_logo.png b/test_data/golden/browser_qr_with_logo.png index 2130aa2..1402e88 100644 Binary files a/test_data/golden/browser_qr_with_logo.png and b/test_data/golden/browser_qr_with_logo.png differ diff --git a/test_data/golden/browser_qr_with_logo_as_arraybuffer.svg b/test_data/golden/browser_qr_with_logo_as_arraybuffer.svg index 926310f..31b9276 100644 --- a/test_data/golden/browser_qr_with_logo_as_arraybuffer.svg +++ b/test_data/golden/browser_qr_with_logo_as_arraybuffer.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test_data/golden/browser_qr_with_logo_as_arraybuffer_jpg.svg b/test_data/golden/browser_qr_with_logo_as_arraybuffer_jpg.svg index c116a1f..6f00f54 100644 --- a/test_data/golden/browser_qr_with_logo_as_arraybuffer_jpg.svg +++ b/test_data/golden/browser_qr_with_logo_as_arraybuffer_jpg.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test_data/golden/browser_qr_with_logo_jpg.png b/test_data/golden/browser_qr_with_logo_jpg.png index 4ec7593..8753ec7 100644 Binary files a/test_data/golden/browser_qr_with_logo_jpg.png and b/test_data/golden/browser_qr_with_logo_jpg.png differ diff --git a/test_data/golden/qr_logo_arraybuffer.pdf b/test_data/golden/qr_logo_arraybuffer.pdf index ad41297..558c711 100644 Binary files a/test_data/golden/qr_logo_arraybuffer.pdf and b/test_data/golden/qr_logo_arraybuffer.pdf differ diff --git a/test_data/golden/qr_logo_arraybuffer_jpg.pdf b/test_data/golden/qr_logo_arraybuffer_jpg.pdf index 98e75ab..562ddf3 100644 Binary files a/test_data/golden/qr_logo_arraybuffer_jpg.pdf and b/test_data/golden/qr_logo_arraybuffer_jpg.pdf differ diff --git a/test_data/golden/qr_with_border_radius.svg b/test_data/golden/qr_with_border_radius.svg index d00805d..45abe5a 100644 --- a/test_data/golden/qr_with_border_radius.svg +++ b/test_data/golden/qr_with_border_radius.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test_data/golden/qr_with_logo.pdf b/test_data/golden/qr_with_logo.pdf index ad41297..558c711 100644 Binary files a/test_data/golden/qr_with_logo.pdf and b/test_data/golden/qr_with_logo.pdf differ diff --git a/test_data/golden/qr_with_logo.png b/test_data/golden/qr_with_logo.png index 2edf458..e451a46 100644 Binary files a/test_data/golden/qr_with_logo.png and b/test_data/golden/qr_with_logo.png differ diff --git a/test_data/golden/qr_with_logo.svg b/test_data/golden/qr_with_logo.svg index 926310f..31b9276 100644 --- a/test_data/golden/qr_with_logo.svg +++ b/test_data/golden/qr_with_logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test_data/golden/qr_with_logo_as_arraybuffer.svg b/test_data/golden/qr_with_logo_as_arraybuffer.svg index 926310f..31b9276 100644 --- a/test_data/golden/qr_with_logo_as_arraybuffer.svg +++ b/test_data/golden/qr_with_logo_as_arraybuffer.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test_data/golden/qr_with_logo_as_arraybuffer_jpg.svg b/test_data/golden/qr_with_logo_as_arraybuffer_jpg.svg index c116a1f..6f00f54 100644 --- a/test_data/golden/qr_with_logo_as_arraybuffer_jpg.svg +++ b/test_data/golden/qr_with_logo_as_arraybuffer_jpg.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test_data/golden/qr_with_logo_jpg.png b/test_data/golden/qr_with_logo_jpg.png index 927a0ce..a92ff38 100644 Binary files a/test_data/golden/qr_with_logo_jpg.png and b/test_data/golden/qr_with_logo_jpg.png differ