diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54653aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +npm-debug.log* diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..dcb3e03 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,32 @@ +# Product + +## Register + +product + +## Users + +Пользователь Lineage II запускает утилиту рядом с несколькими окнами игры и хочет быстро проверить, какие данные удалось извлечь с экрана. На первом этапе это инструмент для владельца проекта, а не публичный продукт. + +## Product Purpose + +Приложение подключает одно или несколько выбранных окон, позволяет откалибровать области распознавания на снимке и показывает извлечённые названия, цены и количества. Успех прототипа означает, что весь путь от выбора окна до видимого результата работает локально на macOS и Windows. + +## Brand Personality + +Нейтральный, утилитарный, спокойный. + +## Anti-references + +Не имитировать интерфейс Lineage II, не превращать инструмент в декоративный игровой оверлей, не использовать маркетинговые dashboard-шаблоны и лишнюю анимацию. + +## Design Principles + +- Показывать реальный захваченный кадр и реальный результат, а не скрывать состояние процесса. +- Вести пользователя по прямому пути: источник, калибровка, результат. +- Сохранять интерфейс компактным и предсказуемым при нескольких окнах. +- Оставлять настраиваемыми только параметры, которые зависят от реального изображения. + +## Accessibility & Inclusion + +Использовать стандартное управление с клавиатуры, заметный фокус, текстовые подписи для состояний и уважать системную настройку уменьшенного движения. Специальный уровень WCAG для прототипа не задан. diff --git a/README.md b/README.md index 11d7b19..5fca666 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,40 @@ -# l2-market-parser +# L2 Market Parser +Кроссплатформенный локальный прототип для захвата нескольких окон Lineage II, калибровки областей на скриншоте и OCR-распознавания результата. + +## Запуск + +Нужен Node.js 20 или новее и Chrome либо Edge. + +```bash +npm install +npm run dev +``` + +Открой адрес, который напечатает Vite, обычно `http://127.0.0.1:5173`. + +На macOS браузеру потребуется разрешение **System Settings → Privacy & Security → Screen & System Audio Recording**. После выдачи разрешения браузер иногда нужно перезапустить. + +## Сценарий + +1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры. +2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот. +3. Выдели маленький неизменяемый элемент торгового окна как якорь. +4. Выдели области названия, цены и количества. +5. Сохрани калибровку и нажми «Распознать все». + +Калибровка хранится в `localStorage` текущего браузера. При первом OCR Tesseract.js загружает английскую языковую модель и кеширует её в браузере. + +## Ограничения прототипа + +- Источники выбираются вручную после каждого обновления страницы. +- Одна калибровка рассчитана на один масштаб интерфейса Lineage II. +- Распознавание запускается вручную, фонового сканирования и отправки на сервер пока нет. +- Для нескольких строк товара нужно отдельно выделить поля каждой строки. + +## Проверки + +```bash +npm test +npm run build +``` diff --git a/index.html b/index.html new file mode 100644 index 0000000..465c944 --- /dev/null +++ b/index.html @@ -0,0 +1,134 @@ + + + + + + + L2 Market Parser + + +
+
+
+

Локальный прототип

+

L2 Market Parser

+
+
+ Готов к работе +
+
+ + + +
+
+
+
+

Захват экрана

+

Окна игры

+

Добавь каждое окно отдельно. Браузер спросит, какое окно разрешено захватывать.

