This commit is contained in:
j3d1 2026-08-21 21:37:12 +02:00
parent 7d9f67a77a
commit 3299c97392
13 changed files with 1407 additions and 327 deletions

View file

@ -569,11 +569,6 @@
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true "dev": true
}, },
"camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
},
"chai": { "chai": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
@ -614,68 +609,11 @@
"readdirp": "~3.6.0" "readdirp": "~3.6.0"
} }
}, },
"cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"requires": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"requires": {
"color-convert": "^2.0.1"
}
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
}
},
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"requires": {
"ansi-regex": "^5.0.1"
}
},
"wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
}
}
}
},
"color-convert": { "color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": { "requires": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
} }
@ -683,7 +621,8 @@
"color-name": { "color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
}, },
"combined-stream": { "combined-stream": {
"version": "1.0.8", "version": "1.0.8",
@ -786,11 +725,6 @@
"ms": "^2.1.3" "ms": "^2.1.3"
} }
}, },
"decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="
},
"decimal.js": { "decimal.js": {
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
@ -812,11 +746,6 @@
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true "dev": true
}, },
"dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"dns-query": { "dns-query": {
"version": "0.11.2", "version": "0.11.2",
"resolved": "https://registry.npmjs.org/dns-query/-/dns-query-0.11.2.tgz", "resolved": "https://registry.npmjs.org/dns-query/-/dns-query-0.11.2.tgz",
@ -926,15 +855,6 @@
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
} }
}, },
"find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"requires": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
}
},
"foreground-child": { "foreground-child": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
@ -963,11 +883,6 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
"get-func-name": { "get-func-name": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@ -1077,7 +992,8 @@
"is-fullwidth-code-point": { "is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true
}, },
"is-glob": { "is-glob": {
"version": "4.0.3", "version": "4.0.3",
@ -1188,14 +1104,6 @@
"integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==",
"dev": true "dev": true
}, },
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"requires": {
"p-locate": "^4.1.0"
}
},
"lodash": { "lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@ -1322,29 +1230,6 @@
"yocto-queue": "^1.0.0" "yocto-queue": "^1.0.0"
} }
}, },
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"requires": {
"p-limit": "^2.2.0"
},
"dependencies": {
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
}
}
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"package-json-from-dist": { "package-json-from-dist": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
@ -1360,11 +1245,6 @@
"entities": "^4.4.0" "entities": "^4.4.0"
} }
}, },
"path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
},
"path-key": { "path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -1415,11 +1295,6 @@
"pathe": "^1.1.2" "pathe": "^1.1.2"
} }
}, },
"pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="
},
"popper.js": { "popper.js": {
"version": "1.16.1", "version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
@ -1478,16 +1353,6 @@
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true "dev": true
}, },
"qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"requires": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
}
},
"querystringify": { "querystringify": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@ -1509,16 +1374,6 @@
"picomatch": "^2.2.1" "picomatch": "^2.2.1"
} }
}, },
"require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
},
"require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"requires-port": { "requires-port": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@ -1572,11 +1427,6 @@
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true "dev": true
}, },
"set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"shebang-command": { "shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -1973,11 +1823,6 @@
"isexe": "^2.0.0" "isexe": "^2.0.0"
} }
}, },
"which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"why-is-node-running": { "why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -2071,68 +1916,6 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true "dev": true
}, },
"y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"requires": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
}
},
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"requires": {
"ansi-regex": "^5.0.1"
}
}
}
},
"yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"requires": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
}
},
"yocto-queue": { "yocto-queue": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",

View file

@ -13,7 +13,6 @@
"dns-query": "^0.11.2", "dns-query": "^0.11.2",
"js-nacl": "^1.4.0", "js-nacl": "^1.4.0",
"moment": "^2.29.4", "moment": "^2.29.4",
"qrcode": "^1.5.4",
"vue": "^3.2.47", "vue": "^3.2.47",
"vue-multiselect": "^2.1.7", "vue-multiselect": "^2.1.7",
"vue-router": "^4.1.6", "vue-router": "^4.1.6",

View file

@ -52,7 +52,7 @@
</style> </style>
<script> <script>
import {drawFallbackLabel} from "@/label.js"; import {drawFallbackLabel, preloadQrEncoder} from "@/label.js";
import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js"; import {LABEL_TEMPLATES, templateIsAvailable, templateContent} from "@/label-layouts.js";
export default { export default {
@ -69,6 +69,13 @@ export default {
value: { value: {
type: String, type: String,
required: true required: true
},
// Print.vue's global qr/micro-qr/rmqr choice (see label.js's QR_CODE_TYPES) - passed
// straight through to drawFallbackLabel so these thumbnails match whatever symbology the
// main preview is actually using.
codeType: {
type: String,
default: "qr"
} }
}, },
model: { model: {
@ -84,6 +91,9 @@ export default {
watch: { watch: {
fields() { fields() {
this.redraw(); this.redraw();
},
codeType() {
this.redraw();
} }
}, },
methods: { methods: {
@ -116,7 +126,7 @@ export default {
continue; continue;
} }
try { try {
drawFallbackLabel(canvas, content); drawFallbackLabel(canvas, content, "along", this.codeType);
} catch (e) { } catch (e) {
// A thumbnail that can't render at this content length just stays blank // A thumbnail that can't render at this content length just stays blank
// rather than surfacing an error for every keystroke. // rather than surfacing an error for every keystroke.
@ -131,7 +141,10 @@ export default {
// array rather than the canvas itself. A function ref sidesteps that entirely. // array rather than the canvas itself. A function ref sidesteps that entirely.
this.templateCanvases = {}; this.templateCanvases = {};
}, },
mounted() { async mounted() {
// See Print.vue's mounted() - every thumbnail here has a "qrcode" leaf, so there's
// nothing worth drawing before this resolves.
await preloadQrEncoder();
this.redraw(); this.redraw();
} }
} }

View file

@ -63,6 +63,12 @@
<span class="align-middle">Print</span> <span class="align-middle">Print</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item">
<router-link to="/scan" class="sidebar-link">
<b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan>
<span class="align-middle">Scan</span>
</router-link>
</li>
</ul> </ul>
<div class="sidebar-version" :title="versionTitle">{{ frontendCommit }}{{ backendCommit ? ' / ' + backendCommit : '' }}</div> <div class="sidebar-version" :title="versionTitle">{{ frontendCommit }}{{ backendCommit ? ' / ' + backendCommit : '' }}</div>
</div> </div>

View file

@ -0,0 +1,9 @@
// Embeds/extracts a handle in a URL path segment. See docs/federation.md's "Embedding a
// `#`-bearing handle in a URL": `#` starts a URI's fragment component, so a group handle
// (`#name@domain`) or classification handle (`origin#type:name`) can't appear unescaped in a
// path segment. `+` stands in for `#` there instead of the usual %23 - safe to reverse
// unambiguously because every field a handle is built from is already required to exclude `+`
// (see federation.md's Reserved characters). The canonical handle itself never changes; this only
// affects how one gets embedded in, or read back out of, a URL path segment.

View file

@ -1,5 +1,36 @@
import QRCode from "qrcode"; import {loadAnyDCode} from "../vendor/anyd-qr.js";
import {encodeHandleForUrl} from "@/handle-url"; import {encodeHandleForUrl} from "@/router"
// anyd-qr.js's own loadAnyDCode() memoizes the wasm instantiation itself, so calling it more
// than once (each of Print.vue and LabelLayoutPreview.vue does, on mount) is free - `anyd` just
// mirrors its resolved value so buildRenderTree below can use it synchronously. Until it
// resolves, a "qrcode" leaf throws (see encodeQr) the same way an oversized value already does -
// callers already have to handle layoutContent throwing, so this reuses that path rather than
// adding a second failure mode.
let anyd = null;
export function preloadQrEncoder() {
return loadAnyDCode().then(instance => { anyd = instance; });
}
// The three symbologies anyd-qr.js exposes (see its CodeType) - Print.vue's code-type selector
// offers exactly these. rMQR's matrix isn't square (see encodeQr's width/height below), unlike
// qr/micro-qr, which always are.
export const QR_CODE_TYPES = ["qr", "micro-qr", "rmqr"];
function encodeQr(text, codeType) {
if (!anyd) {
throw new Error("The QR encoder is still loading — try again in a moment.");
}
// BitMatrix-alike view over anyd's row-major Uint8Array, matching the shape drawQrLeaf/
// snapQrToCrispSize below expect (they predate this and were written against the "qrcode"
// package's own modules.size/get()). anyd's matrix already excludes the quiet zone from
// width/height (see its ModuleMatrix type), same as the old library's BitMatrix. width/height
// are kept separate rather than a single `size` (the old library's own shape, always square)
// since rMQR symbols are rectangular.
const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), {ecc: "M"}).matrix;
return {width, height, get: (row, col) => modules[row * width + col] !== 0};
}
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */ const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
@ -39,27 +70,46 @@ export function tapeFromStatus(status) {
See label-layouts.js's LABEL_TEMPLATES for concrete trees. See label-layouts.js's LABEL_TEMPLATES for concrete trees.
*/ */
const QR_ASPECT = 1; /* a QR code is always square */
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */ const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to // Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to one
// whichever of these bitmap-style fonts (see ../scss/_pixel-fonts.scss) is designed closest to - // of these bitmap-style fonts instead (see ../scss/_pixel-fonts.scss) - Tom Thumb for the smallest
// and no smaller than - the box's actual effective size. Ordered smallest first; fontFamilyFor // sizes, Silkscreen once there's enough room for its more conventional letterforms.
// below picks the first tier whose belowPx clears the requested size, sans-serif once none do. //
// Both were chosen only after rendering single letters in a real browser *at raw canvas pixel
// sizes* and inspecting the actual pixels - checking that fillText was merely *called* doesn't
// confirm anything legible got drawn, and neither does a DPI-adjusted size that was never the
// number actually handed to ctx.font. Silkscreen confirmed clean at 8px+. A third candidate,
// PICO-8, also rendered cleanly across the whole range, but has no lowercase glyphs at all - it
// silently draws lowercase input as uppercase - which rules it out for real label content (item
// handles, URLs) that isn't reliably all-caps. Tom Thumb's declared ascent/descent (0 / ~fontPx,
// backwards from a normal font) turned out not to be a centering quirk: its actual visible ink is
// only ~1/3.2 of its own nominal font-size (confirmed both by measuring actualBoundingBox at
// several sizes and by a live-browser check - "16px" reads as roughly 5px of real glyph height),
// hence the `scale` below - whatever logical size is requested, the font is actually drawn that
// many times larger so its real ink comes out at the intended size. Silkscreen's declared size
// already matches its ink, so it has no `scale` (equivalent to 1).
//
// belowPx and MIN_READABLE_TEXT_PX below are both compared against the *logical* (unscaled)
// fontPx, deliberately not adjusted for the tape's dpi: a browser's font rasterizer only ever
// sees a raw pixel count, with no notion of "physical size" at all, so that's what determines
// whether a glyph's fine detail survives - confirmed by the same real-Chromium testing, where a
// raw 4.35px render was a solid blob regardless of what a dpi-scaled version of that number would
// have implied.
const PIXEL_FONT_TIERS = [ const PIXEL_FONT_TIERS = [
{belowPx: 6, family: "Tom Thumb"}, /* designed for ~5px */ {belowPx: 8, family: "Tom Thumb", scale: 3.2},
{belowPx: 7, family: "PICO-8"}, /* designed for ~6px */ {belowPx: 10, family: "Silkscreen"},
{belowPx: 10, family: "Silkscreen"}, /* designed for ~7-8px */
]; ];
function fontFamilyFor(fontPx) { function fontFamilyFor(fontPx) {
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx)?.family ?? "sans-serif"; return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"};
} }
// A pixel font (see PIXEL_FONT_TIERS above) is what makes a size down here still legible - a // Tom Thumb (see PIXEL_FONT_TIERS above) held up down to 5px in the same real-Chromium pixel-level
// plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor that predates them. // verification - a plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor
// Below this, drawTextLeaf leaves that one field blank rather than drawing illegible ink - see // that predates it. Below this, drawTextLeaf leaves that one field blank rather than drawing
// there for why that's a quieter failure than rejecting the whole label over it. // illegible ink - see there for why that's a quieter failure than rejecting the whole label over
// it.
const MIN_READABLE_TEXT_PX = 5; const MIN_READABLE_TEXT_PX = 5;
function isSplit(node) { function isSplit(node) {
@ -90,8 +140,8 @@ function parseMm(value, key) {
requesting the direction a split doesn't naturally combine in just inverts its own relation. */ requesting the direction a split doesn't naturally combine in just inverts its own relation. */
function relation(node, ownAxis, wantWidth, pxPerMm) { function relation(node, ownAxis, wantWidth, pxPerMm) {
if (!isSplit(node)) { if (!isSplit(node)) {
if (node.type === "qrcode" && node.crispSize !== undefined) { if (node.type === "qrcode" && node.crispWidth !== undefined) {
return {a: 0, b: node.crispSize}; return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight};
} }
if (node.type === "qrcode" || node.type === "text") { if (node.type === "qrcode" || node.type === "text") {
const aspect = node.aspect; const aspect = node.aspect;
@ -156,27 +206,29 @@ function positionTree(node, ownAxis, x, y) {
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring /* A QR code needs an integer number of pixels per module to render crisply rather than blurring
at a fractional scale, so its true size is whatever that rounds down to - almost never the at a fractional scale, so its true size is whatever that rounds down to - almost never the
scale-free square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a scale-free box its aspect ratio alone would suggest. Called once every qrcode leaf has a
provisional (scale-free) box from a first layoutTree pass, this pins each one's real box.width provisional (scale-free) box from a first layoutTree pass, this pins each one's real
(in row context) or box.height (in column context - whichever axis its box doesn't already box.width/box.height as `crispWidth`/`crispHeight`, so relation() above starts treating it as a
share with its siblings) as `crispSize`, so relation() above starts treating it as a fixed fixed size, the same as an "empty" leaf, instead of one that scales with whatever height/width
size, the same as an "empty" leaf, instead of one that scales with whatever height/width it's it's offered. A second relation()/layoutTree() pass (see layoutContent) then resizes everything
offered. A second relation()/layoutTree() pass (see layoutContent) then resizes everything
else around that real footprint, so nothing downstream reserves - and leaves unfilled - room else around that real footprint, so nothing downstream reserves - and leaves unfilled - room
for a squarer QR code than what actually gets drawn. */ for a squarer/differently-shaped code than what actually gets drawn. Kept as two independent
dimensions rather than one `crispSize` (as when every code here was a square QR) since an rMQR
symbol isn't square - see encodeQr. */
function snapQrToCrispSize(node) { function snapQrToCrispSize(node) {
if (isSplit(node)) { if (isSplit(node)) {
node.forEach(snapQrToCrispSize); node.forEach(snapQrToCrispSize);
return; return;
} }
if (node.type === "qrcode") { if (node.type === "qrcode") {
const modules = node.qr.modules.size; const {width: modulesW, height: modulesH} = node.qr;
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules); const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
if (!(scale >= 1)) { if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — " throw new Error("This text needs a bigger code than the tape allows — "
+ "try a shorter value or a wider tape."); + "try a shorter value or a wider tape.");
} }
node.crispSize = modules * scale; node.crispWidth = modulesW * scale;
node.crispHeight = modulesH * scale;
} }
} }
@ -194,18 +246,22 @@ function measureTextBlock(ctx, lines, referencePx) {
} }
/* Turns a resolved content tree (see templateContent below - leaf objects carry a `value` /* Turns a resolved content tree (see templateContent below - leaf objects carry a `value`
rather than a `content` function) into one ready for layout: a QR leaf gets its actual rather than a `content` function) into one ready for layout: a QR leaf gets its actual encoded
QRCode.create() object, a text leaf gets its measured natural aspect ratio, and an "empty" modules (see encodeQr) and an aspect ratio taken from their real width/height - 1 (square) for
leaf passes through untouched. Multi-line text (`value` is an array) measures as one leaf, qr/micro-qr, but not for rMQR, whose symbols are rectangular - a text leaf gets its measured
not one per line - splitting it into a column of independently-sized leaves would let each natural aspect ratio, and an "empty" leaf passes through untouched. Multi-line text (`value` is
line grow to its own full width, ending up at a different font size than its neighbors, which an array) measures as one leaf, not one per line - splitting it into a column of independently-
is legible but not what "one text field" should look like. */ sized leaves would let each line grow to its own full width, ending up at a different font size
function buildRenderTree(ctx, node, referencePx) { than its neighbors, which is legible but not what "one text field" should look like. `codeType`
is Print.vue's global qr/micro-qr/rmqr choice - see QR_CODE_TYPES - applied to every qrcode leaf
in the tree alike, the same way `orientation` applies to the whole tree in layoutContent. */
function buildRenderTree(ctx, node, referencePx, codeType) {
if (isSplit(node)) { if (isSplit(node)) {
return node.map(child => buildRenderTree(ctx, child, referencePx)); return node.map(child => buildRenderTree(ctx, child, referencePx, codeType));
} }
if (node.type === "qrcode") { if (node.type === "qrcode") {
return {type: "qrcode", aspect: QR_ASPECT, qr: QRCode.create(node.value)}; const qr = encodeQr(node.value, codeType);
return {type: "qrcode", aspect: qr.width / qr.height, qr};
} }
if (node.type === "text") { if (node.type === "text") {
const lines = Array.isArray(node.value) ? node.value : [node.value]; const lines = Array.isArray(node.value) ? node.value : [node.value];
@ -216,22 +272,24 @@ function buildRenderTree(ctx, node, referencePx) {
} }
function drawQrLeaf(ctx, node) { function drawQrLeaf(ctx, node) {
const modules = node.qr.modules.size; const {width: modulesW, height: modulesH} = node.qr;
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules); const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
const size = modules * scale; const w = modulesW * scale;
const left = node.box.x + Math.floor((node.box.width - size) / 2); const h = modulesH * scale;
const top = node.box.y + Math.floor((node.box.height - size) / 2); const left = node.box.x + Math.floor((node.box.width - w) / 2);
for (let row = 0; row < modules; row++) { const top = node.box.y + Math.floor((node.box.height - h) / 2);
for (let col = 0; col < modules; col++) { for (let row = 0; row < modulesH; row++) {
if (node.qr.modules.get(row, col)) { for (let col = 0; col < modulesW; col++) {
if (node.qr.get(row, col)) {
ctx.fillRect(left + col * scale, top + row * scale, scale, scale); ctx.fillRect(left + col * scale, top + row * scale, scale, scale);
} }
} }
} }
} }
// Returns the effective font size drawn at (or that would have been, if it's too small to draw - // Returns the effective (raw, un-normalized) font size drawn at - or that would have been, if
// see below) - drawTree collects these into drawLabel/drawFallbackLabel's textSizesPx. // it's too small to draw, see below - drawTree collects these into drawLabel/drawFallbackLabel's
// textSizesPx.
function drawTextLeaf(ctx, node, referencePx) { function drawTextLeaf(ctx, node, referencePx) {
const fontPx = referencePx * (node.box.height / node.naturalHeight); const fontPx = referencePx * (node.box.height / node.naturalHeight);
// Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than // Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than
@ -240,27 +298,51 @@ function drawTextLeaf(ctx, node, referencePx) {
if (fontPx < MIN_READABLE_TEXT_PX) { if (fontPx < MIN_READABLE_TEXT_PX) {
return fontPx; return fontPx;
} }
const lineHeight = node.box.height / node.lines.length; const {family, scale = 1} = fontFamilyFor(fontPx);
const family = fontFamilyFor(fontPx); const isPixelFont = family !== "sans-serif";
ctx.font = `${fontPx}px "${family}"`; // A pixel font's glyphs are meant to land exactly on the pixel grid - node.box.x/y are
// ordinary layout math (sums/quotients of affine-solved sizes) and essentially never land on
// a whole pixel, so drawing at their exact fractional size/position would misalign a pixel
// font's 1px-wide strokes the same as it would any other font. Snapping size and position to
// the nearest whole pixel fixes that; sans-serif is left at its exact fractional fit, since
// ordinary anti-aliased text is expected to (and looks fine) regardless of position.
const snap = isPixelFont ? Math.round : (v) => v;
const drawFontPx = snap(fontPx);
// `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects for a font whose declared
// size doesn't match its real visible ink - the size actually handed to ctx.font, not
// drawFontPx itself, which stays the logical size everything else here (box centering, line
// stacking) is measured against.
ctx.font = `${drawFontPx * scale}px "${family}"`;
// A @font-face family already in use elsewhere on the page loads in time for this, but canvas // A @font-face family already in use elsewhere on the page loads in time for this, but canvas
// text silently falls back to the next font in the stack (there isn't one here, so the // text silently falls back to the next font in the stack (there isn't one here, so the
// browser default) if drawn before its first-ever load finishes - unlike DOM text, a canvas // browser default) if drawn before its first-ever load finishes - unlike DOM text, a canvas
// fillText never waits or repaints on its own once the real font arrives. Kicking off the load // fillText never waits or repaints on its own once the real font arrives. Kicking off the load
// here means only that very first draw at a given size risks the fallback; every redraw after // here means only that very first draw at a given size risks the fallback; every redraw after
// it (Print.vue's live preview redraws on every keystroke) picks up the real font. // it (Print.vue's live preview redraws on every keystroke) picks up the real font.
if (family !== "sans-serif") { if (isPixelFont) {
document.fonts.load(ctx.font); document.fonts.load(ctx.font);
} }
ctx.textBaseline = "middle";
ctx.textAlign = "center"; ctx.textAlign = "center";
const centerX = node.box.x + node.box.width / 2; const centerX = snap(node.box.x + node.box.width / 2);
const lineHeight = node.box.height / node.lines.length;
// Lines stack as a block, each centered under the last - keeps a multi-line field reading as // Lines stack as a block, each centered under the last - keeps a multi-line field reading as
// one unit rather than drifting apart. // one unit rather than drifting apart.
let y = node.box.y + lineHeight / 2; let sliceTop = node.box.y;
for (const line of node.lines) { for (const line of node.lines) {
ctx.fillText(line, centerX, y); if (isPixelFont) {
y += lineHeight; // textBaseline:"middle" centers on the font's *declared* ascent/descent (its line
// height) - Tom Thumb's are backwards (see PIXEL_FONT_TIERS above) and would center on
// nonsense. Centering on actualBoundingBox{Ascent,Descent} instead - this specific
// string's real rendered ink (its character height) - costs nothing and stays correct
// regardless of whether a pixel font's declared metrics can be trusted.
ctx.textBaseline = "alphabetic";
const {actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(line);
ctx.fillText(line, centerX, snap(sliceTop + (lineHeight + up - down) / 2));
} else {
ctx.textBaseline = "middle";
ctx.fillText(line, centerX, sliceTop + lineHeight / 2);
}
sliceTop += lineHeight;
} }
return fontPx; return fontPx;
} }
@ -301,61 +383,98 @@ function drawTree(ctx, node, referencePx, textSizesPx) {
} }
} }
/* Builds, sizes and validates the tree for a fixed `height` (the tape's cross-web printAreaPx, or /* Builds, sizes and validates the tree for a fixed `fixedSize` (the tape's cross-web printAreaPx,
the fallback preview's reference height) - the one dimension every layout scales from, plus or the fallback preview's reference height) - the one dimension every layout scales from, plus
`pxPerMm` to turn "empty" leaves' physical sizes into pixels. `height` and the tree's content `pxPerMm` to turn "empty" leaves' physical sizes into pixels. `fixedSize` and the tree's content
fully determine its overall width; `maxLength`, when finite (a fixed-length/die-cut tape), fully determine its overall size along the other, growing axis (the one that runs along the
rejects content that doesn't fit rather than shrinking it. tape as it feeds); `maxLength`, when finite (a fixed-length/die-cut tape), rejects content that
doesn't fit rather than shrinking it.
Sizing runs twice: a first pass treats every qrcode leaf as the scale-free square its aspect `orientation` picks which axis `fixedSize` binds to: "along" (the default) fixes the tree's
ratio of 1 suggests, purely to find out how much room each one would actually be offered; from height - the tape's cross-web width - and grows its width along the feed direction, same as a
that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The second pass then plain read top-to-bottom design. "across" fixes the tree's width instead and grows its height,
resolves the whole tree again with that real size fixed in, so every sibling and the overall so the design is built turned 90deg from how it'd read "along" - drawLabel/drawFallbackLabel
width reflect what's actually drawn rather than the idealized square no QR code ever quite are what actually rotate the drawing back into the physical raster's fixed orientation; nothing
fills. */ here needs to know about that rotation, since relation()/layoutTree() below already solve the
function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) { tree in either direction symmetrically.
const tree = buildRenderTree(ctx, content, referencePx);
const measured = relation(tree, false, true, pxPerMm); Sizing runs twice: a first pass treats every qrcode leaf as the scale-free box its real
layoutTree(tree, false, measured.a * height + measured.b, height, pxPerMm); width/height ratio suggests, purely to find out how much room each one would actually be
offered; from that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The
second pass then resolves the whole tree again with that real size fixed in, so every sibling
and the overall size reflect what's actually drawn rather than the idealized box no code ever
quite fills. `codeType`, see buildRenderTree. */
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation, codeType) {
const tree = buildRenderTree(ctx, content, referencePx, codeType);
const alongTape = orientation !== "across";
const solve = () => {
const {a, b} = relation(tree, false, alongTape, pxPerMm);
return alongTape
? {width: a * fixedSize + b, height: fixedSize}
: {width: fixedSize, height: a * fixedSize + b};
};
let {width, height} = solve();
layoutTree(tree, false, width, height, pxPerMm);
snapQrToCrispSize(tree); snapQrToCrispSize(tree);
const {a, b} = relation(tree, false, true, pxPerMm); ({width, height} = solve());
const width = a * height + b; const length = alongTape ? width : height;
if (maxLength !== Infinity && width > maxLength) { if (maxLength !== Infinity && length > maxLength) {
throw new Error("This doesn't fit on this tape — " throw new Error("This doesn't fit on this tape — "
+ "try a shorter value, a different layout, or a bigger label."); + "try a shorter value, a different layout, or a bigger label.");
} }
layoutTree(tree, false, width, height, pxPerMm); layoutTree(tree, false, width, height, pxPerMm);
return {tree, width}; return {tree, length};
} }
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's /* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns real pixel dimensions. `orientation` is "along" (the default) to lay the design out reading
along the tape's feed direction, or "across" to turn it 90deg so it reads across the tape
instead - either way the physical raster this returns is still exactly
printedLength x tape.printAreaPx (that's fixed by the tape/print head, not a choice this
makes); "across" just draws the (now width-fixed, see layoutContent) tree through a rotated
canvas transform so it lands correctly in that same raster, rather than transposing every box
the tree itself computed. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
{textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right, {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
top-to-bottom order. */ top-to-bottom order. `codeType` (default "qr"), see buildRenderTree/QR_CODE_TYPES. */
export function drawLabel(canvas, tape, content) { export function drawLabel(canvas, tape, content, orientation = "along", codeType = "qr") {
const maxLength = tape.printLengthPx const maxLength = tape.printLengthPx
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
: Infinity; : Infinity;
const measureCtx = canvas.getContext("2d"); const measureCtx = canvas.getContext("2d");
const pxPerMm = tape.dpi / 25.4; const pxPerMm = tape.dpi / 25.4;
const {tree, width: contentWidth} = layoutContent( const {tree, length: contentLength} = layoutContent(
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm); measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation, codeType);
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX); const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width; canvas.width = printedLength;
canvas.height = tape.printAreaPx; canvas.height = tape.printAreaPx;
const ctx = canvas.getContext("2d", {willReadFrequently: true}); const ctx = canvas.getContext("2d", {willReadFrequently: true});
ctx.fillStyle = "#fff"; ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, width, canvas.height); ctx.fillRect(0, 0, printedLength, canvas.height);
ctx.fillStyle = "#000"; ctx.fillStyle = "#000";
const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2); const origin = tape.leadPx
positionTree(tree, false, originX, 0); + Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2);
const textSizesPx = []; const textSizesPx = [];
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx); if (orientation === "across") {
// The tree was solved width-fixed (see layoutContent) - its width already exactly fills
// tape.printAreaPx, so only its (growing) height needs the same along-the-feed centering
// originX got above; translate+rotate then carries that tree-local (x, y) box straight
// into the physical (printedLength x printAreaPx) raster, a quarter turn at a time.
positionTree(tree, false, 0, origin);
ctx.save();
ctx.translate(0, tape.printAreaPx);
ctx.rotate(-Math.PI / 2);
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
ctx.restore();
} else {
positionTree(tree, false, origin, 0);
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
}
return {textSizesPx}; return {textSizesPx};
} }
@ -365,14 +484,15 @@ const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' m
/* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a /* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a
fixed reference height instead of a real tape's, and with no maxLength (there's no physical fixed reference height instead of a real tape's, and with no maxLength (there's no physical
tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's
no real print head here to keep clear of. Returns {textSizesPx}, see drawLabel. */ no real print head here to keep clear of. `orientation`/`codeType`, see drawLabel. Returns
export function drawFallbackLabel(canvas, content) { {textSizesPx}, see drawLabel. */
export function drawFallbackLabel(canvas, content, orientation = "along", codeType = "qr") {
const measureCtx = canvas.getContext("2d"); const measureCtx = canvas.getContext("2d");
const pxPerMm = FALLBACK_DPI / 25.4; const pxPerMm = FALLBACK_DPI / 25.4;
const {tree, width: contentWidth} = layoutContent( const {tree, length: contentLength} = layoutContent(
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm); measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation, codeType);
canvas.width = Math.ceil(contentWidth); canvas.width = Math.ceil(contentLength);
canvas.height = FALLBACK_LABEL_HEIGHT_PX; canvas.height = FALLBACK_LABEL_HEIGHT_PX;
const ctx = canvas.getContext("2d", {willReadFrequently: true}); const ctx = canvas.getContext("2d", {willReadFrequently: true});
@ -380,9 +500,18 @@ export function drawFallbackLabel(canvas, content) {
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000"; ctx.fillStyle = "#000";
positionTree(tree, false, 0, 0);
const textSizesPx = []; const textSizesPx = [];
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx); if (orientation === "across") {
positionTree(tree, false, 0, 0);
ctx.save();
ctx.translate(0, FALLBACK_LABEL_HEIGHT_PX);
ctx.rotate(-Math.PI / 2);
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
ctx.restore();
} else {
positionTree(tree, false, 0, 0);
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
}
return {textSizesPx}; return {textSizesPx};
} }

View file

@ -24,8 +24,10 @@ import Admin from '@/views/Admin.vue';
import Swatch from '@/views/Swatch.vue'; import Swatch from '@/views/Swatch.vue';
import Files from '@/views/Files.vue'; import Files from '@/views/Files.vue';
import Print from "@/views/Print.vue"; import Print from "@/views/Print.vue";
import Scan from "@/views/Scan.vue";
import Workflows from '@/views/Workflows.vue'; import Workflows from '@/views/Workflows.vue';
import WorkflowDetail from '@/views/WorkflowDetail.vue'; import WorkflowDetail from '@/views/WorkflowDetail.vue';
import ShortId from '@/views/ShortId.vue';
import Account from '@/views/settings/Account.vue'; import Account from '@/views/settings/Account.vue';
import Password from '@/views/settings/Password.vue'; import Password from '@/views/settings/Password.vue';
@ -36,6 +38,14 @@ import Data from '@/views/settings/Data.vue';
import Preferences from '@/views/settings/Preferences.vue'; import Preferences from '@/views/settings/Preferences.vue';
export function encodeHandleForUrl(handle) {
return handle.replace(/#/g, "+");
}
export function decodeHandleFromUrl(segment) {
return segment.replace(/\+/g, "#");
}
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/inventory', path: '/inventory',
component: Inventory, component: Inventory,
@ -58,8 +68,9 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
}, { }, {
path: '/i/:handle/:id', path: '/i/:handle/:id',
redirect: to => { redirect: to => {
const {handle, id} = to.params const handle = decodeHandleFromUrl(to.params.handle)
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + handle + '/' + id const {id} = to.params
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + encodeHandleForUrl(handle) + '/' + id
} }
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, { }, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
path: '/friends', path: '/friends',
@ -98,6 +109,10 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
const {kind, ...components} = route.query; const {kind, ...components} = route.query;
return {prefill: kind ? {kind, components} : null}; return {prefill: kind ? {kind, components} : null};
} }
}, {
path: '/scan',
component: Scan,
meta: {requiresAuth: true},
}, { }, {
path: '/search/:query', path: '/search/:query',
component: Search, component: Search,
@ -145,6 +160,10 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/storage-locations/new', path: '/storage-locations/new',
component: StorageLocationNew, component: StorageLocationNew,
meta: {requiresAuth: true} meta: {requiresAuth: true}
}, {
path: '/:short_id',
component: ShortId,
props: true
}, {path: '/:pathMatch(.*)*', redirect: '/'}] }, {path: '/:pathMatch(.*)*', redirect: '/'}]
const router = createRouter({ const router = createRouter({

217
frontend/src/short-id.js Normal file
View file

@ -0,0 +1,217 @@
const KIND_TAG_BITS = 2
const CHUNK_BITS = 4
const SCHEMAS = {
'0-2': {
name: 'item',
fields: ['owner_identity_id', 'item_local_id'],
interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id})
},
'1-2': {
name: 'storage_location',
fields: ['owner_identity_id', 'storage_location_id'],
interpret: ([owner_identity_id, storage_location_id]) =>
({kind: 'storage_location', owner_identity_id, storage_location_id})
},
'2-1': {
name: 'category',
fields: ['category_id'],
interpret: ([category_id]) => ({kind: 'category', category_id})
},
'3-2': {
name: 'workflow',
fields: ['owner_identity_id', 'workflow_id'],
interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id})
},
'4-1': {
name: 'group',
fields: ['group_id'],
interpret: ([group_id]) => ({kind: 'group', group_id})
},
'5-1': {
name: 'file',
fields: ['file_id'],
interpret: ([file_id]) => ({kind: 'file', file_id})
},
}
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1
class BitWriter {
constructor() {
this.bits = []
}
writeBits(value, count) {
value = BigInt(value)
for (let i = count - 1; i >= 0; i--) {
this.bits.push(Number((value >> BigInt(i)) & 1n))
}
}
toBase64Url() {
const padded = this.bits.slice()
while (padded.length % 6 !== 0) {
padded.push(0)
}
let token = ''
for (let i = 0; i < padded.length; i += 6) {
let n = 0
for (let j = 0; j < 6; j++) {
n = (n << 1) | padded[i + j]
}
token += BASE64URL_ALPHABET[n]
}
return token
}
}
class BitReader {
constructor(bits) {
this.bits = bits
this.pos = 0
}
readBits(count) {
let value = 0n
for (let i = 0; i < count; i++) {
value = (value << 1n) | BigInt(this.bits[this.pos] ?? 0)
this.pos++
}
return value
}
}
function base64UrlToBits(text) {
const bits = []
for (const char of text) {
const n = BASE64URL_ALPHABET.indexOf(char)
if (n === -1) {
throw new Error(`invalid short id: '${char}' is not a URL-safe base64 character`)
}
for (let i = 5; i >= 0; i--) {
bits.push((n >> i) & 1)
}
}
return bits
}
function writeChunkedInt(writer, value, chunkBits) {
value = BigInt(value)
if (value < 0n) {
throw new Error('short id values must be non-negative integers')
}
const mask = (1n << BigInt(chunkBits)) - 1n
const chunks = []
let remaining = value
do {
chunks.unshift(remaining & mask)
remaining >>= BigInt(chunkBits)
} while (remaining > 0n)
chunks.forEach((chunk, i) => {
writer.writeBits(i === chunks.length - 1 ? 0 : 1, 1)
writer.writeBits(chunk, chunkBits)
})
}
function readChunkedInt(reader, chunkBits) {
let value = 0n
for (; ;) {
const more = reader.readBits(1)
const data = reader.readBits(chunkBits)
value = (value << BigInt(chunkBits)) | data
if (more === 0n) {
return value
}
}
}
function entriesForKindIndex(kindIndex) {
return Object.entries(SCHEMAS).filter(([key]) => key.startsWith(`${kindIndex}-`))
}
function schemaForKindIndex(kindIndex) {
const matches = entriesForKindIndex(kindIndex)
if (matches.length === 0) {
throw new Error(`unknown short id kind index: ${kindIndex}`)
}
if (matches.length > 1) {
throw new Error(
`ambiguous short id kind index ${kindIndex}: base-level decoding needs exactly one `
+ `field count per kind, found arities ${matches.map(([, s]) => s.fields.length).join(', ')} `
+ `- only deserializeShortId's (kind, fields.length) lookup can tell those apart`
)
}
return matches[0][1]
}
function schemaForKindAndArity(kindIndex, arity) {
const schema = SCHEMAS[`${kindIndex}-${arity}`]
if (!schema) {
throw new Error(`unknown short id schema for kind ${kindIndex} with ${arity} field value(s)`)
}
return schema
}
export function encodeShortId(ints) {
if (!Array.isArray(ints) || ints.length === 0) {
throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]')
}
const [kindIndex, ...fieldValues] = ints
const schema = schemaForKindIndex(kindIndex)
if (fieldValues.length !== schema.fields.length) {
throw new Error(
`short id kind index ${kindIndex} ('${schema.name}') needs ${schema.fields.length} field value(s), got ${fieldValues.length}`
)
}
const writer = new BitWriter()
if (kindIndex < DIRECT_KIND_COUNT) {
writer.writeBits(kindIndex, KIND_TAG_BITS)
} else {
writer.writeBits(DIRECT_KIND_COUNT, KIND_TAG_BITS)
writeChunkedInt(writer, kindIndex - DIRECT_KIND_COUNT, CHUNK_BITS)
}
for (const value of fieldValues) {
writeChunkedInt(writer, value, CHUNK_BITS)
}
return '~' + writer.toBase64Url()
}
export function decodeShortId(token) {
if (typeof token !== 'string' || !token.startsWith('~')) {
throw new Error("short id must start with '~'")
}
const reader = new BitReader(base64UrlToBits(token.slice(1)))
let kindIndex = Number(reader.readBits(KIND_TAG_BITS))
if (kindIndex === DIRECT_KIND_COUNT) {
kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS))
}
const schema = schemaForKindIndex(kindIndex)
const fieldValues = schema.fields.map(() => Number(readChunkedInt(reader, CHUNK_BITS)))
return [kindIndex, ...fieldValues]
}
export function deserializeShortId(ints) {
const [kindIndex, ...fieldValues] = ints
const schema = schemaForKindAndArity(kindIndex, fieldValues.length)
return schema.interpret(fieldValues)
}
export function serializeShortId({kind, ...fieldValues}) {
const entry = Object.entries(SCHEMAS).find(([, s]) => s.name === kind)
if (!entry) {
throw new Error(`unknown short id kind: ${kind}`)
}
const [key, schema] = entry
const kindIndex = Number(key.split('-')[0])
const values = schema.fields.map(name => {
const value = fieldValues[name]
if (value === undefined) {
throw new Error(`missing field '${name}' for short id kind '${kind}'`)
}
return value
})
return [kindIndex, ...values]
}

