feat: add barcode detection support with zxing-wasm and barcode-detector

This commit is contained in:
2026-08-07 17:09:57 +07:00
parent e9d4e41f7f
commit 7d98bb00fd
6 changed files with 133 additions and 35 deletions
+3 -1
View File
@@ -22,10 +22,12 @@
"@robonen/platform": "^0.0.5", "@robonen/platform": "^0.0.5",
"@robonen/stdlib": "^0.0.12", "@robonen/stdlib": "^0.0.12",
"@robonen/vue": "^0.2.0", "@robonen/vue": "^0.2.0",
"barcode-detector": "^3.2.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"lucide": "^1.29.0", "lucide": "^1.29.0",
"vue": "3.6.0-rc.2", "vue": "3.6.0-rc.2",
"vue-sync-engine": "link:../vue-sync-engine/lib" "vue-sync-engine": "link:../vue-sync-engine/lib",
"zxing-wasm": "3.1.1"
}, },
"devDependencies": { "devDependencies": {
"@robonen/eslint": "^0.0.1", "@robonen/eslint": "^0.0.1",
+44
View File
@@ -23,6 +23,9 @@ importers:
'@robonen/vue': '@robonen/vue':
specifier: ^0.2.0 specifier: ^0.2.0
version: 0.2.0(vue@3.6.0-rc.2(typescript@6.0.3)) version: 0.2.0(vue@3.6.0-rc.2(typescript@6.0.3))
barcode-detector:
specifier: ^3.2.1
version: 3.2.1(@types/emscripten@1.41.5)
date-fns: date-fns:
specifier: ^4.4.0 specifier: ^4.4.0
version: 4.4.0 version: 4.4.0
@@ -35,6 +38,9 @@ importers:
vue-sync-engine: vue-sync-engine:
specifier: link:../vue-sync-engine/lib specifier: link:../vue-sync-engine/lib
version: link:../vue-sync-engine/lib version: link:../vue-sync-engine/lib
zxing-wasm:
specifier: 3.1.1
version: 3.1.1(@types/emscripten@1.41.5)
devDependencies: devDependencies:
'@robonen/eslint': '@robonen/eslint':
specifier: ^0.0.1 specifier: ^0.0.1
@@ -1242,6 +1248,9 @@ packages:
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/emscripten@1.41.5':
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
'@types/esrecurse@4.3.1': '@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -1699,6 +1708,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
barcode-detector@3.2.1:
resolution: {integrity: sha512-zLL7AbT9uNJBUzYpKg9v5tUA4yQGReExSi60q0g660Mj0wjUhKmN0GrUF3qELo8ITW9THzyJXdZQkXLMnsieiw==}
baseline-browser-mapping@2.11.12: baseline-browser-mapping@2.11.12:
resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -2949,6 +2961,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.3: tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -3007,6 +3023,10 @@ packages:
resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==}
engines: {node: '>=10'} engines: {node: '>=10'}
type-fest@5.8.0:
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3355,6 +3375,11 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zxing-wasm@3.1.1:
resolution: {integrity: sha512-g0sPJBIubO6zLcJh1jftLPIN6xziaqLsvLgtpGKwDrEhyXXqla3E3yjFrznlr78UHIOMzbJPi0HDWKs/KgaB7A==}
peerDependencies:
'@types/emscripten': '>=1.39.6'
snapshots: snapshots:
'@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)':
@@ -4513,6 +4538,8 @@ snapshots:
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/emscripten@1.41.5': {}
'@types/esrecurse@4.3.1': {} '@types/esrecurse@4.3.1': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
@@ -4982,6 +5009,12 @@ snapshots:
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
barcode-detector@3.2.1(@types/emscripten@1.41.5):
dependencies:
zxing-wasm: 3.1.1(@types/emscripten@1.41.5)
transitivePeerDependencies:
- '@types/emscripten'
baseline-browser-mapping@2.11.12: {} baseline-browser-mapping@2.11.12: {}
boolbase@1.0.0: {} boolbase@1.0.0: {}
@@ -6325,6 +6358,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
tagged-tag@1.0.0: {}
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
tapable@2.3.3: {} tapable@2.3.3: {}
@@ -6375,6 +6410,10 @@ snapshots:
type-fest@0.16.0: {} type-fest@0.16.0: {}
type-fest@5.8.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3: typed-array-buffer@1.0.3:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -6786,3 +6825,8 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zxing-wasm@3.1.1(@types/emscripten@1.41.5):
dependencies:
'@types/emscripten': 1.41.5
type-fest: 5.8.0
+70 -19
View File
@@ -1,20 +1,57 @@
import { onMounted, onUnmounted, shallowRef } from 'vue'; import { onMounted, onUnmounted, shallowRef } from 'vue';
import { useRef } from 'vue-jsx-vapor'; import { useRef } from 'vue-jsx-vapor';
import type { BarcodeFormat, DetectedBarcode } from 'barcode-detector/ponyfill';
/** Есть ли в этом браузере нативное распознавание штрихкодов (Chromium). */ // Только URL: сам wasm в бандл не попадает, он эмитится отдельным файлом и
export function isBarcodeScanSupported(): boolean { // скачивается лишь тогда, когда до него дойдёт дело. Версия zxing-wasm в
return typeof BarcodeDetector !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; // package.json приколочена намертво — обвязка и .wasm обязаны быть из одного
} // релиза, иначе модуль не инициализируется.
import wasmUrl from 'zxing-wasm/reader/zxing_reader.wasm?url';
const SCAN_INTERVAL_MS = 300;
/** /**
* Камера + BarcodeDetector: распознали EAN/UPC — отдали наверх и погасили * Сканер доступен везде, где есть камера. Нативный Shape Detection API —
* камеру. Компонент монтируется только после isBarcodeScanSupported(). * только в Chromium; в WebKit (Safari и любой браузер на iOS, включая Chrome)
* BarcodeDetector не реализован, там работает wasm-сборка zxing.
*/
export function isBarcodeScanSupported(): boolean {
return !!navigator.mediaDevices?.getUserMedia;
}
const FORMATS: BarcodeFormat[] = ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'];
const SCAN_INTERVAL_MS = 300;
interface Detector {
detect: (source: HTMLVideoElement) => Promise<DetectedBarcode[]>;
}
type NativeDetector = new (options?: { formats?: BarcodeFormat[] }) => Detector;
/**
* Нативный детектор бесплатен и энергоэффективнее, поэтому wasm подключается
* только там, где своего распознавания нет.
*/
async function createDetector(): Promise<Detector> {
const Native = (globalThis as { BarcodeDetector?: NativeDetector }).BarcodeDetector;
if (Native) return new Native({ formats: FORMATS });
const { BarcodeDetector, prepareZXingModule } = await import('barcode-detector/ponyfill');
// По умолчанию zxing тянет .wasm с CDN — это ломало бы сканер офлайн и
// упиралось бы в CSP, поэтому подставляем файл из собственной сборки.
await prepareZXingModule({
overrides: {
locateFile: (path: string, prefix: string) => (path.endsWith('.wasm') ? wasmUrl : `${prefix}${path}`),
},
fireImmediately: true,
});
return new BarcodeDetector({ formats: FORMATS });
}
/**
* Камера + детектор: распознали EAN/UPC — отдали наверх и погасили камеру.
*/ */
export default function BarcodeScanner(props: { onDetected: (code: string) => void; onCancel: () => void }) { export default function BarcodeScanner(props: { onDetected: (code: string) => void; onCancel: () => void }) {
const videoEl = useRef(); const videoEl = useRef();
const error = shallowRef(''); const error = shallowRef('');
const loading = shallowRef(true);
let stream: MediaStream | null = null; let stream: MediaStream | null = null;
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
@@ -32,15 +69,18 @@ export default function BarcodeScanner(props: { onDetected: (code: string) => vo
if (!video) return; if (!video) return;
try { try {
stream = await navigator.mediaDevices.getUserMedia({ stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }, video: { facingMode: { ideal: 'environment' } },
audio: false, audio: false,
}); });
// Пока ждали разрешение, лист мог закрыться — поток нужно погасить.
if (finished) return;
video.srcObject = stream; video.srcObject = stream;
await video.play(); await video.play();
const detector = new BarcodeDetector({ const detector = await createDetector();
formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'], if (finished) return;
}); loading.value = false;
timer = setInterval(async () => { timer = setInterval(async () => {
if (finished || video.readyState < 2) return; if (finished || video.readyState < 2) return;
try { try {
@@ -58,26 +98,37 @@ export default function BarcodeScanner(props: { onDetected: (code: string) => vo
}, SCAN_INTERVAL_MS); }, SCAN_INTERVAL_MS);
} }
catch (cause) { catch (cause) {
error.value = cause instanceof DOMException && cause.name === 'NotAllowedError' if (cause instanceof DOMException && cause.name === 'NotAllowedError') {
? 'Нет доступа к камере — разрешите его в настройках браузера.' error.value = 'Нет доступа к камере — разрешите его в настройках браузера.';
: 'Не удалось включить камеру.'; }
else if (stream) {
// Камера уже работает, значит споткнулись на загрузке распознавания.
error.value = 'Не удалось загрузить распознавание штрихкодов. Введите цифры вручную.';
}
else {
error.value = 'Не удалось включить камеру.';
}
stop();
} }
}); });
onUnmounted(stop); onUnmounted(() => {
finished = true;
stop();
});
return ( return (
<div class="overflow-hidden rounded-2xl border hairline bg-black/60"> <div class="overflow-hidden rounded-2xl border hairline bg-black/60">
{error.value === '' {error.value === ''
? ( ? (
<div class="relative"> <div class="relative">
<video ref={videoEl} muted playsinline class="aspect-[4/3] w-full object-cover" /> <video ref={videoEl} muted playsinline class="aspect-4/3 w-full object-cover" />
{/* Рамка прицела */} {/* Рамка прицела */}
<div class="pointer-events-none absolute inset-0 grid place-items-center"> <div class="pointer-events-none absolute inset-0 grid place-items-center">
<div class="h-24 w-56 rounded-xl border-2 border-ember-bright/80 shadow-[0_0_0_999px_rgba(0,0,0,0.35)]" /> <div class="h-24 w-56 rounded-xl border-2 border-ember-bright/80 shadow-[0_0_0_999px_rgba(0,0,0,0.35)]" />
</div> </div>
<p class="absolute right-0 bottom-2 left-0 text-center text-[12px] text-ink/90"> <p class="absolute right-0 bottom-2 left-0 text-center text-[12px] text-ink/90">
Наведите на штрихкод упаковки {loading.value ? 'Готовим распознавание…' : 'Наведите на штрихкод упаковки'}
</p> </p>
</div> </div>
) )
-12
View File
@@ -1,13 +1 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
// BarcodeDetector (Chromium): в lib.dom его ещё нет — минимальная декларация.
interface DetectedBarcode {
rawValue: string;
format: string;
}
declare class BarcodeDetector {
constructor(options?: { formats?: string[] });
detect(source: CanvasImageSource): Promise<DetectedBarcode[]>;
static getSupportedFormats(): Promise<string[]>;
}
+2 -2
View File
@@ -168,7 +168,7 @@ export default function StatsScreen() {
class="absolute right-0 left-0 z-10 border-t border-dashed border-ink-soft/40" class="absolute right-0 left-0 z-10 border-t border-dashed border-ink-soft/40"
style={{ bottom: `${(target.value / chartMax.value) * 100}%` }} style={{ bottom: `${(target.value / chartMax.value) * 100}%` }}
/> />
<div class="flex h-full items-end gap-[3px]"> <div class="flex h-full items-end gap-0.75">
{days.value.map(day => ( {days.value.map(day => (
<button <button
type="button" type="button"
@@ -177,7 +177,7 @@ export default function StatsScreen() {
onClick={() => (selectedBar.value = day.date)} onClick={() => (selectedBar.value = day.date)}
> >
<div <div
class={`w-full rounded-t-[4px] transition-colors ${day.kcal > 0 ? '' : 'min-h-[2px]'} ${selectedBar.value === day.date class={`w-full rounded-t-sm transition-colors ${day.kcal > 0 ? '' : 'min-h-0.5'} ${selectedBar.value === day.date
? 'bg-ember-bright' ? 'bg-ember-bright'
: day.kcal > target.value ? 'bg-over/80 group-hover:bg-over' : 'bg-ember/65 group-hover:bg-ember'}`} : day.kcal > target.value ? 'bg-over/80 group-hover:bg-over' : 'bg-ember/65 group-hover:bg-ember'}`}
style={{ height: `${Math.max((day.kcal / chartMax.value) * 100, day.kcal > 0 ? 2 : 1)}%` }} style={{ height: `${Math.max((day.kcal / chartMax.value) * 100, day.kcal > 0 ? 2 : 1)}%` }}
+14 -1
View File
@@ -36,8 +36,21 @@ export default defineConfig({
workbox: { workbox: {
// woff тут дубли woff2 от fontsource — в прекеш идёт только woff2. // woff тут дубли woff2 от fontsource — в прекеш идёт только woff2.
globPatterns: ['**/*.{js,css,html,woff2,svg,png,ico}'], globPatterns: ['**/*.{js,css,html,woff2,svg,png,ico}'],
// Штрихкоды из Open Food Facts: сеть свежее, но офлайн отдаём кэш.
runtimeCaching: [ runtimeCaching: [
// Распознавание штрихкодов на WebKit — ~1 МБ wasm. В прекеш его класть
// жалко (нужен не всем), поэтому кэшируем после первого запуска
// сканера: дальше он работает и офлайн. Файл с хэшем в имени, так что
// CacheFirst безопасен.
{
urlPattern: /\.wasm$/,
handler: 'CacheFirst',
options: {
cacheName: 'wasm',
expiration: { maxEntries: 4 },
cacheableResponse: { statuses: [0, 200] },
},
},
// Штрихкоды из Open Food Facts: сеть свежее, но офлайн отдаём кэш.
{ {
urlPattern: /^https:\/\/world\.openfoodfacts\.org\/.*/, urlPattern: /^https:\/\/world\.openfoodfacts\.org\/.*/,
handler: 'NetworkFirst', handler: 'NetworkFirst',