+
+ +
+
+
+ + + + +
+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..077e2ed --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1246 @@ +{ + "name": "l2-market-parser", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "l2-market-parser", + "version": "0.1.0", + "dependencies": { + "@techstark/opencv-js": "5.0.0-release.1", + "tesseract.js": "7.0.0" + }, + "devDependencies": { + "vite": "^7.3.6" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@techstark/opencv-js": { + "version": "5.0.0-release.1", + "resolved": "https://registry.npmjs.org/@techstark/opencv-js/-/opencv-js-5.0.0-release.1.tgz", + "integrity": "sha512-PIm+eB0MFtieXoNC2GRao0dv/02sehG+Nv2nSW5D6pQm6J/4WqvDHm0RyoqOmGYQm67jdGiaOdIeTShCY3PIUg==", + "license": "Apache-2.0" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wasm-feature-detect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", + "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c68695e --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "l2-market-parser", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "test": "node --test" + }, + "dependencies": { + "@techstark/opencv-js": "5.0.0-release.1", + "tesseract.js": "7.0.0" + }, + "devDependencies": { + "vite": "^7.3.6" + } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..eaa7e14 --- /dev/null +++ b/src/main.js @@ -0,0 +1,705 @@ +import "./style.css"; +import { + isCalibrationReady, + isRectInside, + rectFromPoints, + resolveFieldRect, +} from "./parser.js"; +import { + findAnchor, + onOcrProgress, + recognizeField, + terminateVision, +} from "./vision.js"; + +const STORAGE_KEY = "l2-market-parser.calibration.v1"; +const fieldNames = { + item: "Название", + price: "Цена", + quantity: "Количество", + text: "Текст", +}; + +function emptyCalibration() { + return { anchor: null, fields: [], threshold: 0.8 }; +} + +function loadCalibration() { + try { + const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); + return saved?.anchor && Array.isArray(saved.fields) + ? { ...emptyCalibration(), ...saved } + : emptyCalibration(); + } catch { + return emptyCalibration(); + } +} + +const state = { + sources: [], + selectedSourceId: null, + referenceCanvas: null, + calibration: loadCalibration(), + drawing: null, + results: [], + busy: false, +}; + +const elements = { + status: document.querySelector("#global-status"), + nav: [...document.querySelectorAll("[data-view]")], + views: [...document.querySelectorAll(".view")], + addSource: document.querySelector("#add-source"), + sourceList: document.querySelector("#source-list"), + calibrationSource: document.querySelector("#calibration-source"), + captureReference: document.querySelector("#capture-reference"), + referenceFile: document.querySelector("#reference-file"), + canvasEmpty: document.querySelector("#canvas-empty"), + canvas: document.querySelector("#calibration-canvas"), + regionType: document.querySelector("#region-type"), + regionLabel: document.querySelector("#region-label"), + drawHint: document.querySelector("#draw-hint"), + threshold: document.querySelector("#match-threshold"), + thresholdValue: document.querySelector("#threshold-value"), + regionCount: document.querySelector("#region-count"), + regionList: document.querySelector("#region-list"), + saveCalibration: document.querySelector("#save-calibration"), + clearCalibration: document.querySelector("#clear-calibration"), + recognizeAll: document.querySelector("#recognize-all"), + copyResults: document.querySelector("#copy-results"), + resultsList: document.querySelector("#results-list"), +}; + +function setStatus(message, tone = "neutral") { + elements.status.textContent = message; + elements.status.dataset.tone = tone; +} + +function showView(name) { + elements.nav.forEach((button) => { + const active = button.dataset.view === name; + button.classList.toggle("is-active", active); + active + ? button.setAttribute("aria-current", "page") + : button.removeAttribute("aria-current"); + }); + + elements.views.forEach((view) => { + const active = view.id === `view-${name}`; + view.classList.toggle("is-active", active); + view.hidden = !active; + }); +} + +function selectedSource() { + return state.sources.find((source) => source.id === state.selectedSourceId); +} + +async function waitForVideo(video) { + if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.videoWidth) return; + + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => reject(new Error("Нет кадров от выбранного окна")), 5000); + video.addEventListener( + "loadeddata", + () => { + window.clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }); +} + +async function addSource() { + if (!navigator.mediaDevices?.getDisplayMedia) { + setStatus("Браузер не поддерживает захват экрана", "error"); + return; + } + + try { + setStatus("Выбери окно игры в системном окне", "working"); + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: { frameRate: { ideal: 5, max: 10 } }, + audio: false, + }); + const track = stream.getVideoTracks()[0]; + const video = document.createElement("video"); + video.autoplay = true; + video.muted = true; + video.playsInline = true; + video.srcObject = stream; + await video.play(); + await waitForVideo(video); + + const source = { + id: crypto.randomUUID(), + name: track.label || `Окно ${state.sources.length + 1}`, + stream, + video, + active: true, + }; + + track.addEventListener("ended", () => { + source.active = false; + renderSources(); + setStatus(`Захват «${source.name}» остановлен`, "neutral"); + }); + + state.sources.push(source); + state.selectedSourceId = source.id; + renderSources(); + setStatus(`Добавлено: ${source.name}`, "success"); + } catch (error) { + if (error.name === "NotAllowedError") { + setStatus("Захват отменён или не разрешён", "error"); + } else { + setStatus(error.message || "Не удалось добавить окно", "error"); + } + } +} + +function removeSource(sourceId) { + const source = state.sources.find((item) => item.id === sourceId); + source?.stream.getTracks().forEach((track) => track.stop()); + state.sources = state.sources.filter((item) => item.id !== sourceId); + state.selectedSourceId = state.sources.find((item) => item.active)?.id ?? null; + renderSources(); +} + +function chooseSource(sourceId) { + state.selectedSourceId = sourceId; + renderSources(); +} + +function sourceEmptyState() { + const empty = document.createElement("div"); + empty.className = "empty-state empty-state--wide"; + const title = document.createElement("strong"); + title.textContent = "Нет подключённых окон"; + const text = document.createElement("span"); + text.textContent = "Нажми «Добавить окно» и выбери первое окно Lineage II."; + empty.append(title, text); + return empty; +} + +function renderSources() { + elements.sourceList.replaceChildren(); + + if (!state.sources.length) { + elements.sourceList.append(sourceEmptyState()); + } + + state.sources.forEach((source, index) => { + const row = document.createElement("article"); + row.className = "source-row"; + row.classList.toggle("is-selected", source.id === state.selectedSourceId); + + const preview = document.createElement("div"); + preview.className = "source-row__preview"; + const video = document.createElement("video"); + video.autoplay = true; + video.muted = true; + video.playsInline = true; + video.srcObject = source.stream; + preview.append(video); + + const info = document.createElement("div"); + info.className = "source-row__info"; + const kicker = document.createElement("span"); + kicker.textContent = `Источник ${index + 1}`; + const name = document.createElement("h3"); + name.textContent = source.name; + const stateText = document.createElement("p"); + stateText.textContent = source.active + ? `${source.video.videoWidth} × ${source.video.videoHeight}, захват активен` + : "Захват остановлен"; + info.append(kicker, name, stateText); + + const actions = document.createElement("div"); + actions.className = "source-row__actions"; + const calibrate = document.createElement("button"); + calibrate.className = "button"; + calibrate.type = "button"; + calibrate.textContent = "Калибровать"; + calibrate.disabled = !source.active; + calibrate.addEventListener("click", () => { + chooseSource(source.id); + showView("calibration"); + }); + const select = document.createElement("button"); + select.className = "button button--quiet"; + select.type = "button"; + select.textContent = source.id === state.selectedSourceId ? "Выбрано" : "Выбрать"; + select.disabled = !source.active || source.id === state.selectedSourceId; + select.addEventListener("click", () => chooseSource(source.id)); + const remove = document.createElement("button"); + remove.className = "button button--quiet"; + remove.type = "button"; + remove.textContent = "Убрать"; + remove.addEventListener("click", () => removeSource(source.id)); + actions.append(calibrate, select, remove); + + row.append(preview, info, actions); + elements.sourceList.append(row); + }); + + elements.calibrationSource.replaceChildren(); + if (!state.sources.some((source) => source.active)) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "Нет активных окон"; + elements.calibrationSource.append(option); + } else { + state.sources + .filter((source) => source.active) + .forEach((source) => { + const option = document.createElement("option"); + option.value = source.id; + option.textContent = source.name; + option.selected = source.id === state.selectedSourceId; + elements.calibrationSource.append(option); + }); + } + + elements.captureReference.disabled = !selectedSource()?.active; +} + +async function captureSource(source) { + if (!source?.active) throw new Error("Сначала выбери активное окно"); + await waitForVideo(source.video); + + const canvas = document.createElement("canvas"); + canvas.width = source.video.videoWidth; + canvas.height = source.video.videoHeight; + canvas.getContext("2d", { willReadFrequently: true }).drawImage(source.video, 0, 0); + return canvas; +} + +function setReference(canvas) { + state.referenceCanvas = canvas; + elements.canvas.width = canvas.width; + elements.canvas.height = canvas.height; + elements.canvas.hidden = false; + elements.canvasEmpty.hidden = true; + drawCalibration(); + setStatus(`Снимок готов: ${canvas.width} × ${canvas.height}`, "success"); +} + +async function captureReference() { + try { + setStatus("Делаю снимок выбранного окна", "working"); + setReference(await captureSource(selectedSource())); + } catch (error) { + setStatus(error.message, "error"); + } +} + +async function loadReferenceFile(file) { + if (!file) return; + + try { + const bitmap = await createImageBitmap(file); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext("2d", { willReadFrequently: true }).drawImage(bitmap, 0, 0); + bitmap.close(); + setReference(canvas); + } catch { + setStatus("Не удалось открыть изображение", "error"); + } +} + +function canvasPoint(event) { + const bounds = elements.canvas.getBoundingClientRect(); + return { + x: Math.max( + 0, + Math.min(elements.canvas.width, ((event.clientX - bounds.left) / bounds.width) * elements.canvas.width), + ), + y: Math.max( + 0, + Math.min(elements.canvas.height, ((event.clientY - bounds.top) / bounds.height) * elements.canvas.height), + ), + }; +} + +function cropCanvas(source, rect) { + const canvas = document.createElement("canvas"); + canvas.width = rect.width; + canvas.height = rect.height; + canvas + .getContext("2d", { willReadFrequently: true }) + .drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height); + return canvas; +} + +function addRegion(rect) { + const type = elements.regionType.value; + + if (type === "anchor") { + state.calibration.anchor = { + ...rect, + image: cropCanvas(state.referenceCanvas, rect).toDataURL("image/png"), + }; + state.calibration.fields = []; + elements.regionType.value = "item"; + updateDrawHint(); + setStatus("Якорь выбран. Теперь отметь поля", "success"); + } else { + const anchor = state.calibration.anchor; + if (!anchor) { + setStatus("Сначала выдели якорь", "error"); + return; + } + + const sameTypeCount = state.calibration.fields.filter((field) => field.type === type).length; + state.calibration.fields.push({ + id: crypto.randomUUID(), + type, + label: elements.regionLabel.value.trim() || `${fieldNames[type]} ${sameTypeCount + 1}`, + offsetX: rect.x - anchor.x, + offsetY: rect.y - anchor.y, + width: rect.width, + height: rect.height, + }); + elements.regionLabel.value = ""; + setStatus("Область добавлена", "success"); + } + + renderCalibration(); +} + +function fieldAbsoluteRect(field) { + return resolveFieldRect( + { x: state.calibration.anchor.x, y: state.calibration.anchor.y }, + field, + ); +} + +function drawBox(context, rect, label, color, dashed = false) { + context.save(); + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = Math.max(2, elements.canvas.width / 900); + context.setLineDash(dashed ? [10, 7] : []); + context.strokeRect(rect.x, rect.y, rect.width, rect.height); + + if (label) { + context.font = `${Math.max(13, elements.canvas.width / 90)}px system-ui`; + const width = context.measureText(label).width + 14; + const y = Math.max(0, rect.y - 25); + context.fillRect(rect.x, y, width, 25); + context.fillStyle = "oklch(0.98 0.004 250)"; + context.fillText(label, rect.x + 7, y + 17); + } + context.restore(); +} + +function drawCalibration() { + if (!state.referenceCanvas) return; + const context = elements.canvas.getContext("2d"); + context.clearRect(0, 0, elements.canvas.width, elements.canvas.height); + context.drawImage(state.referenceCanvas, 0, 0); + + if (state.calibration.anchor) { + drawBox(context, state.calibration.anchor, "Якорь", "oklch(0.72 0.16 65)"); + state.calibration.fields.forEach((field) => { + drawBox(context, fieldAbsoluteRect(field), field.label, "oklch(0.65 0.17 250)"); + }); + } + + if (state.drawing) { + const rect = rectFromPoints(state.drawing.start, state.drawing.current); + drawBox(context, rect, "", "oklch(0.75 0.18 150)", true); + } +} + +function updateDrawHint() { + const hints = { + anchor: "Протяни рамку вокруг небольшого постоянного элемента торгового окна.", + item: "Выдели только строку с названием предмета.", + price: "Выдели цену вместе с разделителями разрядов.", + quantity: "Выдели количество предметов.", + text: "Выдели одну строку текста.", + }; + elements.drawHint.textContent = hints[elements.regionType.value]; +} + +function renderCalibration() { + elements.threshold.value = String(state.calibration.threshold); + elements.thresholdValue.value = state.calibration.threshold.toFixed(2); + elements.regionCount.textContent = String( + state.calibration.fields.length + (state.calibration.anchor ? 1 : 0), + ); + elements.regionList.replaceChildren(); + + if (!state.calibration.anchor) { + const note = document.createElement("p"); + note.className = "muted-copy"; + note.textContent = "Якорь ещё не выбран."; + elements.regionList.append(note); + } else { + const anchor = document.createElement("div"); + anchor.className = "region-row"; + anchor.innerHTML = "ЯкорьШаблон поиска"; + elements.regionList.append(anchor); + } + + state.calibration.fields.forEach((field) => { + const row = document.createElement("div"); + row.className = "region-row"; + const copy = document.createElement("span"); + const name = document.createElement("strong"); + name.textContent = field.label; + const type = document.createElement("small"); + type.textContent = fieldNames[field.type]; + copy.append(name, type); + const remove = document.createElement("button"); + remove.className = "icon-button"; + remove.type = "button"; + remove.textContent = "×"; + remove.setAttribute("aria-label", `Удалить ${field.label}`); + remove.addEventListener("click", () => { + state.calibration.fields = state.calibration.fields.filter((item) => item.id !== field.id); + renderCalibration(); + drawCalibration(); + }); + row.append(copy, remove); + elements.regionList.append(row); + }); + + drawCalibration(); +} + +function saveCalibration() { + if (!isCalibrationReady(state.calibration)) { + setStatus("Для сохранения нужны якорь и хотя бы одно поле", "error"); + return; + } + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.calibration)); + setStatus("Калибровка сохранена в браузере", "success"); + } catch { + setStatus("Браузер не смог сохранить калибровку", "error"); + } +} + +function clearCalibration() { + state.calibration = emptyCalibration(); + localStorage.removeItem(STORAGE_KEY); + renderCalibration(); + setStatus("Калибровка сброшена", "neutral"); +} + +function imageToCanvas(dataUrl) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + canvas.getContext("2d").drawImage(image, 0, 0); + resolve(canvas); + }; + image.onerror = reject; + image.src = dataUrl; + }); +} + +function renderResults() { + elements.resultsList.replaceChildren(); + elements.copyResults.disabled = !state.results.length; + + if (!state.results.length) { + const empty = document.createElement("div"); + empty.className = "empty-state empty-state--wide"; + const title = document.createElement("strong"); + title.textContent = "Результатов пока нет"; + const text = document.createElement("span"); + text.textContent = "Подключи окна, сохрани калибровку и запусти распознавание."; + empty.append(title, text); + elements.resultsList.append(empty); + return; + } + + state.results.forEach((result) => { + const section = document.createElement("section"); + section.className = "result-group"; + const header = document.createElement("header"); + const heading = document.createElement("div"); + const title = document.createElement("h3"); + title.textContent = result.source; + const meta = document.createElement("p"); + meta.textContent = result.found + ? `Якорь найден, совпадение ${(result.score * 100).toFixed(1)}%` + : `Якорь не найден, лучшее совпадение ${(result.score * 100).toFixed(1)}%`; + heading.append(title, meta); + const badge = document.createElement("span"); + badge.className = "result-badge"; + badge.dataset.tone = result.found ? "success" : "error"; + badge.textContent = result.found ? "Распознано" : "Не найдено"; + header.append(heading, badge); + section.append(header); + + if (result.error) { + const error = document.createElement("p"); + error.className = "result-error"; + error.textContent = result.error; + section.append(error); + } else if (result.fields?.length) { + const table = document.createElement("table"); + table.innerHTML = "ПолеЗначениеOCRУверенность"; + const body = document.createElement("tbody"); + result.fields.forEach((field) => { + const row = document.createElement("tr"); + const label = document.createElement("td"); + label.textContent = field.label; + const value = document.createElement("td"); + value.textContent = + typeof field.value === "number" ? field.value.toLocaleString("ru-RU") : field.value || "Не распознано"; + const raw = document.createElement("td"); + raw.textContent = field.text || "пусто"; + const confidence = document.createElement("td"); + confidence.textContent = `${field.confidence}%`; + row.append(label, value, raw, confidence); + body.append(row); + }); + table.append(body); + section.append(table); + } + + elements.resultsList.append(section); + }); +} + +async function recognizeAll() { + const activeSources = state.sources.filter((source) => source.active); + if (!activeSources.length) { + setStatus("Сначала добавь хотя бы одно окно", "error"); + return; + } + if (!isCalibrationReady(state.calibration)) { + setStatus("Сначала сохрани якорь и области распознавания", "error"); + return; + } + if (state.busy) return; + + state.busy = true; + elements.recognizeAll.disabled = true; + elements.recognizeAll.textContent = "Распознаю…"; + state.results = []; + renderResults(); + + onOcrProgress((progress) => { + if (progress.status === "recognizing text") { + setStatus(`OCR: ${Math.round((progress.progress || 0) * 100)}%`, "working"); + } + }); + + try { + const template = await imageToCanvas(state.calibration.anchor.image); + + for (const source of activeSources) { + setStatus(`Ищу торговое окно: ${source.name}`, "working"); + const frame = await captureSource(source); + const match = await findAnchor(frame, template); + const result = { + source: source.name, + capturedAt: new Date().toISOString(), + score: match.score, + found: match.score >= state.calibration.threshold, + fields: [], + }; + + if (!result.found) { + result.error = "Проверь масштаб интерфейса, выбранный якорь или уменьши порог совпадения."; + } else { + for (const field of state.calibration.fields) { + const rect = resolveFieldRect(match, field); + if (!isRectInside(rect, frame.width, frame.height)) { + result.fields.push({ + ...field, + text: "", + value: null, + confidence: 0, + error: "Область вышла за границы кадра", + }); + continue; + } + + setStatus(`${source.name}: ${field.label}`, "working"); + result.fields.push({ ...field, ...(await recognizeField(frame, rect, field.type)) }); + } + } + + state.results.push(result); + renderResults(); + } + + setStatus("Распознавание завершено", "success"); + } catch (error) { + setStatus(error.message || "Ошибка распознавания", "error"); + } finally { + state.busy = false; + elements.recognizeAll.disabled = false; + elements.recognizeAll.textContent = "Распознать все"; + } +} + +async function copyResults() { + try { + await navigator.clipboard.writeText(JSON.stringify(state.results, null, 2)); + setStatus("JSON скопирован", "success"); + } catch { + setStatus("Не удалось скопировать JSON", "error"); + } +} + +elements.nav.forEach((button) => button.addEventListener("click", () => showView(button.dataset.view))); +elements.addSource.addEventListener("click", addSource); +elements.calibrationSource.addEventListener("change", (event) => chooseSource(event.target.value)); +elements.captureReference.addEventListener("click", captureReference); +elements.referenceFile.addEventListener("change", (event) => loadReferenceFile(event.target.files[0])); +elements.regionType.addEventListener("change", updateDrawHint); +elements.threshold.addEventListener("input", (event) => { + state.calibration.threshold = Number(event.target.value); + elements.thresholdValue.value = state.calibration.threshold.toFixed(2); +}); +elements.saveCalibration.addEventListener("click", saveCalibration); +elements.clearCalibration.addEventListener("click", clearCalibration); +elements.recognizeAll.addEventListener("click", recognizeAll); +elements.copyResults.addEventListener("click", copyResults); + +elements.canvas.addEventListener("pointerdown", (event) => { + if (!state.referenceCanvas) return; + elements.canvas.setPointerCapture(event.pointerId); + const point = canvasPoint(event); + state.drawing = { start: point, current: point }; + drawCalibration(); +}); +elements.canvas.addEventListener("pointermove", (event) => { + if (!state.drawing) return; + state.drawing.current = canvasPoint(event); + drawCalibration(); +}); +elements.canvas.addEventListener("pointerup", (event) => { + if (!state.drawing) return; + state.drawing.current = canvasPoint(event); + const rect = rectFromPoints(state.drawing.start, state.drawing.current); + state.drawing = null; + if (rect.width >= 4 && rect.height >= 4) addRegion(rect); + drawCalibration(); +}); + +window.addEventListener("beforeunload", () => { + state.sources.forEach((source) => source.stream.getTracks().forEach((track) => track.stop())); + terminateVision(); +}); + +renderSources(); +renderCalibration(); +renderResults(); +updateDrawHint(); diff --git a/src/parser.js b/src/parser.js new file mode 100644 index 0000000..43bb600 --- /dev/null +++ b/src/parser.js @@ -0,0 +1,52 @@ +export function rectFromPoints(start, end) { + return { + x: Math.round(Math.min(start.x, end.x)), + y: Math.round(Math.min(start.y, end.y)), + width: Math.round(Math.abs(end.x - start.x)), + height: Math.round(Math.abs(end.y - start.y)), + }; +} + +export function resolveFieldRect(anchorPosition, field) { + return { + x: Math.round(anchorPosition.x + field.offsetX), + y: Math.round(anchorPosition.y + field.offsetY), + width: Math.round(field.width), + height: Math.round(field.height), + }; +} + +export function normalizeOcrText(text) { + return text.replace(/\s+/g, " ").trim(); +} + +export function parseFieldValue(type, text) { + const normalized = normalizeOcrText(text); + + if (type !== "price" && type !== "quantity") { + return normalized; + } + + const digits = normalized.replace(/\D/g, ""); + return digits ? Number(digits) : null; +} + +export function isCalibrationReady(calibration) { + return Boolean( + calibration?.anchor?.image && + calibration.anchor.width >= 4 && + calibration.anchor.height >= 4 && + calibration.fields?.length, + ); +} + +export function isRectInside(rect, width, height) { + return ( + rect.x >= 0 && + rect.y >= 0 && + rect.width > 0 && + rect.height > 0 && + rect.x + rect.width <= width && + rect.y + rect.height <= height + ); +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..b407f5b --- /dev/null +++ b/src/style.css @@ -0,0 +1,754 @@ +:root { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + color: oklch(0.24 0.012 255); + background: oklch(0.965 0.006 250); + font-synthesis: none; + --background: oklch(0.965 0.006 250); + --surface: oklch(0.99 0.004 250); + --surface-raised: oklch(1 0.003 250); + --surface-muted: oklch(0.935 0.008 250); + --text: oklch(0.24 0.012 255); + --text-muted: oklch(0.52 0.018 255); + --border: oklch(0.86 0.012 250); + --border-strong: oklch(0.74 0.02 250); + --accent: oklch(0.58 0.16 250); + --accent-hover: oklch(0.52 0.17 250); + --accent-soft: oklch(0.93 0.035 250); + --success: oklch(0.54 0.13 155); + --success-soft: oklch(0.93 0.04 155); + --error: oklch(0.56 0.18 25); + --error-soft: oklch(0.94 0.04 25); + --warning: oklch(0.68 0.15 75); + --focus: oklch(0.66 0.18 250); + --shadow: 0 14px 40px oklch(0.32 0.025 250 / 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--background); + color: var(--text); +} + +button, +input, +select { + font: inherit; +} + +button, +select, +input[type="range"], +.file-button { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +.file-button:has(input:focus-visible) { + outline: 3px solid color-mix(in oklch, var(--focus), transparent 50%); + outline-offset: 2px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +h1, +h2, +h3, +p { + margin-block-start: 0; +} + +h1 { + margin-block-end: 0; + font-size: 1.45rem; + line-height: 1.15; + letter-spacing: -0.025em; +} + +h2 { + margin-block-end: 0.45rem; + font-size: 1.75rem; + line-height: 1.15; + letter-spacing: -0.035em; +} + +h3 { + margin-block-end: 0.25rem; + font-size: 1rem; +} + +.app-shell { + width: min(1480px, 100%); + min-height: 100vh; + margin-inline: auto; + padding: 28px clamp(18px, 3vw, 48px) 56px; +} + +.app-header { + display: flex; + min-height: 62px; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.eyebrow { + margin-block-end: 5px; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.status { + display: grid; + width: 270px; + min-height: 38px; + place-items: center; + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--text-muted); + font-size: 0.84rem; + text-align: center; + transition: background-color 180ms cubic-bezier(0.22, 1, 0.36, 1), + border-color 180ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.status[data-tone="working"] { + border-color: color-mix(in oklch, var(--accent), transparent 55%); + background: var(--accent-soft); + color: var(--accent-hover); +} + +.status[data-tone="success"] { + border-color: color-mix(in oklch, var(--success), transparent 55%); + background: var(--success-soft); + color: var(--success); +} + +.status[data-tone="error"] { + border-color: color-mix(in oklch, var(--error), transparent 55%); + background: var(--error-soft); + color: var(--error); +} + +.step-nav { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-block: 30px 42px; + border-block-end: 1px solid var(--border); +} + +.step-nav__item { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 54px; + border: 0; + border-block-end: 2px solid transparent; + background: transparent; + color: var(--text-muted); + font-weight: 650; +} + +.step-nav__item span { + display: grid; + width: 24px; + height: 24px; + place-items: center; + border: 1px solid var(--border-strong); + border-radius: 50%; + font-size: 0.75rem; +} + +.step-nav__item:hover { + color: var(--text); +} + +.step-nav__item.is-active { + border-block-end-color: var(--accent); + color: var(--accent); +} + +.step-nav__item.is-active span { + border-color: var(--accent); + background: var(--accent); + color: oklch(0.98 0.004 250); +} + +.view[hidden] { + display: none; +} + +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 32px; + margin-block-end: 28px; +} + +.section-heading > div:first-child { + max-width: 70ch; +} + +.section-heading p:last-child { + margin-block-end: 0; + color: var(--text-muted); + line-height: 1.55; +} + +.section-heading--compact { + align-items: center; +} + +.button-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 9px; +} + +.button, +select, +input[type="text"] { + min-height: 40px; + border: 1px solid var(--border-strong); + border-radius: 9px; + background: var(--surface-raised); + color: var(--text); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 14px; + font-weight: 650; + white-space: nowrap; +} + +.button:hover:not(:disabled) { + border-color: var(--accent); +} + +.button--primary { + border-color: var(--accent); + background: var(--accent); + color: oklch(0.98 0.004 250); +} + +.button--primary:hover:not(:disabled) { + border-color: var(--accent-hover); + background: var(--accent-hover); +} + +.button--quiet { + border-color: transparent; + background: transparent; + color: var(--text-muted); +} + +.button--quiet:hover:not(:disabled) { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text); +} + +select, +input[type="text"] { + padding-inline: 11px; +} + +.file-button input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} + +.source-list { + display: grid; + gap: 12px; +} + +.source-row { + display: grid; + grid-template-columns: 220px minmax(220px, 1fr) auto; + align-items: center; + gap: 22px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + transition: border-color 180ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 180ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.source-row.is-selected { + border-color: color-mix(in oklch, var(--accent), transparent 35%); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent), transparent 88%); +} + +.source-row__preview { + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 8px; + background: oklch(0.18 0.01 255); +} + +.source-row__preview video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.source-row__info span { + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.source-row__info h3 { + margin-block: 5px; +} + +.source-row__info p { + margin: 0; + color: var(--text-muted); + font-size: 0.85rem; +} + +.source-row__actions { + display: flex; + align-items: center; + gap: 5px; +} + +.empty-state { + display: grid; + place-items: center; + gap: 8px; + min-height: 260px; + padding: 32px; + color: var(--text-muted); + text-align: center; +} + +.empty-state strong { + color: var(--text); + font-size: 1rem; +} + +.empty-state--wide { + min-height: 300px; + border: 1px dashed var(--border-strong); + border-radius: 14px; + background: color-mix(in oklch, var(--surface), transparent 30%); +} + +.calibration-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 310px; + min-height: 620px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.canvas-stage { + display: grid; + min-width: 0; + min-height: 620px; + max-height: calc(100vh - 260px); + place-items: center; + overflow: auto; + padding: 24px; + background-color: oklch(0.91 0.008 250); + background-image: linear-gradient(45deg, oklch(0.88 0.008 250) 25%, transparent 25%), + linear-gradient(-45deg, oklch(0.88 0.008 250) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, oklch(0.88 0.008 250) 75%), + linear-gradient(-45deg, transparent 75%, oklch(0.88 0.008 250) 75%); + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + background-size: 16px 16px; +} + +#calibration-canvas { + display: block; + max-width: 100%; + max-height: 100%; + border-radius: 4px; + box-shadow: 0 8px 30px oklch(0.2 0.02 255 / 0.2); + cursor: crosshair; + touch-action: none; +} + +.inspector { + display: flex; + flex-direction: column; + gap: 20px; + padding: 24px; + border-inline-start: 1px solid var(--border); + background: var(--surface); +} + +.field-stack { + display: grid; + gap: 7px; +} + +.field-stack label, +.range-label label { + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 700; +} + +.hint { + min-height: 58px; + margin: 0; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-muted); + color: var(--text-muted); + font-size: 0.82rem; + line-height: 1.45; +} + +.range-label { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.range-label output { + color: var(--text); + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +.inspector__section { + min-height: 0; + flex: 1; + overflow: auto; + border-block-start: 1px solid var(--border); + padding-block-start: 18px; +} + +.inspector__title { + display: flex; + align-items: center; + justify-content: space-between; + margin-block-end: 10px; +} + +.inspector__title h3 { + margin: 0; +} + +.inspector__title span { + display: grid; + min-width: 24px; + height: 24px; + place-items: center; + border-radius: 999px; + background: var(--surface-muted); + color: var(--text-muted); + font-size: 0.75rem; +} + +.region-list { + display: grid; + gap: 6px; +} + +.region-row { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 9px; + border-radius: 8px; +} + +.region-row:hover { + background: var(--surface-muted); +} + +.region-row span { + display: grid; + gap: 2px; +} + +.region-row strong { + font-size: 0.84rem; +} + +.region-row small { + color: var(--text-muted); + font-size: 0.72rem; +} + +.icon-button { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--text-muted); + font-size: 1.2rem; +} + +.icon-button:hover { + background: var(--error-soft); + color: var(--error); +} + +.muted-copy { + color: var(--text-muted); + font-size: 0.82rem; +} + +.inspector__actions { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + padding-block-start: 2px; +} + +.results-list { + display: grid; + gap: 18px; +} + +.result-group { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); +} + +.result-group > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 18px 20px; + border-block-end: 1px solid var(--border); +} + +.result-group header h3 { + margin-block-end: 3px; +} + +.result-group header p { + margin: 0; + color: var(--text-muted); + font-size: 0.82rem; +} + +.result-badge { + padding: 6px 9px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; +} + +.result-badge[data-tone="success"] { + background: var(--success-soft); + color: var(--success); +} + +.result-badge[data-tone="error"] { + background: var(--error-soft); + color: var(--error); +} + +.result-error { + margin: 0; + padding: 22px 20px; + color: var(--error); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.86rem; +} + +th, +td { + padding: 12px 20px; + border-block-end: 1px solid var(--border); + text-align: start; +} + +th { + background: var(--surface-muted); + color: var(--text-muted); + font-size: 0.72rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +tbody tr:last-child td { + border-block-end: 0; +} + +td:nth-child(2), +td:nth-child(4) { + font-variant-numeric: tabular-nums; + font-weight: 650; +} + +@media (max-width: 960px) { + .section-heading { + align-items: start; + flex-direction: column; + } + + .button-row { + justify-content: flex-start; + } + + .source-row { + grid-template-columns: 180px 1fr; + } + + .source-row__actions { + grid-column: 1 / -1; + justify-content: flex-end; + } + + .calibration-layout { + grid-template-columns: 1fr; + } + + .inspector { + border-block-start: 1px solid var(--border); + border-inline-start: 0; + } +} + +@media (max-width: 640px) { + .app-shell { + padding-inline: 14px; + } + + .app-header { + align-items: start; + flex-direction: column; + } + + .status { + width: 100%; + } + + .step-nav { + margin-block: 22px 30px; + } + + .step-nav__item { + flex-direction: column; + gap: 5px; + font-size: 0.76rem; + } + + .source-row { + grid-template-columns: 1fr; + } + + .source-row__actions { + grid-column: auto; + flex-wrap: wrap; + justify-content: flex-start; + } + + .button-row, + .button-row > * { + width: 100%; + } + + .canvas-stage { + min-height: 360px; + padding: 12px; + } + + .result-group { + overflow-x: auto; + } + + table { + min-width: 680px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + color: oklch(0.9 0.009 250); + background: oklch(0.18 0.012 255); + --background: oklch(0.18 0.012 255); + --surface: oklch(0.225 0.014 255); + --surface-raised: oklch(0.255 0.015 255); + --surface-muted: oklch(0.28 0.016 255); + --text: oklch(0.9 0.009 250); + --text-muted: oklch(0.68 0.018 250); + --border: oklch(0.34 0.018 255); + --border-strong: oklch(0.43 0.022 255); + --accent: oklch(0.7 0.15 250); + --accent-hover: oklch(0.75 0.13 250); + --accent-soft: oklch(0.3 0.05 250); + --success: oklch(0.72 0.12 155); + --success-soft: oklch(0.29 0.045 155); + --error: oklch(0.72 0.15 25); + --error-soft: oklch(0.3 0.05 25); + --warning: oklch(0.78 0.13 75); + --shadow: 0 18px 48px oklch(0.08 0.015 255 / 0.28); + } + + .canvas-stage { + background-color: oklch(0.16 0.01 255); + background-image: linear-gradient(45deg, oklch(0.2 0.012 255) 25%, transparent 25%), + linear-gradient(-45deg, oklch(0.2 0.012 255) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, oklch(0.2 0.012 255) 75%), + linear-gradient(-45deg, transparent 75%, oklch(0.2 0.012 255) 75%); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/vision.js b/src/vision.js new file mode 100644 index 0000000..e412e7d --- /dev/null +++ b/src/vision.js @@ -0,0 +1,124 @@ +import { createWorker, PSM } from "tesseract.js"; +import { normalizeOcrText, parseFieldValue } from "./parser.js"; + +let cvPromise; +let workerPromise; +let progressListener = () => {}; + +export function onOcrProgress(listener) { + progressListener = listener; +} + +async function getOpenCv() { + if (!cvPromise) { + cvPromise = import("@techstark/opencv-js").then(async ({ default: module }) => { + const cv = module instanceof Promise ? await module : module; + + if (cv.Mat) return cv; + + await new Promise((resolve) => { + cv.onRuntimeInitialized = resolve; + }); + return cv; + }); + } + + return cvPromise; +} + +async function getOcrWorker() { + if (!workerPromise) { + workerPromise = createWorker("eng", 1, { + logger: (message) => progressListener(message), + }); + } + + return workerPromise; +} + +export async function findAnchor(sourceCanvas, templateCanvas) { + if ( + templateCanvas.width > sourceCanvas.width || + templateCanvas.height > sourceCanvas.height + ) { + return { x: 0, y: 0, score: 0 }; + } + + const cv = await getOpenCv(); + const source = cv.imread(sourceCanvas); + const template = cv.imread(templateCanvas); + const sourceGray = new cv.Mat(); + const templateGray = new cv.Mat(); + const result = new cv.Mat(); + + try { + cv.cvtColor(source, sourceGray, cv.COLOR_RGBA2GRAY); + cv.cvtColor(template, templateGray, cv.COLOR_RGBA2GRAY); + cv.matchTemplate(sourceGray, templateGray, result, cv.TM_CCOEFF_NORMED); + const match = cv.minMaxLoc(result); + + return { + x: match.maxLoc.x, + y: match.maxLoc.y, + score: Number.isFinite(match.maxVal) ? match.maxVal : 0, + }; + } finally { + source.delete(); + template.delete(); + sourceGray.delete(); + templateGray.delete(); + result.delete(); + } +} + +function prepareOcrCrop(sourceCanvas, rect) { + const scale = 3; + const canvas = document.createElement("canvas"); + canvas.width = rect.width * scale; + canvas.height = rect.height * scale; + + const context = canvas.getContext("2d", { willReadFrequently: true }); + context.imageSmoothingEnabled = false; + context.filter = "grayscale(1) contrast(2)"; + context.drawImage( + sourceCanvas, + rect.x, + rect.y, + rect.width, + rect.height, + 0, + 0, + canvas.width, + canvas.height, + ); + + return canvas; +} + +export async function recognizeField(sourceCanvas, rect, type) { + const worker = await getOcrWorker(); + const numbersOnly = type === "price" || type === "quantity"; + + await worker.setParameters({ + tessedit_pageseg_mode: PSM.SINGLE_LINE, + tessedit_char_whitelist: numbersOnly ? "0123456789,. " : "", + preserve_interword_spaces: "1", + }); + + const image = prepareOcrCrop(sourceCanvas, rect); + const { data } = await worker.recognize(image); + const text = normalizeOcrText(data.text); + + return { + text, + value: parseFieldValue(type, text), + confidence: Math.round(data.confidence ?? 0), + }; +} + +export async function terminateVision() { + if (!workerPromise) return; + const worker = await workerPromise; + await worker.terminate(); + workerPromise = null; +} diff --git a/test/parser.test.js b/test/parser.test.js new file mode 100644 index 0000000..d500849 --- /dev/null +++ b/test/parser.test.js @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isCalibrationReady, + isRectInside, + parseFieldValue, + rectFromPoints, + resolveFieldRect, +} from "../src/parser.js"; + +test("normalizes a reverse drag into a positive rectangle", () => { + assert.deepEqual(rectFromPoints({ x: 140, y: 90 }, { x: 20, y: 30 }), { + x: 20, + y: 30, + width: 120, + height: 60, + }); +}); + +test("moves calibrated fields together with the detected anchor", () => { + assert.deepEqual( + resolveFieldRect( + { x: 400, y: 250 }, + { offsetX: 32, offsetY: 80, width: 180, height: 24 }, + ), + { x: 432, y: 330, width: 180, height: 24 }, + ); +}); + +test("parses formatted prices and rejects empty numeric OCR", () => { + assert.equal(parseFieldValue("price", "1 250,000 adena"), 1250000); + assert.equal(parseFieldValue("quantity", "not found"), null); + assert.equal(parseFieldValue("item", " Soulshot: S-grade\n"), "Soulshot: S-grade"); +}); + +test("requires an anchor image and at least one field", () => { + assert.equal(isCalibrationReady({ anchor: null, fields: [] }), false); + assert.equal( + isCalibrationReady({ + anchor: { image: "data:image/png;base64,x", width: 20, height: 10 }, + fields: [{ id: "price" }], + }), + true, + ); +}); + +test("detects OCR rectangles outside the captured frame", () => { + assert.equal(isRectInside({ x: 10, y: 10, width: 20, height: 20 }, 100, 100), true); + assert.equal(isRectInside({ x: 90, y: 10, width: 20, height: 20 }, 100, 100), false); +});