View file

@ -0,0 +1,104 @@
import {encodeShortId, decodeShortId, deserializeShortId, serializeShortId} from '../short-id.js'
test('encodes the worked example from docs/handles-and-shortids.md', () => {
const token = encodeShortId([0, 7, 42])
expect(token).toBe('~DyU')
})
test('round-trips every kind as a flat [kindIndex, ...fieldValues] list', () => {
const cases = [
[0, 7, 42],
[1, 3, 1000],
[2, 0],
]
for (const ints of cases) {
const token = encodeShortId(ints)
expect(decodeShortId(token)).toEqual(ints)
}
})
test('round-trips small and large values, including zero', () => {
const values = [0, 1, 7, 42, 100, 10000, 100000, 123456789]
for (const value of values) {
const token = encodeShortId([2, value])
expect(decodeShortId(token)).toEqual([2, value])
}
})
test('tokens are URL-safe: only unreserved characters, plus the leading ~', () => {
const token = encodeShortId([0, 123456, 987654])
expect(token[0]).toBe('~')
expect(token.slice(1)).toMatch(/^[A-Za-z0-9\-_]+$/)
})
test('deserializeShortId names a decoded list into a flat shape, serializeShortId reverses it', () => {
const cases = [
[[0, 7, 42], {kind: 'item', owner_identity_id: 7, item_local_id: 42}],
[[1, 3, 1000], {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}],
[[2, 0], {kind: 'category', category_id: 0}],
[[3, 2, 9], {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}],
[[4, 11], {kind: 'group', group_id: 11}],
[[5, 123], {kind: 'file', file_id: 123}],
]
for (const [ints, named] of cases) {
expect(deserializeShortId(ints)).toEqual(named)
expect(serializeShortId(named)).toEqual(ints)
}
})
test('kinds 3+ round-trip through the escape tag, not just the direct 0-2 range', () => {
for (const named of [
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
{kind: 'group', group_id: 11},
{kind: 'file', file_id: 123},
]) {
const token = encodeShortId(serializeShortId(named))
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
}
})
test('serializeShortId then deserializeShortId round-trips through encode/decode', () => {
const token = encodeShortId(serializeShortId({kind: 'item', owner_identity_id: 7, item_local_id: 42}))
expect(token).toBe('~DyU')
expect(deserializeShortId(decodeShortId(token))).toEqual({
kind: 'item',
owner_identity_id: 7,
item_local_id: 42
})
})
test('rejects a token missing the leading ~', () => {
expect(() => decodeShortId('DyU')).toThrow()
})
test('rejects an unknown kind index', () => {
expect(() => encodeShortId([99, 1])).toThrow()
})
test('deserializeShortId is keyed by (kind, fields.length), not kind alone', () => {
// kind=1 with the arity storage_location actually has (2 fields) is fine...
expect(deserializeShortId([1, 3, 1000])).toEqual({
kind: 'storage_location',
owner_identity_id: 3,
storage_location_id: 1000
})
// ...but kind=1 with a different arity (1 field) isn't a registered schema at all, and must
// not be silently misread as a truncated storage_location.
expect(() => deserializeShortId([1, 359])).toThrow()
})
test('rejects an unknown kind name', () => {
expect(() => serializeShortId({kind: 'not_a_kind'})).toThrow()
})
test('rejects a field count mismatch', () => {
expect(() => encodeShortId([0, 7])).toThrow()
})
test('rejects a missing field', () => {
expect(() => serializeShortId({kind: 'item', owner_identity_id: 7})).toThrow()
})
test('rejects a negative field value', () => {
expect(() => encodeShortId([2, -1])).toThrow()
})

