UNPKG

miijs

Version:

The most complete and easy to use Mii library available.

431 lines (366 loc) 14.8 kB
import jsQR from "jsqr"; import QRCodeStyling from "qr-code-styling"; import { Buffer } from "./platform.js"; import { decodeMii, encodeMii, detectMiiFormat } from "./miiProcess.js"; import { MiiFormats } from "./formats.js"; const ONE_BY_ONE_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="; const isBrowser = typeof window !== "undefined" && typeof document !== "undefined"; const importNodeOnlyModule = isBrowser ? null : new Function("specifier", "return import(specifier)"); let miiRenderingPromise; async function renderMii(data, opts) { miiRenderingPromise ??= import("./miiRendering.js"); return (await miiRenderingPromise).renderMii(data, opts); } async function loadNodeOnlyModule(specifier) { if (!importNodeOnlyModule) { throw new Error(`${specifier} is unavailable in the browser build`); } // Keep Node-only image dependencies out of browser bundle graphs. return importNodeOnlyModule(specifier); } function isWebp(buf) { return ( Buffer.isBuffer(buf) && buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP" ); } function clampByte(n) { if (n < 0) return 0; if (n > 255) return 255; return n | 0; } function toGrayscaleContrast(rgba, contrast = 1) { const out = new Uint8ClampedArray(rgba.length); for (let i = 0; i < rgba.length; i += 4) { const lum = rgba[i] * 0.299 + rgba[i + 1] * 0.587 + rgba[i + 2] * 0.114; const adjusted = clampByte((lum - 128) * contrast + 128); out[i] = adjusted; out[i + 1] = adjusted; out[i + 2] = adjusted; out[i + 3] = 255; } return out; } function toBinaryThreshold(rgba, threshold = 128) { const out = new Uint8ClampedArray(rgba.length); for (let i = 0; i < rgba.length; i += 4) { const lum = rgba[i] * 0.299 + rgba[i + 1] * 0.587 + rgba[i + 2] * 0.114; const value = lum < threshold ? 0 : 255; out[i] = value; out[i + 1] = value; out[i + 2] = value; out[i + 3] = 255; } return out; } function resizeNearest(rgba, width, height, scale) { const outWidth = Math.max(1, Math.round(width * scale)); const outHeight = Math.max(1, Math.round(height * scale)); if (outWidth === width && outHeight === height) { return { width, height, rgba }; } const out = new Uint8ClampedArray(outWidth * outHeight * 4); for (let y = 0; y < outHeight; y++) { const srcY = Math.min(height - 1, Math.floor(y / scale)); for (let x = 0; x < outWidth; x++) { const srcX = Math.min(width - 1, Math.floor(x / scale)); const srcI = (srcY * width + srcX) * 4; const outI = (y * outWidth + x) * 4; out[outI] = rgba[srcI]; out[outI + 1] = rgba[srcI + 1]; out[outI + 2] = rgba[srcI + 2]; out[outI + 3] = rgba[srcI + 3]; } } return { width: outWidth, height: outHeight, rgba: out }; } function decodeWithJsQrVariants(rgba, width, height) { const variants = []; variants.push({ width, height, rgba }); variants.push({ width, height, rgba: toGrayscaleContrast(rgba, 1.4) }); variants.push({ width, height, rgba: toGrayscaleContrast(rgba, 1.9) }); for (const threshold of [112, 128, 144]) { variants.push({ width, height, rgba: toBinaryThreshold(rgba, threshold) }); } if (width * height <= 700 * 700) { const upscaled = resizeNearest(rgba, width, height, 2); variants.push(upscaled); variants.push({ width: upscaled.width, height: upscaled.height, rgba: toGrayscaleContrast(upscaled.rgba, 1.4) }); variants.push({ width: upscaled.width, height: upscaled.height, rgba: toBinaryThreshold(upscaled.rgba, 128) }); } const decodeOptions = [ { inversionAttempts: "attemptBoth" }, { inversionAttempts: "dontInvert" }, { inversionAttempts: "onlyInvert" }, ]; for (const variant of variants) { for (const opts of decodeOptions) { let decoded = null; try { decoded = jsQR(variant.rgba, variant.width, variant.height, opts); } catch { continue; } if (decoded?.binaryData?.length > 0) return decoded; } } return null; } function bytesToLatin1String(bytes) { if (Buffer.isBuffer(bytes)) return bytes.toString("latin1"); const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let s = ""; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return s; } async function toUint8(input) { if (input == null) throw new TypeError("Expected image bytes or Blob"); if (Buffer.isBuffer(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); if (input instanceof Uint8Array) return input; if (input instanceof ArrayBuffer) return new Uint8Array(input); if (isBrowser && input instanceof Blob) return new Uint8Array(await input.arrayBuffer()); throw new TypeError("Unsupported input type"); } // --- Browser image decode: bytes/blob -> { width, height, rgba(Uint8ClampedArray) } --- async function decodeImageToRGBA_Browser(bytesOrBlob) { const blob = bytesOrBlob instanceof Blob ? bytesOrBlob : new Blob([await toUint8(bytesOrBlob)]); const bmp = await createImageBitmap(blob); const canvas = document.createElement("canvas"); canvas.width = bmp.width; canvas.height = bmp.height; const ctx = canvas.getContext("2d"); ctx.drawImage(bmp, 0, 0); const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); return { width: canvas.width, height: canvas.height, rgba: imgData.data }; } // --- Node image decode: keep your existing PNG/JPEG path but load deps dynamically --- async function decodeImageToRGBA_Node(buf) { const [{ PNG }, jpeg, isPng, isJpg] = await Promise.all([ loadNodeOnlyModule("pngjs"), loadNodeOnlyModule("jpeg-js"), loadNodeOnlyModule("is-png"), loadNodeOnlyModule("is-jpg"), ]); let width, height, rgba; if (isPng.default(buf)) { const png = PNG.sync.read(buf); width = png.width; height = png.height; rgba = png.data; } else if (isJpg.default(buf)) { const jpg = jpeg.default.decode(buf, { useTArray: true }); width = jpg.width; height = jpg.height; rgba = jpg.data; } else if (isWebp(buf)) { try { const { createCanvas, loadImage } = await loadNodeOnlyModule("canvas"); const img = await loadImage(buf); const canvas = createCanvas(img.width, img.height); const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0); const imgData = ctx.getImageData(0, 0, img.width, img.height); width = img.width; height = img.height; rgba = imgData.data; } catch { // Some canvas builds lack WEBP decode support; sharp provides a reliable fallback. const sharpMod = await loadNodeOnlyModule("sharp"); const sharp = sharpMod.default; const raw = await sharp(buf).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); width = raw.info.width; height = raw.info.height; rgba = raw.data; } } else { throw new Error("Unsupported image format"); } const data = new Uint8ClampedArray(rgba.buffer, rgba.byteOffset, rgba.byteLength); return { width, height, rgba: data }; } async function scanQR(input) { try { let decoded; if (isBrowser) { const { width, height, rgba } = await decodeImageToRGBA_Browser(input); decoded = decodeWithJsQrVariants(rgba, width, height); } else { const buf = Buffer.isBuffer(input) ? input : Buffer.from(await toUint8(input)); const { width, height, rgba } = await decodeImageToRGBA_Node(buf); decoded = decodeWithJsQrVariants(rgba, width, height); } if (!decoded) throw new Error("QR code not found"); return Buffer.from(decoded.binaryData); } catch (e) { return null; } } async function makeQR(input, opts = {}) { let { size = 512, margin = 0, prefix = "", encoding, overlayFrac = 0.3, qrOptions, dotsOptions, cornersSquareOptions, cornersDotOptions, backgroundOptions, image, noRenderMii = false, label } = opts; let isSpecial = false; if (input == null) throw new TypeError("makeQR(input): input is required"); let dataStr = ""; // Resolve input -> dataStr + overlay if (typeof input === "string") { dataStr = input; } else if (typeof input === "object" && input?.general?.hasOwnProperty("favoriteColor")) { if (!image && !noRenderMii) { const overlayPng = await renderMii(input, opts); image = Buffer.isBuffer(overlayPng) ? overlayPng : Buffer.from(overlayPng); } if (!label && input?.meta?.name?.length > 0) label = input.meta.name; if (input?.meta?.type === "Special") isSpecial = true; const encoded = encodeMii(input, MiiFormats.FFED); dataStr = bytesToLatin1String(encoded); } else if (Buffer.isBuffer(input) || input instanceof Uint8Array) { if (!image && !noRenderMii && detectMiiFormat(input)?.length > 0) { try { const overlayPng = await renderMii(input, opts); image = Buffer.isBuffer(overlayPng) ? overlayPng : Buffer.from(overlayPng); const temp = await decodeMii(input); if (!label && temp?.meta?.name?.length > 0) label = temp.meta.name; if (temp?.meta?.type === "Special") isSpecial = true; } catch (e) { console.log(e); image = null; } } dataStr = bytesToLatin1String(input); } else { throw new TypeError("input must be string | Buffer | Uint8Array | Mii JSON"); } if (encoding && typeof input !== "string") { dataStr = encoding === "latin1" ? bytesToLatin1String(input) : Buffer.from(input).toString(encoding); } if (prefix) dataStr = prefix + dataStr; const hasOverlay = !!image; // Build QRCodeStyling config. Node needs jsdom/nodeCanvas; browser must omit them. const baseConfig = { type: "canvas", data: dataStr, width: size, height: size, margin, dotsOptions: { color: "#000000", type: "square", ...(dotsOptions ?? {}) }, backgroundOptions: { color: "#ffffff", ...(backgroundOptions ?? {}) }, image: hasOverlay ? ONE_BY_ONE_GIF : undefined, imageOptions: { saveAsBlob: false, hideBackgroundDots: hasOverlay, imageSize: 0.4, crossOrigin: "anonymous", margin: 0, }, qrOptions: { errorCorrectionLevel: "H", ...(qrOptions ?? {}) }, ...(cornersSquareOptions ? { cornersSquareOptions: { ...cornersSquareOptions } } : {}), ...(cornersDotOptions ? { cornersDotOptions: { ...cornersDotOptions } } : {}), }; if (!isBrowser) { const { JSDOM } = await loadNodeOnlyModule("jsdom"); const nodeCanvas = await loadNodeOnlyModule("canvas"); baseConfig.jsdom = JSDOM; baseConfig.nodeCanvas = nodeCanvas; } const qr = new QRCodeStyling(baseConfig); // Get QR PNG bytes let qrBytes; if (isBrowser) { const blob = await qr.getRawData("png"); // Blob in browser qrBytes = new Uint8Array(await blob.arrayBuffer()); } else { qrBytes = Buffer.from(await qr.getRawData("png")); // Buffer-ish in node } if (!hasOverlay) return Buffer.from(qrBytes); // Composite overlay onto QR in current environment if (isBrowser) { // Browser canvas compositing const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); const qrBmp = await createImageBitmap(new Blob([qrBytes], { type: "image/png" })); ctx.drawImage(qrBmp, 0, 0, size, size); const overlayBlob = image instanceof Blob ? image : new Blob([await toUint8(image)], { type: "image/png" }); const overlayBmp = await createImageBitmap(overlayBlob); const box = Math.floor(size * overlayFrac); const x = Math.floor((size - box) / 2); const y = Math.floor((size - box) / 2); const scale = Math.min(box / overlayBmp.width, box / overlayBmp.height); const w = Math.floor(overlayBmp.width * scale); const h = Math.floor(overlayBmp.height * scale); const ox = x + Math.floor((box - w) / 2); const oy = y + Math.floor((box - h) / 2); const fontSize = Math.max(12, Math.floor(box * 0.12)); ctx.font = `${fontSize}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; const textX = x + box / 2; const textY = oy; ctx.fillStyle = "#000000"; if (label) ctx.fillText(label, textX, textY); ctx.drawImage(overlayBmp, ox, oy, w, h); if (isSpecial) { ctx.strokeStyle = "#D4AF37"; ctx.lineWidth = 5; ctx.strokeRect(x + ctx.lineWidth / 2, y + ctx.lineWidth / 2, box - ctx.lineWidth, box - ctx.lineWidth); ctx.strokeRect(ctx.lineWidth / 2, ctx.lineWidth / 2, size - ctx.lineWidth, size - ctx.lineWidth); } const outBlob = await new Promise((res) => canvas.toBlob(res, "image/png")); return Buffer.from(new Uint8Array(await outBlob.arrayBuffer())); } else { // Node canvas compositing (your original logic) const nodeCanvas = await loadNodeOnlyModule("canvas"); const { createCanvas, loadImage } = nodeCanvas; const canvas = createCanvas(size, size); const ctx = canvas.getContext("2d"); const qrImg = await loadImage(Buffer.from(qrBytes)); ctx.drawImage(qrImg, 0, 0, size, size); const overlayImg = await loadImage(image); const box = Math.floor(size * overlayFrac); const x = Math.floor((size - box) / 2); const y = Math.floor((size - box) / 2); const scale = Math.min(box / overlayImg.width, box / overlayImg.height); const w = Math.floor(overlayImg.width * scale); const h = Math.floor(overlayImg.height * scale); const ox = x + Math.floor((box - w) / 2); const oy = y + Math.floor((box - h) / 2); const fontSize = Math.max(12, Math.floor(box * 0.12)); ctx.font = `${fontSize}px sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; const textX = x + box / 2; const textY = oy; ctx.fillStyle = "#000000"; if (label) ctx.fillText(label, textX, textY); if (image) ctx.drawImage(overlayImg, ox, oy, w, h); if (isSpecial) { ctx.strokeStyle = "#D4AF37"; ctx.lineWidth = 5; ctx.strokeRect(x + ctx.lineWidth / 2, y + ctx.lineWidth / 2, box - ctx.lineWidth, box - ctx.lineWidth); ctx.strokeRect(ctx.lineWidth / 2, ctx.lineWidth / 2, size - ctx.lineWidth, size - ctx.lineWidth); } return canvas.toBuffer("image/png"); } } export { scanQR, makeQR };