baldav/frontend/js/chzn.js
4aiman 5f30af1cf7 ЧЗ scanner: switch decoder to zxing-wasm (ZXing-C++) for reliable DataMatrix
The pure-JS @zxing/library locator is weak on real ЧЗ codes (decoded 1/4 of the
test photos with correct grayscale input; libdmtx and zxing-wasm read 3/4 — the
4th is physically undecodable). Symptom: scanner takes very long to even find a
code, 2 of 4 never read.

- Bundle zxing-wasm reader as an IIFE (frontend/lib/zxing-wasm.js, global
  ZXingWASM) + zxing_reader.wasm; drop frontend/lib/zxing.js. Init fetches the
  .wasm as an arrayBuffer and hands it over as wasmBinary (no streaming/MIME/CDN).
- ChZ.zxReady()/ChZ.decodeImageData() (chzn.js): decode an ImageData -> raw GS1
  bytes (result.bytes keeps the GS separators) with tryHarder/tryRotate/tryInvert.
- Scanner (main.js) rewritten to a canvas loop: crop the central ROI, upscale to
  540px, decode each frame — full-frame search was the slow/unreliable part.
  Photo path decodes the picked image the same way.

Not yet verified on-device (WebView WASM instantiation); decoder logic validated
in a Node harness against the 4 sample photos.
2026-08-27 09:42:05 +03:00

309 lines
12 KiB
JavaScript

