Files
sousa-gecko/browser/components/qrcode/QRCodeWorker.worker.mjs
T

325 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// Now load the QRCode library with the full resource URI
import { QR } from "moz-src:///toolkit/components/qrcode/encoder.mjs";
import { PromiseWorker } from "resource://gre/modules/workers/PromiseWorker.mjs";
// Per ISO/IEC 18004, finder patterns are always 7x7 modules.
const FINDER_SIZE = 7;
const CELL_SIZE = 20;
// Per ISO/IEC 18004, the minimum quiet zone around the code is 4 modules.
const MARGIN_CELLS = 4;
// Dot radius as a fraction of cell size. 0.4 means dots are 80% of cell width,
// leaving a visible gap between adjacent dots.
const DOT_RADIUS_FACTOR = 0.4;
// Corner radius factors for finder pattern rounded rectangles (design choices).
const FINDER_OUTER_CORNER_RADIUS_FACTOR = 1.2;
const FINDER_INNER_CORNER_RADIUS_FACTOR = 0.6;
// Minimum logo size in QR modules - below this the logo is too small to recognize.
const MIN_LOGO_MODULE_SPAN = 6;
// Maximum logo size in QR modules - keeps the logo within the M-level error correction budget.
const MAX_LOGO_MODULE_SPAN = 8;
/**
* QRCode Worker Implementation
*
* This worker handles QR code generation off the main thread.
*/
class QRCodeWorkerImpl {
constructor() {
this.#connectToPromiseWorker();
}
/**
* @returns {number} Pixel margin around the QR code.
*/
#getMargin() {
return MARGIN_CELLS * CELL_SIZE;
}
/**
* @param {number} dotCount - Number of modules per side.
* @param {number} [margin] - Pixel margin; defaults to #getMargin().
* @returns {number} Canvas side length in pixels.
*/
#getCanvasSize(dotCount, margin = this.#getMargin()) {
return dotCount * CELL_SIZE + margin * 2;
}
/**
* Returns the top-left [row, col] of each of the three finder patterns.
*
* @param {number} dotCount
* @returns {number[][]}
*/
#getFinderPatternOrigins(dotCount) {
return [
[0, 0],
[0, dotCount - FINDER_SIZE],
[dotCount - FINDER_SIZE, 0],
];
}
/**
* Calls callback(dotX, dotY) for every dark module that should be drawn as a
* dot — skipping finder-pattern corners and any module suppressed by the logo.
*
* @param {boolean[][]} matrix
* @param {object} placement - Logo placement descriptor from getLogoPlacement.
* @param {number} margin - Pixel margin.
* @param {Function} callback
*/
#forEachVisibleDarkModule(matrix, placement, margin, callback) {
const dotCount = matrix.length;
const isInFinderPatternCorners = (row, col) =>
(row < FINDER_SIZE && col < FINDER_SIZE) ||
(row < FINDER_SIZE && col >= dotCount - FINDER_SIZE) ||
(row >= dotCount - FINDER_SIZE && col < FINDER_SIZE);
for (let row = 0; row < dotCount; row++) {
for (let col = 0; col < dotCount; col++) {
if (isInFinderPatternCorners(row, col) || !matrix[row][col]) {
continue;
}
const dotX = margin + (col + 0.5) * CELL_SIZE;
const dotY = margin + (row + 0.5) * CELL_SIZE;
const offsetX = dotX - placement.centerX;
const offsetY = dotY - placement.centerY;
if (
placement.showLogo &&
Math.hypot(offsetX, offsetY) <
placement.clearRadius + CELL_SIZE * DOT_RADIUS_FACTOR
) {
continue;
}
callback(dotX, dotY);
}
}
}
/**
* Draws a single rounded-rectangle finder pattern at canvas position (x, y).
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} x - Left edge in pixels.
* @param {number} y - Top edge in pixels.
*/
#drawFinderPattern(ctx, x, y) {
// Finder pattern: 7×7 outer square, 5×5 white ring (inset 1 module each side),
// 3×3 black center (inset 1 more module each side).
const outerSize = FINDER_SIZE * CELL_SIZE;
const ringSize = (FINDER_SIZE - 2) * CELL_SIZE;
const centerSize = (FINDER_SIZE - 4) * CELL_SIZE;
const outerR = CELL_SIZE * FINDER_OUTER_CORNER_RADIUS_FACTOR;
const innerR = CELL_SIZE * FINDER_INNER_CORNER_RADIUS_FACTOR;
ctx.fillStyle = "black";
ctx.beginPath();
ctx.roundRect(x, y, outerSize, outerSize, outerR);
ctx.fill();
ctx.fillStyle = "white";
ctx.beginPath();
ctx.roundRect(x + CELL_SIZE, y + CELL_SIZE, ringSize, ringSize, innerR);
ctx.fill();
ctx.fillStyle = "black";
ctx.beginPath();
ctx.roundRect(
x + 2 * CELL_SIZE,
y + 2 * CELL_SIZE,
centerSize,
centerSize,
innerR
);
ctx.fill();
}
/**
* Fills ctx with the white background, draws all data dots, and draws the
* three finder patterns.
*
* @param {OffscreenCanvasRenderingContext2D} ctx
* @param {boolean[][]} matrix
* @param {object} placement - Logo placement descriptor from getLogoPlacement.
* @param {number} [margin]
*/
#drawQRBodyToCanvas(ctx, matrix, placement, margin = this.#getMargin()) {
const dotCount = matrix.length;
const canvasSize = this.#getCanvasSize(dotCount, margin);
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvasSize, canvasSize);
ctx.fillStyle = "black";
this.#forEachVisibleDarkModule(matrix, placement, margin, (dotX, dotY) => {
ctx.beginPath();
ctx.arc(dotX, dotY, CELL_SIZE * DOT_RADIUS_FACTOR, 0, Math.PI * 2);
ctx.fill();
});
for (const [startRow, startCol] of this.#getFinderPatternOrigins(
dotCount
)) {
const x = margin + startCol * CELL_SIZE;
const y = margin + startRow * CELL_SIZE;
this.#drawFinderPattern(ctx, x, y);
}
}
/**
* Returns the preferred logo size capped at MAX_LOGO_MODULE_SPAN modules.
*
* @param {number} canvasSize - Canvas side length in pixels.
* @returns {number} Logo size in pixels.
*/
#getPreferredLogoSize(canvasSize) {
// 18% of canvas width is a design choice: large enough to be recognizable
// without exceeding the M-level error correction budget.
const desiredLogoSize = Math.round(canvasSize * 0.18);
return Math.min(desiredLogoSize, MAX_LOGO_MODULE_SPAN * CELL_SIZE);
}
/**
* @param {string} url
* @returns {{ matrix: boolean[][], dotCount: number }}
*/
generateQRMatrix(url) {
if (!QR || !QR.encodeToMatrix) {
throw new Error("QRCode library not available in worker");
}
const { matrix, dotCount } = QR.encodeToMatrix(url, "M");
return { matrix, dotCount };
}
/**
* Returns the logo placement, always centered on the canvas. Modules under
* the logo are cleared at draw time; M-level error correction absorbs the
* loss.
*
* @param {number} dotCount
* @param {number} [margin]
* @returns {object} Logo placement descriptor.
*/
getLogoPlacement(dotCount, margin = this.#getMargin()) {
const canvasSize = this.#getCanvasSize(dotCount, margin);
const preferredLogoSize = this.#getPreferredLogoSize(canvasSize);
const minimumLogoSize = MIN_LOGO_MODULE_SPAN * CELL_SIZE;
const logoSize = Math.max(preferredLogoSize, minimumLogoSize);
return {
centerX: canvasSize / 2,
centerY: canvasSize / 2,
clearRadius: logoSize / 2,
logoSize,
showLogo: true,
};
}
/**
* Generate a complete QR code PNG with the Firefox logo composited in the
* worker. Returns a data URI ready for display or saving.
*
* Defaults to M-level error correction (matching Chrome): denser modules
* than H, with enough headroom for the centered logo. Falls back to L for
* URLs that don't fit at M, hiding the logo since L's ~7% error-correction
* budget is too narrow to spare.
*
* @param {string} url
* @param {boolean} [showLogo=true]
* @returns {Promise<string>} data:image/png;base64,... URI
*/
async generateFullQRCode(url, showLogo = true) {
let matrix, dotCount, ecLevel;
for (const level of ["M", "L"]) {
try {
({ matrix, dotCount } = QR.encodeToMatrix(url, level));
ecLevel = level;
break;
} catch (e) {
if (level === "L") {
throw e;
}
}
}
const margin = this.#getMargin();
const placement = this.getLogoPlacement(dotCount, margin);
if (ecLevel !== "M" || !showLogo) {
placement.showLogo = false;
}
const size = this.#getCanvasSize(dotCount, margin);
const canvas = new OffscreenCanvas(size, size);
const ctx = canvas.getContext("2d");
this.#drawQRBodyToCanvas(ctx, matrix, placement, margin);
if (placement.showLogo) {
try {
const response = await fetch(
"chrome://branding/content/about-logo@2x.png"
);
if (!response.ok) {
throw new Error(`Logo fetch failed: ${response.status}`);
}
const blob = await response.blob();
const logoSize = Math.round(placement.logoSize);
const logoBitmap = await globalThis.createImageBitmap(blob, {
resizeWidth: logoSize,
resizeHeight: logoSize,
resizeQuality: "high",
});
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(
logoBitmap,
placement.centerX - placement.logoSize / 2,
placement.centerY - placement.logoSize / 2,
placement.logoSize,
placement.logoSize
);
logoBitmap.close();
} catch (e) {
// Workers don't have access to the main-thread lazy.logConsole, so
// log directly via console.
console.warn("Failed to load Firefox logo for QR code:", e);
}
}
const pngBlob = await canvas.convertToBlob({ type: "image/png" });
const arrayBuffer = await pngBlob.arrayBuffer();
const base64 = new Uint8Array(arrayBuffer).toBase64();
return `data:image/png;base64,${base64}`;
}
/**
* Glue code to connect the `QRCodeWorkerImpl` to the PromiseWorker interface.
*/
#connectToPromiseWorker() {
const worker = new PromiseWorker.AbstractWorker();
worker.dispatch = (method, args = []) => {
if (!this[method]) {
throw new Error("Method does not exist: " + method);
}
return this[method](...args);
};
worker.close = () => self.close();
worker.postMessage = (message, ...transfers) => {
self.postMessage(message, ...transfers);
};
self.addEventListener("message", msg => worker.handleMessage(msg));
self.addEventListener("unhandledrejection", function (error) {
throw error.reason;
});
}
}
// Create the worker instance
new QRCodeWorkerImpl();