View file

@ -46,6 +46,35 @@
</div> </div>
</div> </div>
<!--div class="card mb-3 border-warning">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Experimental: new font candidates</h5>
<small class="text-muted">for judging legibility - not part of the real label pipeline</small>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
are actually just the browser's fallback serif, not a rendering bug in this page.
<strong>Chava</strong> is uppercase-only, same dealbreaker as PICO-8.
<strong>6pxExpert</strong> only has pixel glyphs for digits; its letters are the same
fallback serif.
</p>
<div v-for="cfg in candidateFonts" :key="cfg.family" class="mb-3">
<div class="fw-bold small mb-1">{{ cfg.family }}</div>
<div class="d-flex flex-wrap gap-3">
<div v-for="size in candidateSizes" :key="cfg.family + '-' + size" class="text-center">
<div class="small text-muted mb-1">{{ size }}px</div>
<canvas :ref="el => setCandidateCanvasRef(cfg.family + '-' + size, el)"
class="pixel-test-canvas"></canvas>
</div>
</div>
</div>
</div>
</div-->
<div v-if="!usbSupported" class="row"> <div v-if="!usbSupported" class="row">
<div class="col-lg-7"> <div class="col-lg-7">
<div class="card"> <div class="card">
@ -57,6 +86,37 @@
<canvas ref="fallbackCanvas"></canvas> <canvas ref="fallbackCanvas"></canvas>
</div> </div>
<div class="mb-3">
<label class="form-label">Orientation</label>
<div class="btn-group d-block" role="group">
<input type="radio" class="btn-check" id="orientation-along-fallback"
autocomplete="off" value="along" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-along-fallback">
Along tape
</label>
<input type="radio" class="btn-check" id="orientation-across-fallback"
autocomplete="off" value="across" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-across-fallback">
Across tape
</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Code type</label>
<div class="btn-group d-block" role="group">
<template v-for="opt in codeTypeOptions" :key="opt.id">
<input type="radio" class="btn-check"
:id="'code-type-' + opt.id + '-fallback'"
autocomplete="off" :value="opt.id" v-model="codeType">
<label class="btn btn-outline-secondary"
:for="'code-type-' + opt.id + '-fallback'">
{{ opt.label }}
</label>
</template>
</div>
</div>
<button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng"> <button class="btn btn-primary" :disabled="!fallbackReady" @click="downloadPng">
<b-icon-download class="me-1"></b-icon-download> <b-icon-download class="me-1"></b-icon-download>
Download label as PNG Download label as PNG
@ -132,6 +192,34 @@
</div> </div>
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Orientation</label>
<div class="btn-group" role="group">
<input type="radio" class="btn-check" id="orientation-along"
autocomplete="off" value="along" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-along">
Along tape
</label>
<input type="radio" class="btn-check" id="orientation-across"
autocomplete="off" value="across" v-model="orientation">
<label class="btn btn-outline-secondary" for="orientation-across">
Across tape
</label>
</div>
</div>
<div class="col-auto">
<label class="form-label">Code type</label>
<div class="btn-group" role="group">
<template v-for="opt in codeTypeOptions" :key="opt.id">
<input type="radio" class="btn-check"
:id="'code-type-' + opt.id" autocomplete="off"
:value="opt.id" v-model="codeType">
<label class="btn btn-outline-secondary" :for="'code-type-' + opt.id">
{{ opt.label }}
</label>
</template>
</div>
</div>
<div class="col-auto"> <div class="col-auto">
<label class="form-label">Copies</label> <label class="form-label">Copies</label>
<input type="number" class="form-control copies-input" <input type="number" class="form-control copies-input"
@ -187,7 +275,7 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<label-layout-preview :fields="fields" :value="selectedTemplate" <label-layout-preview :fields="fields" :value="selectedTemplate" :code-type="codeType"
@input="selectedTemplate = $event"></label-layout-preview> @input="selectedTemplate = $event"></label-layout-preview>
</div> </div>
</div> </div>
@ -203,7 +291,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue"; import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js"; import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields} from "@/label.js"; import {tapeFromStatus, drawLabel, drawFallbackLabel, buildLabelContent, buildLabelFields, preloadQrEncoder, QR_CODE_TYPES} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js"; import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, withDerivedVars, templateContent} from "@/label-layouts.js";
// Served verbatim from public/vendor/ rather than bundled: libweblabel.js's // Served verbatim from public/vendor/ rather than bundled: libweblabel.js's
@ -226,6 +314,15 @@ const RULER_TIERS = [
{aboveMm: 500, tickMm: 5, majorEveryMm: 25}, {aboveMm: 500, tickMm: 5, majorEveryMm: 25},
]; ];
// Display labels for label.js's QR_CODE_TYPES - the code-type button group below reads this
// (via codeTypeOptions) rather than hard-coding its own list, so a new symbology added to
// anyd-qr.js/QR_CODE_TYPES only needs a label here, not a whole new radio group.
const CODE_TYPE_LABELS = {
"qr": "QR",
"micro-qr": "Micro QR",
"rmqr": "rMQR",
};
export default { export default {
name: "Print", name: "Print",
components: { components: {
@ -283,11 +380,40 @@ export default {
}, },
copies: 1, copies: 1,
selectedTemplate: LABEL_TEMPLATES[0].id, selectedTemplate: LABEL_TEMPLATES[0].id,
// "along" draws a layout reading along the tape's feed direction (the usual case -
// constrained by the tape's cross-web width, growing as long as the content needs);
// "across" turns it 90deg, constrained by that same width but along the *other* axis
// instead, so it reads across the tape rather than along it. See label.js's
// drawLabel/drawFallbackLabel for how that turn is actually drawn.
orientation: "along",
// Which of label.js's QR_CODE_TYPES every "qrcode" leaf in the current template
// renders as - one global choice for the whole label, same as `orientation` above,
// since a template only ever has one scannable code in practice.
codeType: "qr",
fallbackReady: false, fallbackReady: false,
// TODO: replace with the real commands for our printers. // TODO: replace with the real commands for our printers.
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png", brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png", niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
// Nine pixel/bitmap-style font candidates found in frontend/public (see
// ../assets/fonts/pixel-candidates/LICENSE.md) - none of these are used by label.js's
// real PIXEL_FONT_TIERS; this card exists purely so they can be judged the same way
// Tom Thumb/Silkscreen were, against real (live-typed) content instead of a fixed
// example.
candidateFonts: [
{family: "Pixelon"},
{family: "Pixelbasel"},
{family: "Chava"},
{family: "CodersCrux"},
{family: "712Serif"},
{family: "6pxNormal"},
{family: "6pxExpert"},
{family: "Jersey10"},
{family: "Jersey15"},
],
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
candidateZoom: 4,
}; };
}, },
computed: { computed: {
@ -297,6 +423,11 @@ export default {
baseVars() { baseVars() {
return BASE_VARS; return BASE_VARS;
}, },
// {id, label} pairs for the code-type button group (see CODE_TYPE_LABELS above), in
// label.js's QR_CODE_TYPES order.
codeTypeOptions() {
return QR_CODE_TYPES.map(id => ({id, label: CODE_TYPE_LABELS[id]}));
},
derivedVars() { derivedVars() {
return Object.keys(DERIVED_VARS); return Object.keys(DERIVED_VARS);
}, },
@ -397,6 +528,7 @@ export default {
} else { } else {
this.redrawFallback(); this.redrawFallback();
} }
this.drawCandidateFontTests();
}, },
deep: true, deep: true,
}, },
@ -407,6 +539,20 @@ export default {
this.redrawFallback(); this.redrawFallback();
} }
}, },
orientation() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
codeType() {
if (this.usbSupported) {
this.redraw();
} else {
this.redrawFallback();
}
},
// Covers connecting/disconnecting/switching printers - anything that changes the // Covers connecting/disconnecting/switching printers - anything that changes the
// tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas // tape dimensions redraw() sizes the canvas from. flush: 'post' because the canvas
// itself only exists once `tape` is truthy (see the v-if/v-else in the template), so // itself only exists once `tape` is truthy (see the v-if/v-else in the template), so
@ -517,7 +663,7 @@ export default {
this.resizeObserver.observe(canvas.parentElement); this.resizeObserver.observe(canvas.parentElement);
let textSizesPx; let textSizesPx;
try { try {
({textSizesPx} = drawLabel(canvas, this.tape, content)); ({textSizesPx} = drawLabel(canvas, this.tape, content, this.orientation, this.codeType));
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
return; return;
@ -543,7 +689,7 @@ export default {
} }
this.resizeObserver.observe(canvas.parentElement); this.resizeObserver.observe(canvas.parentElement);
try { try {
drawFallbackLabel(canvas, content); drawFallbackLabel(canvas, content, this.orientation, this.codeType);
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
return; return;
@ -575,7 +721,17 @@ export default {
if (!(available > 0)) { if (!(available > 0)) {
return 1; return 1;
} }
const zoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height); const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
// When magnifying, round DOWN to a whole number: image-rendering:pixelated below
// only actually looks crisp when every source pixel maps to the *same* number of
// screen pixels - at a fractional zoom (the overwhelmingly common case, since rawZoom
// is just whatever ratio the tape/card happen to produce) some source pixels get
// rounded up to one extra screen pixel and others don't, unevenly warping fine,
// already-pixel-perfect detail like a crisp QR module or a tiny bitmap font glyph.
// Flooring (never rounding/ceiling) keeps the same "never bigger than available
// space" guarantee rawZoom already had. Shrinking (zoom < 1) has no equivalent "whole
// factor" to snap to - downsampling always blends source pixels - so it's left as-is.
const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
canvas.style.width = `${canvas.width * zoom}px`; canvas.style.width = `${canvas.width * zoom}px`;
canvas.style.height = `${canvas.height * zoom}px`; canvas.style.height = `${canvas.height * zoom}px`;
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto"; canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
@ -607,17 +763,75 @@ export default {
onUsbChange() { onUsbChange() {
this.guard(() => this.refreshDevices()); this.guard(() => this.refreshDevices());
}, },
// A plain :ref="key" inside v-for would still get Vue's refInFor array-collecting
// behavior (see LabelLayoutPreview.vue's setTemplateCanvasRef for the same pattern), so
// this keys the canvases by the caller's own composite key string explicitly instead.
setCandidateCanvasRef(key, el) {
if (el) {
this.candidateCanvases[key] = el;
} else {
delete this.candidateCanvases[key];
}
},
// Draws the live "Text" field's value into a single canvas at the exact size/family
// given - no layout math, no snapping, just ctx.font as requested, so what's judged here
// is the font itself rather than anything label.js's real pipeline does to it. Canvas size
// is measured from the text itself, and ink-centered vertically (see label.js's
// drawTextLeaf for the same idea) so a font with unreliable declared metrics still lands
// fully inside the canvas instead of clipped off the top/bottom.
drawCandidateCell(canvas, family, fontPx) {
if (!canvas) {
return;
}
const text = this.varValues.text;
const ctx = canvas.getContext("2d");
ctx.font = `${fontPx}px "${family}"`;
const {width, actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(text);
canvas.width = Math.ceil(width) + 4;
canvas.height = Math.ceil(up + down) + 8;
// Re-set font/alignment - changing canvas.width/height above resets the 2D context.
ctx.font = `${fontPx}px "${family}"`;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";
ctx.fillText(text, 2, (canvas.height + up - down) / 2);
canvas.style.width = `${canvas.width * this.candidateZoom}px`;
canvas.style.height = `${canvas.height * this.candidateZoom}px`;
},
// Draws every (candidateFonts x candidateSizes) cell - see the card in the template.
async drawCandidateFontTests() {
await Promise.all(this.candidateFonts
.map(({family}) => document.fonts.load(`20px "${family}"`).catch(() => {})));
for (const {family} of this.candidateFonts) {
for (const size of this.candidateSizes) {
const canvas = this.candidateCanvases[`${family}-${size}`];
this.drawCandidateCell(canvas, family, size);
}
}
},
}, },
created() { created() {
this.blob = null; this.blob = null;
this.resizeObserver = null; this.resizeObserver = null;
this.resizeTimer = null; this.resizeTimer = null;
this.candidateCanvases = {};
}, },
async mounted() { async mounted() {
this.drawCandidateFontTests();
this.resizeObserver = new ResizeObserver(this.handleContainerResize); this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Kicked off here rather than awaited immediately, so it loads concurrently with
// MultiPrinterBlob below instead of serializing two independent wasm fetches - every
// redraw()/redrawFallback() call below still waits on it first, since a "qrcode" leaf
// throws (see label.js's encodeQr) until it resolves.
const qrReady = preloadQrEncoder();
if (!("usb" in navigator)) { if (!("usb" in navigator)) {
this.usbSupported = false; this.usbSupported = false;
await nextTick(); await Promise.all([nextTick(), qrReady]);
this.redrawFallback(); this.redrawFallback();
return; return;
} }
@ -630,6 +844,7 @@ export default {
this.blobReady = true; this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange); navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange); navigator.usb.addEventListener("disconnect", this.onUsbChange);
await qrReady;
await this.guard(() => this.refreshDevices()); await this.guard(() => this.refreshDevices());
}, },
beforeUnmount() { beforeUnmount() {
@ -774,6 +989,13 @@ export default {
width: 5.5rem; width: 5.5rem;
} }
.pixel-test-canvas {
display: block;
background: #fff;
box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
image-rendering: pixelated;
}
.code-block { .code-block {
background: rgba(127, 127, 127, .08); background: rgba(127, 127, 127, .08);
border-radius: .35rem; border-radius: .35rem;

435
frontend/src/views/Scan.vue Normal file
View file

@ -0,0 +1,435 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Scan a code</h1>
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!insecureContext" class="alert alert-warning">
This page isn't in a secure context ({{ insecureOrigin }}), so the camera API is
unavailable. From another device, use https:// (or http://localhost) instead.
</div>
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from image</h5>
</div>
<div class="card-body">
<input ref="fileInput" type="file" accept="image/*" class="form-control mb-3"
@change="onFileInputChange">
<div class="dropzone" :class="{'dropzone-hover': dropHover}"
@dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
Drag &amp; drop an image here, paste one, or use the file picker above.
</div>
<div class="scan-canvas-wrap" v-show="hasFileImage">
<canvas ref="fileCanvas" class="scan-canvas"></canvas>
</div>
<ul class="scan-results">
<li v-if="hasFileImage && !fileResults.length" class="scan-results-empty">
No codes found.
</li>
<li v-for="(r, i) in fileResults" :key="i">
<span class="scan-result-type">{{ r.type }}</span>
<span v-if="r.metaText" class="text-muted"> {{ r.metaText }}</span>
<div class="text-break">{{ r.text }}</div>
</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<p v-if="!cameraSupported" class="text-muted">
This browser has no camera API available.
</p>
<template v-else>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto flex-grow-1">
<label class="form-label">Camera</label>
<select class="form-control" v-model="selectedCameraId"
:disabled="cameraRunning">
<option value="">Auto (prefer rear camera)</option>
<option v-for="(c, i) in cameras" :key="c.deviceId"
:value="c.deviceId">
{{ c.label || ('Camera ' + (i + 1)) }}
</option>
</select>
</div>
<div class="col-auto">
<button v-if="!cameraRunning" class="btn btn-primary"
:disabled="!insecureContext" @click="startCamera">
<b-icon-camera class="me-1"></b-icon-camera>
Start camera
</button>
<button v-else class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
Stop
</button>
</div>
</div>
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
<div class="video-wrap">
<video ref="video" autoplay muted playsinline></video>
<canvas ref="overlay"></canvas>
</div>
</template>
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">{{ c.text }}</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js";
// A decode result's metadata (see anyd-qr.js's SymbolMetadata) as one short, human-readable
// string, e.g. "(v7, ec=M, mask=3)" - shared by both the "from image" and "from camera" result
// lists below rather than each formatting it its own way.
function metaSummary(metadata) {
const parts = [];
if (metadata.version != null) {
parts.push(`v${metadata.version}`);
}
if (metadata.size) {
parts.push(metadata.size);
}
if (metadata.ecc) {
parts.push(`ec=${metadata.ecc}`);
}
if (metadata.mask != null) {
parts.push(`mask=${metadata.mask}`);
}
return parts.length ? `(${parts.join(", ")})` : "";
}
// How long a camera-detected code's bounding box stays drawn on the overlay before fading, so a
// one-off decode doesn't leave a stale box on screen once the code's moved out of frame.
const OVERLAY_CLEAR_MS = 2000;
export default {
name: "Scan",
components: {
BaseLayout,
...BIcons
},
data() {
return {
error: null,
insecureContext: window.isSecureContext,
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
dropHover: false,
hasFileImage: false,
fileResults: [],
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
cameras: [],
selectedCameraId: "",
cameraRunning: false,
cameraRes: "",
cameraLog: [],
};
},
methods: {
// Draws `file`/a pasted or dropped Blob onto fileCanvas and decodes whatever's in it -
// shared by the file input, drag&drop and paste handlers below rather than each
// duplicating the createImageBitmap/getImageData/decodeImage sequence.
async decodeBlob(file) {
this.error = null;
try {
const bitmap = await createImageBitmap(file);
const canvas = this.$refs.fileCanvas;
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const anyd = await this.anydPromise;
const results = anyd.decodeImage(imageData);
this.drawBoxes(ctx, results);
this.hasFileImage = true;
this.fileResults = results.map(r => ({...r, metaText: metaSummary(r.metadata)}));
} catch (e) {
this.error = e.message ?? String(e);
}
},
drawBoxes(ctx, results) {
ctx.lineWidth = Math.max(2, ctx.canvas.width / 300);
ctx.strokeStyle = "#3ce77c";
ctx.fillStyle = "#3ce77c";
ctx.font = `${Math.max(14, ctx.canvas.width / 60)}px sans-serif`;
for (const r of results) {
if (!r.box) {
continue;
}
const {x0, y0, x1, y1} = r.box;
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
ctx.fillText(r.type, x0, Math.max(14, y0 - 4));
}
},
onFileInputChange() {
const file = this.$refs.fileInput.files[0];
if (file) {
this.decodeBlob(file);
}
},
onDragOver(e) {
e.preventDefault();
this.dropHover = true;
},
onDragLeave() {
this.dropHover = false;
},
onDrop(e) {
e.preventDefault();
this.dropHover = false;
const file = e.dataTransfer.files[0];
if (file) {
this.decodeBlob(file);
}
},
onPaste(e) {
const item = [...(e.clipboardData?.items ?? [])].find(i => i.type.startsWith("image/"));
if (item) {
this.decodeBlob(item.getAsFile());
}
},
// Device labels are only populated once camera permission has actually been granted, so
// this is called again right after getUserMedia resolves in startCamera, not just once
// up front.
async populateCameraList() {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
this.cameras = devices.filter(d => d.kind === "videoinput");
},
syncOverlaySize() {
const video = this.$refs.video;
const overlay = this.$refs.overlay;
if (!video || !overlay) {
return;
}
overlay.width = video.clientWidth;
overlay.height = video.clientHeight;
},
logDecode(code) {
this.cameraLog.unshift({
type: code.type,
text: code.text ?? "",
metaText: metaSummary(code.metadata),
time: new Date().toLocaleTimeString(),
});
// Caps the log rather than letting it grow forever - old entries are trimmed off the
// end, same as CameraScanner's own dedupe window makes them stale for re-matching.
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
},
// Draws every detected code's box (in the video's own native pixel space, scaled to
// however large the overlay is actually displayed) and fades them out after
// OVERLAY_CLEAR_MS - a one-off decode shouldn't leave a stale box on screen once the code
// has moved out of frame.
drawOverlay(codes) {
const overlay = this.$refs.overlay;
const video = this.$refs.video;
if (!overlay || !video || !video.videoWidth) {
return;
}
const ctx = overlay.getContext("2d");
ctx.clearRect(0, 0, overlay.width, overlay.height);
const scaleX = overlay.width / video.videoWidth;
const scaleY = overlay.height / video.videoHeight;
ctx.lineWidth = 3;
ctx.strokeStyle = "#3ce77c";
ctx.fillStyle = "#3ce77c";
ctx.font = "14px sans-serif";
for (const code of codes) {
if (!code.box) {
continue;
}
const {x0, y0, x1, y1} = code.box;
ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
}
clearTimeout(this.clearOverlayTimer);
this.clearOverlayTimer = setTimeout(
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
},
async startCamera() {
this.error = null;
try {
const constraints = {
video: this.selectedCameraId
? {deviceId: {exact: this.selectedCameraId}}
: {facingMode: {ideal: "environment"}},
audio: false,
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
const video = this.$refs.video;
video.srcObject = this.stream;
await video.play();
this.syncOverlaySize();
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
await this.populateCameraList();
const anyd = await this.anydPromise;
this.scanner = anyd.createCameraScanner(video, {
fps: 12,
downscale: 2,
onDecode: (codes) => {
codes.forEach(this.logDecode);
this.drawOverlay(codes);
},
onError: (err) => console.error("[camera scan]", err),
});
this.scanner.start();
this.cameraRunning = true;
} catch (e) {
this.error = `Camera error: ${e.message ?? e}`;
}
},
stopCamera() {
this.scanner?.stop();
this.scanner = null;
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
if (this.$refs.video) {
this.$refs.video.srcObject = null;
}
this.cameraRunning = false;
const overlay = this.$refs.overlay;
if (overlay) {
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
}
},
},
created() {
// Kept as a promise (rather than awaited here) so every caller - decodeBlob, startCamera -
// shares the same in-flight load instead of each triggering its own; loadAnyDCode's own
// memoization (see anyd-qr.js) means a second call anywhere else in the app is free too.
this.anydPromise = loadAnyDCode();
this.stream = null;
this.scanner = null;
this.clearOverlayTimer = null;
},
mounted() {
window.addEventListener("paste", this.onPaste);
window.addEventListener("resize", this.syncOverlaySize);
this.populateCameraList();
},
beforeUnmount() {
this.stopCamera();
window.removeEventListener("paste", this.onPaste);
window.removeEventListener("resize", this.syncOverlaySize);
clearTimeout(this.clearOverlayTimer);
},
}
</script>
<style scoped>
.dropzone {
margin-bottom: .75rem;
border: 2px dashed var(--bs-gray-400);
border-radius: .35rem;
padding: 1.25rem;
text-align: center;
color: var(--bs-gray-600);
font-size: .9rem;
}
.dropzone-hover {
border-color: var(--bs-primary);
color: var(--bs-primary);
}
.scan-canvas-wrap {
text-align: center;
margin-bottom: .75rem;
}
.scan-canvas {
max-width: 100%;
border-radius: .35rem;
border: 1px solid var(--bs-gray-300);
}
.video-wrap {
position: relative;
margin-bottom: .75rem;
background: #000;
border-radius: .35rem;
overflow: hidden;
}
.video-wrap video {
display: block;
width: 100%;
height: auto;
}
.video-wrap canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.scan-results {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: .4rem;
max-height: 260px;
overflow-y: auto;
}
.scan-results li {
border: 1px solid var(--bs-gray-300);
border-radius: .35rem;
padding: .4rem .6rem;
font-size: .85rem;
}
.scan-result-type {
font-weight: 600;
color: var(--bs-success);
}
.scan-results-empty {
color: var(--bs-gray-600);
border-style: dashed;
}
</style>

View file

@ -0,0 +1,143 @@
<template>
<BaseLayout hide-search>
<main class="content">
<div class="container-fluid p-0">
<div class="card">
<div class="card-header">Decoded</div>
<div class="card-body">
<ul>
<li v-for="(value, index) in decoded" :key="index">{{ value }}</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header">Deserialized</div>
<div class="card-body">
<p>kind: {{ deserialized.kind }}</p>
<ul>
<li v-for="(value, name) in fields" :key="name">{{ name }}: {{ value }}</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header">Examples</div>
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Kind</th>
<th>Fields</th>
<th>Serialized</th>
<th>Bits</th>
<th>Token</th>
</tr>
</thead>
<tbody>
<tr v-for="example in examples" :key="example.token">
<td>{{ example.kind }}</td>
<td>
<span v-for="(value, name) in example.fields" :key="name" class="me-2">
{{ name }}: {{ value }}
</span>
</td>
<td>[{{ example.ints.join(', ') }}]</td>
<td><code>{{ example.bits }}</code></td>
<td>
<router-link :to="'/' + example.token">{{ example.token }}</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import BaseLayout from '@/components/BaseLayout.vue';
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
const EXAMPLES = [
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
{kind: 'category', category_id: 5},
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
{kind: 'group', group_id: 11},
{kind: 'file', file_id: 123},
];
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
// rules (2-bit kind tag with an all-ones escape, 4-bit continuation chunks, see
// docs/handles-and-shortids.md) just to show them, recomputed straight from an already-serialized
// `ints` list rather than re-parsing a token.
const KIND_TAG_BITS = 2;
const CHUNK_BITS = 4;
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1;
function chunkedIntBits(value, chunkBits) {
value = BigInt(value);
const mask = (1n << BigInt(chunkBits)) - 1n;
const chunks = [];
let remaining = value;
do {
chunks.unshift(remaining & mask);
remaining >>= BigInt(chunkBits);
} while (remaining > 0n);
return chunks.map((chunk, i) => {
const continuation = i === chunks.length - 1 ? '0' : '1';
return continuation + chunk.toString(2).padStart(chunkBits, '0');
}).join('');
}
function shortIdBitSegments(ints) {
const [kindIndex, ...fieldValues] = ints;
const segments = [];
if (kindIndex < DIRECT_KIND_COUNT) {
segments.push(kindIndex.toString(2).padStart(KIND_TAG_BITS, '0'));
} else {
segments.push('1'.repeat(KIND_TAG_BITS));
segments.push(chunkedIntBits(kindIndex - DIRECT_KIND_COUNT, CHUNK_BITS));
}
fieldValues.forEach(value => segments.push(chunkedIntBits(value, CHUNK_BITS)));
const totalBits = segments.join('').length;
const paddingBits = (6 - (totalBits % 6)) % 6;
if (paddingBits > 0) {
segments.push('0'.repeat(paddingBits));
}
return segments.join(' ');
}
export default {
name: 'ShortId',
components: {
BaseLayout
},
props: {
short_id: {
type: String,
required: true
}
},
computed: {
decoded() {
return decodeShortId(this.short_id);
},
deserialized() {
return deserializeShortId(this.decoded);
},
fields() {
const {kind, ...fields} = this.deserialized;
return fields;
},
examples() {
return EXAMPLES.map(named => {
const {kind, ...fields} = named;
const ints = serializeShortId(named);
const token = encodeShortId(ints);
return {kind, fields, ints, token, bits: shortIdBitSegments(ints)};
});
}
}
}
</script>

1
frontend/vendor/anyd-qr.js vendored Normal file

File diff suppressed because one or more lines are too long