// ---------------------------------------------------------------------------
// Честный знак (CRPT) integration.
//
// Scan a product's ЧЗ DataMatrix -> resolve via mobile.api.crpt.ru -> pull КБЖУ
// from the National Catalog card -> prefill the "new product" form.
//
// Auth: the CRPT anonymous-session endpoint (/authorize/anonymous) is gated by
// Google Play Integrity (bound to the ЧЗ app package) so BalDav cannot mint its
// own session. Instead a session is exported from the ЧЗ app on a rooted device
// with tools/chzn_export.sh and imported here as a JSON file. The session's sid
// (48h) is kept alive indefinitely via /authorize/refresh (NOT integrity-gated).
//
// All requests go through the Rust `chzn_http` command (returns {status,body})
// so there is no CORS and we can see 204/401/427 status codes.
// ---------------------------------------------------------------------------
let ChZ = (function () {
let LS_KEY = "chzn_session";
let DEFAULT_BASE = "https://mobile.api.crpt.ru";
let DEFAULT_EXPIRES = 172800000; // 48h fallback
function invoke(url, method, body) {
return window.__TAURI__.core.invoke("chzn_http", { url, method, body: body || null });
}
// Returns {status:int, body:string} or throws.
async function raw(url, method, body) {
let envelope = await invoke(url, method, body);
let parsed = JSON.parse(envelope);
return parsed;
}
function getSession() {
try { return JSON.parse(localStorage.getItem(LS_KEY)) || null; } catch { return null; }
}
function setSession(s) {
localStorage.setItem(LS_KEY, JSON.stringify(s));
}
function hasSession() {
let s = getSession();
return !!(s && s.userId && s.sid && s.refreshToken && s.instanceId && s.deviceId);
}
function baseUrl() {
let s = getSession();
return (s && s.baseUrl) || DEFAULT_BASE;
}
// Import an exported chzn_session.json (text). Returns the stored session.
function importFromText(text) {
let d = JSON.parse(text);
let need = ["userId", "sid", "refreshToken", "instanceId", "deviceId"];
for (let k of need) {
if (d[k] === undefined || d[k] === null || d[k] === "") {
throw new Error("в файле нет поля " + k);
}
}
let s = {
source: "chestny_znak",
baseUrl: d.baseUrl || DEFAULT_BASE,
userId: d.userId,
sid: d.sid,
refreshToken: d.refreshToken,
instanceId: d.instanceId,
deviceId: String(d.deviceId),
sidObtainedAt: d.sidObtainedAt || Date.now(),
expiresInMs: d.expiresInMs || DEFAULT_EXPIRES,
};
setSession(s);
return s;
}
function sidExpired(s) {
let exp = s.expiresInMs || DEFAULT_EXPIRES;
let got = s.sidObtainedAt || 0;
return Date.now() >= got + exp - 120000; // refresh 2 min early
}
// POST /mobile/authorize/refresh -> new sid. Updates stored session.
async function refresh() {
let s = getSession();
if (!s) throw new Error("нет сессии Честного знака");
let body = JSON.stringify({
instanceId: s.instanceId,
refresh_token: s.refreshToken,
device_id: String(s.deviceId),
client: "MOBILE",
});
let r = await raw(baseUrl() + "/mobile/authorize/refresh", "POST", body);
if (r.status !== 200) throw new Error("refresh HTTP " + r.status);
let j = JSON.parse(r.body);
if (!j.sid) throw new Error("refresh без sid: " + r.body.slice(0, 120));
s.sid = j.sid;
if (j.refreshToken) s.refreshToken = j.refreshToken;
s.expiresInMs = j.expiresIn || s.expiresInMs || DEFAULT_EXPIRES;
s.sidObtainedAt = Date.now();
setSession(s);
return s;
}
// Ensure a fresh sid before an authed call.
async function ensureSid() {
let s = getSession();
if (!s) throw new Error("нет сессии Честного знака — импортируйте chzn_session.json");
if (sidExpired(s)) return await refresh();
return s;
}
function looksLikeAuthError(r) {
if (r.status === 401 || r.status === 403) return true;
if (r.status === 400 && /"formErrors"[\s\S]*"(userId|sid)"/.test(r.body || "")) return true;
return false;
}
// Authenticated POST with userId/sid in the query string; refreshes once on auth error.
async function authedPost(path, body) {
let s = await ensureSid();
let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") +
"userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid);
let r = await raw(mk(s), "POST", body);
if (looksLikeAuthError(r)) {
s = await refresh();
r = await raw(mk(s), "POST", body);
}
return r;
}
async function authedGet(path) {
let s = await ensureSid();
let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") +
"userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid);
let r = await raw(mk(s), "GET");
if (looksLikeAuthError(r)) {
s = await refresh();
r = await raw(mk(s), "GET");
}
return r;
}
// POST /mobile/check with a GS1 code string. Returns parsed check JSON.
async function check(code) {
let r = await authedPost("/mobile/check", JSON.stringify({ code }));
if (r.status !== 200) throw new Error("check HTTP " + r.status + ": " + (r.body || "").slice(0, 160));
return JSON.parse(r.body);
}
// Optional bonus: GET /mobile/nutrition/info -> vitamins/minerals. Often 204.
async function nutritionInfo(checkId, gtin) {
try {
let r = await authedGet("/mobile/nutrition/info?checkId=" + encodeURIComponent(checkId) +
"&gtin=" + encodeURIComponent(gtin));
if (r.status !== 200 || !r.body) return null;
return JSON.parse(r.body);
} catch { return null; }
}
// ------------------------------------------------------------------
// GS1 code normalisation.
// A GS1 DataMatrix is 01<gtin14>21<serial><GS>93<crypto>. The GS (0x1D)
// separator between the variable-length AI 21 and AI 93 is REQUIRED for
// /mobile/check to resolve. Different decoders present it differently, so:
// - strip a leading symbology id (]d2, ]C1, {FNC1}) and leading GS chars;
// - if the string already contains a GS, keep it;
// - otherwise insert a GS before the last "93".
// ------------------------------------------------------------------
let GS = String.fromCharCode(0x1d);
function normalizeCode(rawStr) {
let s = String(rawStr || "");
s = s.replace(/^\]d2/, "").replace(/^\]C1/, "").replace(/^\]Q3/, "");
s = s.replace(/^\{FNC1\}/i, "").replace(/\{GS\}/gi, GS);
s = s.replace(new RegExp("^" + GS + "+"), "");
if (s.indexOf(GS) === -1) {
let m = s.match(/^01(\d{14})21(.+)$/);
if (m) {
let tail = m[2];
let idx = tail.lastIndexOf("93");
if (idx > 0) {
s = "01" + m[1] + "21" + tail.slice(0, idx) + GS + tail.slice(idx);
}
}
}
return s;
}
function extractGtin(code) {
let m = String(code || "").match(/^01(\d{14})/);
return m ? m[1] : null;
}
// ------------------------------------------------------------------
// Parse the National Catalog card (check.catalogData[0].good_attrs) into a
// product prefill object. КБЖУ live in the group "Энергетическая ценность,
// белки, жиры и углеводы" but attr names vary by producer
// ("Белки" vs "Белки, количество", etc.), so match by prefix within the group.
// ------------------------------------------------------------------
function num(v) {
if (v === undefined || v === null) return null;
let n = parseFloat(String(v).replace(",", ".").replace(/[^\d.\-]/g, ""));
return isFinite(n) ? n : null;
}
function parseCard(chk) {
let cd = (chk.catalogData || [])[0] || {};
let attrs = cd.good_attrs || [];
let inEnergy = (a) => (a.attr_group_name || "").indexOf("Энергетическая") === 0;
let find = (pred) => attrs.find(pred);
let kcalAttr = find(a => inEnergy(a) && /Энергетическая ценность/i.test(a.attr_name) &&
/ккал/i.test((a.attr_value_type || "") + " " + a.attr_name));
let proteinAttr = find(a => inEnergy(a) && /^Белки/i.test(a.attr_name));
let fatAttr = find(a => inEnergy(a) && /^Жиры/i.test(a.attr_name));
let carbsAttr = find(a => inEnergy(a) && /^Углеводы/i.test(a.attr_name));
let compAttr = find(a => /^Состав$/i.test(a.attr_name) || (a.attr_group_name === "Состав"));
let weightAttr = find(a => /вес нетто|Заявленный объ[её]м/i.test(a.attr_name));
let name = cd.good_name || chk.productName || "";
let prefill = {
name: name,
kcal_per_100g: kcalAttr ? num(kcalAttr.attr_value) : null,
protein_per_100g: proteinAttr ? num(proteinAttr.attr_value) : null,
fat_per_100g: fatAttr ? num(fatAttr.attr_value) : null,
carbs_per_100g: carbsAttr ? num(carbsAttr.attr_value) : null,
composition: compAttr ? String(compAttr.attr_value).trim() : null,
net_weight_g: weightAttr ? num(weightAttr.attr_value) : null,
gtin: extractGtin(chk.code) || (chk.codeResolveData || {}).gtin || null,
source: "chzn",
micros: {},
};
return prefill;
}
// Map ЧЗ vitamin/mineral names to product table columns (unit matches column).
let MICRO_MAP = {
"Кальций": "calcium_mg", "Фосфор": "phosphorus_mg", "Железо": "iron_mg",
"Магний": "magnesium_mg", "Калий": "potassium_mg", "Натрий": "sodium_mg",
"Цинк": "zinc_mg", "Медь": "copper_mg", "Селен": "selenium_mcg",
"Витамин A": "vit_a_mcg", "Витамин C": "vit_c_mg", "Витамин D": "vit_d_mcg",
"Витамин E": "vit_e_mg", "Витамин B1": "vit_b1_mg", "Витамин B2": "vit_b2_mg",
"Витамин B6": "vit_b6_mg", "Витамин B12": "vit_b12_mcg", "Фолиевая кислота": "folate_mcg",
};
function mergeMicros(prefill, nut) {
if (!nut || !Array.isArray(nut.nutrition)) return prefill;
for (let blk of nut.nutrition) {
for (let c of (blk.components || [])) {
if (c.key !== "vitaminsAndMinerals") continue;
for (let d of (c.details || [])) {
for (let row of (d.rows || [])) {
let col = MICRO_MAP[row.name];
let val = row.calculationValue;
if (col && val != null) prefill.micros[col] = val;
}
}
}
}
return prefill;
}
// Full resolve: GS1 code (raw from scanner) -> prefill object.
async function resolve(rawCode) {
let code = normalizeCode(rawCode);
let chk = await check(code);
if (chk.codeFounded === false) {
let e = new Error("код не найден в Честном знаке");
e.notFound = true;
throw e;
}
let prefill = parseCard(chk);
let cid = chk.id;
let gtin = prefill.gtin;
if (cid && gtin) {
let nut = await nutritionInfo(cid, gtin);
mergeMicros(prefill, nut);
}
return prefill;
}
// ------------------------------------------------------------------
// DataMatrix decoding via bundled zxing-wasm (ZXing-C++). Far better at
// LOCATING codes than the pure-JS port. The .wasm is fetched once as an
// arrayBuffer and handed to the module (wasmBinary) — no streaming/MIME issues.
// ------------------------------------------------------------------
let _zxReadyP = null;
function zxReady() {
if (_zxReadyP) return _zxReadyP;
_zxReadyP = (async () => {
if (typeof ZXingWASM === "undefined") throw new Error("ZXingWASM не загружен (пересобери приложение)");
let buf = await (await fetch("lib/zxing_reader.wasm")).arrayBuffer();
await ZXingWASM.prepareZXingModule({ overrides: { wasmBinary: new Uint8Array(buf) }, fireImmediately: true });
return true;
})();
return _zxReadyP;
}
// Decode one ImageData -> raw GS1 code string (with GS 0x1D), or null.
async function decodeImageData(imageData, formats) {
let res = await ZXingWASM.readBarcodes(imageData, {
formats: formats || ["DataMatrix"],
tryHarder: true, tryRotate: true, tryInvert: true, maxNumberOfSymbols: 1,
});
if (!res || !res.length || !res[0].isValid) return null;
let r = res[0];
// raw payload from bytes keeps the GS separators; fall back to text
if (r.bytes && r.bytes.length) return String.fromCharCode.apply(null, r.bytes);
return r.text || null;
}
return {
getSession, setSession, hasSession, importFromText, refresh, ensureSid,
check, nutritionInfo, normalizeCode, extractGtin, parseCard, resolve,
zxReady, decodeImageData,
_raw: raw,
};
})();