474 lines
18 KiB
JavaScript
474 lines
18 KiB
JavaScript
// QR code generation helpers for player onboarding and administrative links.
|
|
|
|
const path = require('path');
|
|
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
|
const QR_PNG_WIDTH = 2048;
|
|
|
|
function escapeXml(value) {
|
|
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, function (character) {
|
|
return {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
}[character];
|
|
});
|
|
}
|
|
|
|
function serializeNode(node) {
|
|
if (!node) {
|
|
return '';
|
|
}
|
|
if (node.nodeType === 3) {
|
|
return escapeXml(node.textContent || '');
|
|
}
|
|
|
|
const attributeEntries = Object.keys(node.attributes || {}).map(function (name) {
|
|
return name + '="' + escapeXml(node.attributes[name]) + '"';
|
|
});
|
|
if (node.tagName === 'svg' && node.namespaceURI === 'http://www.w3.org/2000/svg' && !Object.prototype.hasOwnProperty.call(node.attributes || {}, 'xmlns')) {
|
|
attributeEntries.unshift('xmlns="http://www.w3.org/2000/svg"');
|
|
}
|
|
const attributes = attributeEntries.join(' ');
|
|
const children = (node.childNodes || []).map(serializeNode).join('') + escapeXml(node.textContent || '');
|
|
return '<' + node.tagName + (attributes ? ' ' + attributes : '') + '>' + children + '</' + node.tagName + '>';
|
|
}
|
|
|
|
function createDomNode(tagName, namespaceUri) {
|
|
return {
|
|
tagName: tagName,
|
|
namespaceURI: namespaceUri || null,
|
|
attributes: {},
|
|
childNodes: [],
|
|
parentNode: null,
|
|
nodeType: 1,
|
|
textContent: '',
|
|
style: {},
|
|
setAttribute: function (name, value) {
|
|
this.attributes[name] = String(value);
|
|
},
|
|
appendChild: function (child) {
|
|
if (child) {
|
|
this.childNodes.push(child);
|
|
child.parentNode = this;
|
|
}
|
|
return child;
|
|
},
|
|
removeChild: function (child) {
|
|
this.childNodes = this.childNodes.filter(function (node) { return node !== child; });
|
|
return child;
|
|
},
|
|
get firstChild() {
|
|
return this.childNodes[0] || null;
|
|
},
|
|
get outerHTML() {
|
|
return serializeNode(this);
|
|
}
|
|
};
|
|
}
|
|
|
|
function createCanvasContext() {
|
|
return {
|
|
fillStyle: '#000000',
|
|
strokeStyle: '#000000',
|
|
lineWidth: 1,
|
|
beginPath: function () {},
|
|
closePath: function () {},
|
|
clearRect: function () {},
|
|
fillRect: function () {},
|
|
strokeRect: function () {},
|
|
moveTo: function () {},
|
|
lineTo: function () {},
|
|
arc: function () {},
|
|
rect: function () {},
|
|
fill: function () {},
|
|
stroke: function () {},
|
|
save: function () {},
|
|
restore: function () {},
|
|
translate: function () {},
|
|
rotate: function () {},
|
|
scale: function () {},
|
|
setTransform: function () {},
|
|
transform: function () {},
|
|
drawImage: function () {},
|
|
measureText: function (text) {
|
|
return { width: String(text === undefined || text === null ? '' : text).length * 8 };
|
|
},
|
|
createLinearGradient: function () {
|
|
return { addColorStop: function () {} };
|
|
},
|
|
createRadialGradient: function () {
|
|
return { addColorStop: function () {} };
|
|
},
|
|
createPattern: function () {
|
|
return null;
|
|
},
|
|
getImageData: function () {
|
|
return { data: [] };
|
|
},
|
|
putImageData: function () {},
|
|
clip: function () {}
|
|
};
|
|
}
|
|
|
|
function createCanvasNode() {
|
|
const node = createDomNode('canvas', 'http://www.w3.org/1999/xhtml');
|
|
node.width = 0;
|
|
node.height = 0;
|
|
node.getContext = function () {
|
|
return createCanvasContext();
|
|
};
|
|
node.toDataURL = function () {
|
|
return '';
|
|
};
|
|
node.toBlob = function (callback) {
|
|
if (typeof callback === 'function') {
|
|
callback(null);
|
|
}
|
|
};
|
|
return node;
|
|
}
|
|
|
|
function createQrStylingWindow() {
|
|
const document = {
|
|
createElement: function (tagName) {
|
|
if (String(tagName || '').toLowerCase() === 'canvas') {
|
|
return createCanvasNode();
|
|
}
|
|
return createDomNode(tagName, 'http://www.w3.org/1999/xhtml');
|
|
},
|
|
createElementNS: function (namespaceUri, tagName) {
|
|
return createDomNode(tagName, namespaceUri);
|
|
},
|
|
createTextNode: function (text) {
|
|
return {
|
|
nodeType: 3,
|
|
textContent: String(text === undefined || text === null ? '' : text),
|
|
parentNode: null
|
|
};
|
|
},
|
|
body: createDomNode('body', 'http://www.w3.org/1999/xhtml'),
|
|
head: createDomNode('head', 'http://www.w3.org/1999/xhtml')
|
|
};
|
|
|
|
const window = {
|
|
document: document,
|
|
navigator: { userAgent: 'node' },
|
|
URL: {
|
|
createObjectURL: function () { return ''; },
|
|
revokeObjectURL: function () {}
|
|
}
|
|
};
|
|
|
|
window.window = window;
|
|
window.self = window;
|
|
window.Image = class {
|
|
constructor() {
|
|
this.onload = null;
|
|
this.onerror = null;
|
|
this.width = 1;
|
|
this.height = 1;
|
|
this.naturalWidth = 1;
|
|
this.naturalHeight = 1;
|
|
this._src = '';
|
|
}
|
|
|
|
set src(value) {
|
|
this._src = String(value === undefined || value === null ? '' : value);
|
|
if (typeof this.onload === 'function') {
|
|
setTimeout(() => this.onload(), 0);
|
|
}
|
|
}
|
|
|
|
get src() {
|
|
return this._src;
|
|
}
|
|
};
|
|
window.HTMLImageElement = window.Image;
|
|
|
|
window.XMLSerializer = class {
|
|
serializeToString(node) {
|
|
return serializeNode(node);
|
|
}
|
|
};
|
|
|
|
return { window: window, document: document };
|
|
}
|
|
|
|
async function renderStyledQrRawData(options, format) {
|
|
const previousWindow = global.window;
|
|
const previousDocument = global.document;
|
|
const previousXMLSerializer = global.XMLSerializer;
|
|
const dom = createQrStylingWindow();
|
|
|
|
global.window = dom.window;
|
|
global.document = dom.document;
|
|
global.XMLSerializer = dom.window.XMLSerializer;
|
|
|
|
try {
|
|
const qrCode = new QRCodeStyling(options);
|
|
const blob = await qrCode.getRawData(format);
|
|
if (!blob) {
|
|
return '';
|
|
}
|
|
|
|
if (typeof blob === 'string') {
|
|
return blob;
|
|
}
|
|
|
|
if (typeof blob.text === 'function') {
|
|
return blob.text();
|
|
}
|
|
|
|
if (typeof blob.arrayBuffer === 'function') {
|
|
const buffer = await blob.arrayBuffer();
|
|
const bytes = new Uint8Array(buffer);
|
|
let binary = '';
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
binary += String.fromCharCode(bytes[index]);
|
|
}
|
|
return binary;
|
|
}
|
|
|
|
return '';
|
|
} finally {
|
|
global.window = previousWindow;
|
|
global.document = previousDocument;
|
|
global.XMLSerializer = previousXMLSerializer;
|
|
}
|
|
}
|
|
|
|
function svgToDataUrl(svg) {
|
|
const markup = String(svg === undefined || svg === null ? '' : svg).trim();
|
|
if (!markup) {
|
|
return '';
|
|
}
|
|
|
|
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup);
|
|
}
|
|
|
|
function normalizeQrStyleColor(value, fallback) {
|
|
const raw = String(value === undefined || value === null ? '' : value).trim();
|
|
return raw || fallback;
|
|
}
|
|
|
|
function normalizeQrStyleNumber(value, fallback, min, max) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) {
|
|
return fallback;
|
|
}
|
|
|
|
let next = parsed;
|
|
if (typeof min === 'number') {
|
|
next = Math.max(min, next);
|
|
}
|
|
if (typeof max === 'number') {
|
|
next = Math.min(max, next);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function buildQrStylingOptions(source) {
|
|
const text = String(source && source.value === undefined ? '' : source && source.value === null ? '' : source.value || '').trim();
|
|
const qrStyle = source && typeof source === 'object' ? source : {};
|
|
const dotsGradient = qrStyle.qr_dots_color_mode === 'gradient' ? {
|
|
type: String(qrStyle.qr_dots_gradient_type || 'linear').trim() || 'linear',
|
|
rotation: normalizeQrStyleNumber(qrStyle.qr_dots_gradient_rotation, 0, undefined, undefined),
|
|
colorStops: [
|
|
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000')) },
|
|
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_2, '#ffffff') }
|
|
]
|
|
} : null;
|
|
const cornersSquareGradient = qrStyle.qr_corners_square_color_mode === 'gradient' ? {
|
|
type: String(qrStyle.qr_corners_square_gradient_type || 'linear').trim() || 'linear',
|
|
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_square_gradient_rotation, 0, undefined, undefined),
|
|
colorStops: [
|
|
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000')) },
|
|
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_2, '#ffffff') }
|
|
]
|
|
} : null;
|
|
const cornersDotGradient = qrStyle.qr_corners_dot_color_mode === 'gradient' ? {
|
|
type: String(qrStyle.qr_corners_dot_gradient_type || 'linear').trim() || 'linear',
|
|
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_dot_gradient_rotation, 0, undefined, undefined),
|
|
colorStops: [
|
|
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000')) },
|
|
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_2, '#ffffff') }
|
|
]
|
|
} : null;
|
|
|
|
return {
|
|
width: QR_PNG_WIDTH,
|
|
height: QR_PNG_WIDTH,
|
|
type: 'canvas',
|
|
data: text,
|
|
margin: normalizeQrStyleNumber(qrStyle.qr_margin, 10, 0, undefined),
|
|
qrOptions: {},
|
|
dotsOptions: {
|
|
type: String(qrStyle.qr_dots_type || 'square').trim() || 'square',
|
|
color: normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000'),
|
|
gradient: dotsGradient || undefined
|
|
},
|
|
cornersSquareOptions: {
|
|
type: String(qrStyle.qr_corners_square_type || 'square').trim() || 'square',
|
|
color: normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000'),
|
|
gradient: cornersSquareGradient || undefined
|
|
},
|
|
cornersDotOptions: {
|
|
type: String(qrStyle.qr_corners_dot_type || 'square').trim() || 'square',
|
|
color: normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000'),
|
|
gradient: cornersDotGradient || undefined
|
|
},
|
|
backgroundOptions: {
|
|
color: qrStyle.qr_background_transparent ? 'transparent' : normalizeQrStyleColor(qrStyle.qr_background_color, '#ffffff')
|
|
},
|
|
image: String(qrStyle.qr_image || '').trim() || undefined,
|
|
imageOptions: {
|
|
hideBackgroundDots: Boolean(qrStyle.qr_image_hide_background_dots),
|
|
imageSize: normalizeQrStyleNumber(qrStyle.qr_image_size, 0.4, 0, 1),
|
|
margin: normalizeQrStyleNumber(qrStyle.qr_image_margin, 5, 0, undefined)
|
|
}
|
|
};
|
|
}
|
|
|
|
async function createStyledQrCodeDataUrl(value) {
|
|
const source = value && typeof value === 'object' ? value : { value: value };
|
|
const options = buildQrStylingOptions(source);
|
|
if (!options.data) {
|
|
return '';
|
|
}
|
|
|
|
return svgToDataUrl(await createStyledQrCodeSvg(options.data));
|
|
}
|
|
|
|
async function createStyledQrCodeSvg(value) {
|
|
const source = value && typeof value === 'object' ? value : { value: value };
|
|
const options = buildQrStylingOptions(source);
|
|
if (!options.data) {
|
|
return '';
|
|
}
|
|
|
|
return renderStyledQrRawData(options, 'svg');
|
|
}
|
|
|
|
async function createQrCodeSvg(value) {
|
|
return createStyledQrCodeSvg(value);
|
|
}
|
|
|
|
async function createQrCodeDataUrl(value) {
|
|
return createStyledQrCodeDataUrl(value);
|
|
}
|
|
|
|
async function buildQrCodeContent(value, options) {
|
|
const source = value && typeof value === 'object' ? value : { value: value };
|
|
const forcePreviewRefresh = Boolean(options && options.forcePreviewRefresh);
|
|
const text = String(source.value === undefined || source.value === null ? '' : source.value).trim();
|
|
const hasBooleanField = function (key) {
|
|
return Object.prototype.hasOwnProperty.call(source, key);
|
|
};
|
|
const margin = Number(source.qr_margin);
|
|
const normalizeHex = function (value) {
|
|
const raw = String(value === undefined || value === null ? '' : value).trim();
|
|
return raw || undefined;
|
|
};
|
|
const normalizeNumber = function (value, min, max, round) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) {
|
|
return undefined;
|
|
}
|
|
|
|
let next = parsed;
|
|
if (round) {
|
|
next = Math.round(next);
|
|
}
|
|
if (typeof min === 'number') {
|
|
next = Math.max(min, next);
|
|
}
|
|
if (typeof max === 'number') {
|
|
next = Math.min(max, next);
|
|
}
|
|
return next;
|
|
};
|
|
const style = {
|
|
qr_margin: Number.isFinite(margin) && margin >= 0 ? Math.round(margin) : undefined,
|
|
qr_border_radius: Number.isFinite(Number(source.qr_border_radius)) ? Math.max(0, Math.round(Number(source.qr_border_radius))) : undefined,
|
|
qr_background_color: String(source.qr_background_color === undefined || source.qr_background_color === null ? '' : source.qr_background_color).trim() || undefined,
|
|
qr_background_transparent: hasBooleanField('qr_background_transparent') ? Boolean(source.qr_background_transparent) : undefined,
|
|
qr_background_color_mode: 'single',
|
|
qr_background_gradient_type: String(source.qr_background_gradient_type === undefined || source.qr_background_gradient_type === null ? '' : source.qr_background_gradient_type).trim() || undefined,
|
|
qr_background_gradient_rotation: normalizeNumber(source.qr_background_gradient_rotation),
|
|
qr_background_gradient_color_1: normalizeHex(source.qr_background_gradient_color_1),
|
|
qr_background_gradient_color_2: normalizeHex(source.qr_background_gradient_color_2),
|
|
qr_dots_color: String(source.qr_dots_color === undefined || source.qr_dots_color === null ? '' : source.qr_dots_color).trim() || undefined,
|
|
qr_dots_type: String(source.qr_dots_type === undefined || source.qr_dots_type === null ? '' : source.qr_dots_type).trim() || undefined,
|
|
qr_dots_color_mode: String(source.qr_dots_color_mode === undefined || source.qr_dots_color_mode === null ? '' : source.qr_dots_color_mode).trim() || undefined,
|
|
qr_dots_gradient_type: String(source.qr_dots_gradient_type === undefined || source.qr_dots_gradient_type === null ? '' : source.qr_dots_gradient_type).trim() || undefined,
|
|
qr_dots_gradient_rotation: normalizeNumber(source.qr_dots_gradient_rotation),
|
|
qr_dots_gradient_color_1: normalizeHex(source.qr_dots_gradient_color_1),
|
|
qr_dots_gradient_color_2: normalizeHex(source.qr_dots_gradient_color_2),
|
|
qr_corners_square_color: String(source.qr_corners_square_color === undefined || source.qr_corners_square_color === null ? '' : source.qr_corners_square_color).trim() || undefined,
|
|
qr_corners_square_type: String(source.qr_corners_square_type === undefined || source.qr_corners_square_type === null ? '' : source.qr_corners_square_type).trim() || undefined,
|
|
qr_corners_square_color_mode: String(source.qr_corners_square_color_mode === undefined || source.qr_corners_square_color_mode === null ? '' : source.qr_corners_square_color_mode).trim() || undefined,
|
|
qr_corners_square_gradient_type: String(source.qr_corners_square_gradient_type === undefined || source.qr_corners_square_gradient_type === null ? '' : source.qr_corners_square_gradient_type).trim() || undefined,
|
|
qr_corners_square_gradient_rotation: normalizeNumber(source.qr_corners_square_gradient_rotation),
|
|
qr_corners_square_gradient_color_1: normalizeHex(source.qr_corners_square_gradient_color_1),
|
|
qr_corners_square_gradient_color_2: normalizeHex(source.qr_corners_square_gradient_color_2),
|
|
qr_corners_dot_color: String(source.qr_corners_dot_color === undefined || source.qr_corners_dot_color === null ? '' : source.qr_corners_dot_color).trim() || undefined,
|
|
qr_corners_dot_type: String(source.qr_corners_dot_type === undefined || source.qr_corners_dot_type === null ? '' : source.qr_corners_dot_type).trim() || undefined,
|
|
qr_corners_dot_color_mode: String(source.qr_corners_dot_color_mode === undefined || source.qr_corners_dot_color_mode === null ? '' : source.qr_corners_dot_color_mode).trim() || undefined,
|
|
qr_corners_dot_gradient_type: String(source.qr_corners_dot_gradient_type === undefined || source.qr_corners_dot_gradient_type === null ? '' : source.qr_corners_dot_gradient_type).trim() || undefined,
|
|
qr_corners_dot_gradient_rotation: normalizeNumber(source.qr_corners_dot_gradient_rotation),
|
|
qr_corners_dot_gradient_color_1: normalizeHex(source.qr_corners_dot_gradient_color_1),
|
|
qr_corners_dot_gradient_color_2: normalizeHex(source.qr_corners_dot_gradient_color_2),
|
|
qr_image: String(source.qr_image === undefined || source.qr_image === null ? '' : source.qr_image).trim() || undefined,
|
|
qr_image_size: Number.isFinite(Number(source.qr_image_size)) ? Math.max(0, Math.min(1, Number(source.qr_image_size))) : undefined,
|
|
qr_image_margin: Number.isFinite(Number(source.qr_image_margin)) ? Math.max(0, Math.round(Number(source.qr_image_margin))) : undefined,
|
|
qr_image_hide_background_dots: hasBooleanField('qr_image_hide_background_dots') ? Boolean(source.qr_image_hide_background_dots) : undefined
|
|
};
|
|
const content = {
|
|
type: 'qr-code',
|
|
value: text
|
|
};
|
|
|
|
Object.keys(style).forEach((key) => {
|
|
if (style[key] !== undefined) {
|
|
content[key] = style[key];
|
|
}
|
|
});
|
|
|
|
content.qr_background_use_gradient = false;
|
|
content.qr_dots_use_gradient = content.qr_dots_gradient_type && content.qr_dots_gradient_type !== 'none';
|
|
content.qr_corners_square_use_gradient = content.qr_corners_square_gradient_type && content.qr_corners_square_gradient_type !== 'none';
|
|
content.qr_corners_dot_use_gradient = content.qr_corners_dot_gradient_type && content.qr_corners_dot_gradient_type !== 'none';
|
|
|
|
const svg = String(source.qr_svg === undefined || source.qr_svg === null ? '' : source.qr_svg).trim();
|
|
if (svg) {
|
|
content.qr_svg = svg;
|
|
}
|
|
|
|
const preview = forcePreviewRefresh ? '' : String(source.qr_preview === undefined || source.qr_preview === null ? '' : source.qr_preview).trim();
|
|
if (preview) {
|
|
content.qr_preview = preview;
|
|
}
|
|
|
|
if (text && !content.qr_preview) {
|
|
const generatedSvg = await createQrCodeSvg(text);
|
|
if (generatedSvg) {
|
|
content.qr_svg = generatedSvg;
|
|
}
|
|
const generatedDataUrl = await createStyledQrCodeDataUrl(content);
|
|
if (generatedDataUrl) {
|
|
content.qr_preview = generatedDataUrl;
|
|
}
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
module.exports = {
|
|
createQrCodeSvg,
|
|
createStyledQrCodeSvg,
|
|
createStyledQrCodeDataUrl,
|
|
createQrCodeDataUrl,
|
|
buildQrCodeContent
|
|
}; |