hh-shipments 0.3.23 → 0.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "test": "node test/run-tests.js && node test/run-carriers-tests.js"
19
19
  },
20
20
  "type": "commonjs",
21
- "version": "0.3.23",
21
+ "version": "0.3.25",
22
22
  "bin": {
23
23
  "hh-shipments": "server.js"
24
24
  },
package/public/app.js CHANGED
@@ -175,53 +175,120 @@ function loadTesseract() {
175
175
  return tesseractLoading;
176
176
  }
177
177
 
178
- // Tesseract reads dark-text-on-light far better than the reverse. Chat
179
- // screenshots (dark mode) come out as white-on-black, so detect dark images
180
- // and invert them before OCR. Light images (labels) pass through untouched.
181
- async function maybeInvertForOcr(file) {
178
+ // "Better eyes": smart OCR pipeline that adapts to image type.
179
+ // - Chat screenshots (dark): invert, PSM 6 (uniform block)
180
+ // - Labels (light): crop to tracking zone, upscale, PSM 6
181
+ // - Falls back to full-image PSM 3 if targeted fails
182
+ async function ocrFile(file, statusEl) {
183
+ await loadTesseract();
184
+ statusEl.textContent = 'Reading screenshot…';
185
+ const isDark = await isDarkImage(file);
186
+ file = isDark ? await invertImage(file) : file;
187
+
188
+ const log = (m) => { if (m.status === 'recognizing text' && statusEl) statusEl.textContent = `Reading… ${Math.round(m.progress * 100)}%`; };
189
+
190
+ // Pass 1: targeted — for labels, crop to the tracking-number zone (bottom 45%)
191
+ // and upscale for Tesseract. For chats, use the full image.
192
+ let bestText = '';
193
+ if (!isDark) {
194
+ try {
195
+ const zoned = await cropAndUpscale(file, 0, 0.55, 1, 0.45, 2);
196
+ const r1 = await Tesseract.recognize(zoned, 'eng', {
197
+ logger: log,
198
+ tessedit_pageseg_mode: '6', // uniform block of text
199
+ });
200
+ bestText = r1.data.text || '';
201
+ // If the zone has a plausible tracking number, we're done
202
+ if (/(1Z|12|IZ|17)\s*[0-9A-Z)\]}(\[{|!]/i.test(bestText)) {
203
+ if (statusEl) statusEl.textContent = '';
204
+ return bestText;
205
+ }
206
+ } catch (e) {}
207
+ }
208
+
209
+ // Pass 2: full image with PSM 6
210
+ try {
211
+ const r2 = await Tesseract.recognize(file, 'eng', {
212
+ logger: log,
213
+ tessedit_pageseg_mode: '6',
214
+ });
215
+ const t2 = r2.data.text || '';
216
+ if (t2.length > bestText.length) bestText = t2;
217
+ } catch (e) {}
218
+
219
+ // Pass 3: fallback to default PSM 3 if we got nothing useful
220
+ if (!bestText || bestText.trim().length < 10) {
221
+ try {
222
+ const r3 = await Tesseract.recognize(file, 'eng', { logger: log });
223
+ const t3 = r3.data.text || '';
224
+ if (t3.length > bestText.length) bestText = t3;
225
+ } catch (e) {}
226
+ }
227
+
228
+ if (statusEl) statusEl.textContent = '';
229
+ return bestText;
230
+ }
231
+
232
+ async function isDarkImage(file) {
182
233
  try {
183
234
  const bmp = await createImageBitmap(file);
184
- const w = bmp.width, h = bmp.height;
185
- const canvas = document.createElement('canvas');
186
- // sample at reduced size for speed
187
- const sw = Math.min(200, w), sh = Math.min(200, h);
188
- canvas.width = sw; canvas.height = sh;
189
- const ctx = canvas.getContext('2d', { willReadFrequently: true });
235
+ const sw = Math.min(100, bmp.width), sh = Math.min(100, bmp.height);
236
+ const c = document.createElement('canvas');
237
+ c.width = sw; c.height = sh;
238
+ const ctx = c.getContext('2d', { willReadFrequently: true });
190
239
  ctx.drawImage(bmp, 0, 0, sw, sh);
191
240
  const px = ctx.getImageData(0, 0, sw, sh).data;
192
241
  let sum = 0, n = 0;
193
242
  for (let i = 0; i < px.length; i += 16) {
194
- sum += 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
243
+ sum += 0.299 * px[i] + 0.587 * px[i+1] + 0.114 * px[i+2];
195
244
  n++;
196
245
  }
197
- const avg = sum / n;
198
246
  bmp.close();
199
- if (avg > 110) return file; // already light — leave it alone
200
- // dark image: invert to dark-on-light
201
- const c2 = document.createElement('canvas');
202
- c2.width = w; c2.height = h;
203
- const x2 = c2.getContext('2d');
204
- const full = await createImageBitmap(file);
205
- x2.drawImage(full, 0, 0);
206
- full.close();
207
- const img = x2.getImageData(0, 0, w, h);
247
+ return (sum / n) < 110;
248
+ } catch (e) { return false; }
249
+ }
250
+
251
+ async function invertImage(file) {
252
+ try {
253
+ const bmp = await createImageBitmap(file);
254
+ const c = document.createElement('canvas');
255
+ c.width = bmp.width; c.height = bmp.height;
256
+ const ctx = c.getContext('2d');
257
+ ctx.drawImage(bmp, 0, 0);
258
+ bmp.close();
259
+ const img = ctx.getImageData(0, 0, c.width, c.height);
208
260
  const d = img.data;
209
261
  for (let i = 0; i < d.length; i += 4) {
210
- d[i] = 255 - d[i]; d[i + 1] = 255 - d[i + 1]; d[i + 2] = 255 - d[i + 2];
262
+ d[i] = 255 - d[i]; d[i+1] = 255 - d[i+1]; d[i+2] = 255 - d[i+2];
211
263
  }
212
- x2.putImageData(img, 0, 0);
213
- const blob = await new Promise((res) => c2.toBlob(res, 'image/jpeg', 0.92));
264
+ ctx.putImageData(img, 0, 0);
265
+ const blob = await new Promise((res) => c.toBlob(res, 'image/jpeg', 0.92));
214
266
  return blob ? new File([blob], 'inv.jpg', { type: 'image/jpeg' }) : file;
215
267
  } catch (e) { return file; }
216
268
  }
217
269
 
218
- async function ocrFile(file, statusEl) {
219
- await loadTesseract();
220
- statusEl.textContent = 'Reading screenshot…';
221
- file = await maybeInvertForOcr(file);
222
- const { data } = await Tesseract.recognize(file, 'eng', { logger: (m) => { if (m.status === 'recognizing text') statusEl.textContent = `Reading… ${Math.round(m.progress * 100)}%`; } });
223
- statusEl.textContent = '';
224
- return data.text || '';
270
+ async function cropAndUpscale(file, fx, fy, fw, fh, scale) {
271
+ const bmp = await createImageBitmap(file);
272
+ const sw = Math.round(bmp.width * fw), sh = Math.round(bmp.height * fh);
273
+ const c = document.createElement('canvas');
274
+ c.width = Math.round(sw * scale);
275
+ c.height = Math.round(sh * scale);
276
+ const ctx = c.getContext('2d');
277
+ ctx.drawImage(bmp, bmp.width * fx, bmp.height * fy, sw, sh, 0, 0, c.width, c.height);
278
+ bmp.close();
279
+ // Boost contrast for text
280
+ try {
281
+ const img = ctx.getImageData(0, 0, c.width, c.height);
282
+ const d = img.data;
283
+ for (let i = 0; i < d.length; i += 4) {
284
+ const v = 0.299*d[i] + 0.587*d[i+1] + 0.114*d[i+2];
285
+ const nv = v < 140 ? Math.max(0, v - 40) : Math.min(255, v + 40);
286
+ d[i] = d[i+1] = d[i+2] = nv;
287
+ }
288
+ ctx.putImageData(img, 0, 0);
289
+ } catch (e) {}
290
+ const blob = await new Promise((res) => c.toBlob(res, 'image/jpeg', 0.95));
291
+ return new File([blob], 'crop.jpg', { type: 'image/jpeg' });
225
292
  }
226
293
 
227
294
  async function handlePhotoFile(f) {
@@ -382,24 +449,41 @@ async function decodeBarcode(file) {
382
449
  try { bmp = await createImageBitmap(file); } catch (e) { return null; }
383
450
  const hints = new Map([[ZXing.DecodeHintType.TRY_HARDER, true]]);
384
451
  const reader = new ZXing.BrowserMultiFormatReader(hints);
452
+ // Aggressive region/scale sweep: labels photographed at angles need multiple
453
+ // crops. The UPS 1Z barcode is typically in the lower half.
385
454
  const regions = [
386
- [0, 0, 1, 1], // full image
387
- [0, 0.35, 1, 0.35], // middle strip
388
- [0, 0.55, 1, 0.40], // lower strip (UPS 1Z barcode zone)
455
+ [0, 0, 1, 1],
456
+ [0, 0.30, 1, 0.40],
457
+ [0, 0.50, 1, 0.45],
458
+ [0, 0.60, 1, 0.35],
459
+ [0.05, 0.55, 0.90, 0.40],
389
460
  ];
390
461
  try {
391
462
  for (const [fx, fy, fw, fh] of regions) {
392
- const sw = Math.round(bmp.width * fw), sh = Math.round(bmp.height * fh);
393
- if (sw < 60 || sh < 60) continue;
394
- const scale = Math.max(1, 1200 / sw);
395
- const canvas = document.createElement('canvas');
396
- canvas.width = Math.round(sw * scale);
397
- canvas.height = Math.round(sh * scale);
398
- canvas.getContext('2d').drawImage(bmp, bmp.width * fx, bmp.height * fy, sw, sh, 0, 0, canvas.width, canvas.height);
399
- const url = canvas.toDataURL('image/png');
400
- const text = await decodeOne(reader, url);
401
- const num = strictTracking(text);
402
- if (num) return num;
463
+ for (const scale of [1, 1.5, 2]) {
464
+ const sw = Math.round(bmp.width * fw), sh = Math.round(bmp.height * fh);
465
+ if (sw < 80 || sh < 40) continue;
466
+ const canvas = document.createElement('canvas');
467
+ canvas.width = Math.round(sw * scale);
468
+ canvas.height = Math.round(sh * scale);
469
+ const ctx = canvas.getContext('2d');
470
+ ctx.drawImage(bmp, bmp.width * fx, bmp.height * fy, sw, sh, 0, 0, canvas.width, canvas.height);
471
+ // Boost contrast: barcodes need sharp black/white separation
472
+ try {
473
+ const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
474
+ const d = img.data;
475
+ for (let i = 0; i < d.length; i += 4) {
476
+ const v = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
477
+ const nv = v < 128 ? 0 : 255;
478
+ d[i] = d[i + 1] = d[i + 2] = nv;
479
+ }
480
+ ctx.putImageData(img, 0, 0);
481
+ } catch (e) {}
482
+ const url = canvas.toDataURL('image/png');
483
+ const text = await decodeOne(reader, url);
484
+ const num = strictTracking(text);
485
+ if (num) return num;
486
+ }
403
487
  }
404
488
  } finally { bmp.close(); }
405
489
  return null;
@@ -516,13 +600,12 @@ async function loadPending() {
516
600
  async function reviewPendingImage(id) {
517
601
  $('photo-result').innerHTML = `<img src="/api/intake/image/${id}" style="max-width:100%;border-radius:10px"><p class="muted">Extracting…</p>`;
518
602
  try {
519
- await loadTesseract();
520
603
  const resp = await fetch(`/api/intake/image/${id}`);
521
604
  const blob = await resp.blob();
522
- const file = await maybeInvertForOcr(new File([blob], 'pending.jpg', { type: blob.type || 'image/jpeg' }));
523
- const { data } = await Tesseract.recognize(file, 'eng');
605
+ const file = new File([blob], 'pending.jpg', { type: blob.type || 'image/jpeg' });
606
+ const text = await ocrFile(file, null);
524
607
  await api.patch('/api/intake/' + id, { state: 'reviewed' });
525
- await runQuick(data.text || '', $('photo-result'));
608
+ await runQuick(text, $('photo-result'));
526
609
  loadPending();
527
610
  } catch (e) { $('photo-result').innerHTML = `<p class="error">${esc(e.message)}</p>`; }
528
611
  }
package/public/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <meta name="theme-color" content="#0a84ff">
7
- <meta name="app-version" content="0.3.23">
7
+ <meta name="app-version" content="0.3.25">
8
8
  <title>HH Shipments</title>
9
9
  <link rel="manifest" href="/manifest.webmanifest">
10
10
  <link rel="apple-touch-icon" href="/icon-192.png">
package/src/extract.js CHANGED
@@ -152,20 +152,51 @@
152
152
  }
153
153
 
154
154
  // UPS number split by spaces/dashes: "1Z 9999 99 ..." (or "12 ..."/"IZ ..." misreads)
155
+ // Hot-zone: text right after "TRACKING" often has a dropped character
156
+ // (e.g. "17 1) 539 031342 3923" = 17 chars, missing the J in 1XJ).
157
+ // Try expanding punct confusions that may hide two chars.
158
+ function expandPunctHotZone(text, found, seen) {
159
+ var m = /TRACKING\s*#?\s*:?\s*([0-9A-Z)\]}(\[{|!\s-]{15,25})/i.exec(text);
160
+ if (!m) return;
161
+ var raw = m[1].replace(/[\s-]/g, '');
162
+ // Don't swallow trailing English words ("thanks", "you") as number chars
163
+ raw = raw.replace(/(THANK|THANKS|YOU|PLEASE)$/i, '');
164
+ if (/^(1Z|12|IZ|17)/i.test(raw) && (raw.length === 17 || raw.length === 16 || raw.length === 18)) {
165
+ var anchored = raw.replace(/^(12|IZ|17)/i, '1Z');
166
+ // ")" may be "XJ" collapsed (X+J look like ) when smudged)
167
+ var expanded = anchored.replace(/\)/g, 'XJ').replace(/\(/g, 'CC');
168
+ if (/^1Z[0-9A-Z]{16}$/.test(expanded)) {
169
+ var num = repairUpsTail(expanded.slice(2)) || expanded;
170
+ if (num && UPS_STRICT.test(num) && /[A-Z]/.test(num.slice(2, 8)) && !seen[num]) {
171
+ seen[num] = true;
172
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: true });
173
+ }
174
+ }
175
+ }
176
+ }
177
+
155
178
  function spacedUpsIn(text, found, seen, conf) {
156
- var m, re = /\b(?:1Z|12|IZ)[\s-]?(?:[0-9A-Z][\s-]?){16}\b/gi;
179
+ // Anchor covers common 1Z misreads: 12, IZ, 17 (7→Z confusion)
180
+ var m, re = /\b(?:1Z|12|IZ|17)[\s-]?(?:[0-9A-Z)\]}(\[{|!][\s-]?){16}\b/gi;
157
181
  while ((m = re.exec(text)) !== null) {
158
182
  var ups = normalize(m[0]);
159
183
  var num = null;
160
- if (/^1Z[0-9A-Z]{6}[0-9]{10}$/.test(ups)) num = ups; // fully strict
161
- else if (/^1Z[0-9A-Z]{16}$/.test(ups)) {
162
- // letters sitting in the digit zone are OCR confusions — repair them
163
- num = repairUpsTail(ups.slice(2)) || ups;
184
+ // Normalize the anchor: 12/IZ/17 → 1Z
185
+ var anchored = ups.replace(/^(12|IZ|17)/i, '1Z');
186
+ if (/^1Z[0-9A-Z]{6}[0-9]{10}$/.test(anchored)) num = anchored; // fully strict
187
+ else if (/^1Z[0-9A-Z]{16}$/.test(anchored)) {
188
+ // Repair tail; if repair fails the candidate is invalid — never accept raw
189
+ num = repairUpsTail(anchored.slice(2));
190
+ }
191
+ else num = repairUpsZ(anchored) || repairUpsTail(anchored.slice(2));
192
+ // ")" can be X or J — try X variant if J mapping failed validation
193
+ if (!num && /\)/.test(anchored)) {
194
+ var xVar = anchored.replace(/\)/g, 'X');
195
+ if (/^1Z[0-9A-Z]{16}$/.test(xVar)) num = repairUpsTail(xVar.slice(2));
164
196
  }
165
- else num = repairUpsZ(ups) || repairUpsTail(ups.slice(2));
166
197
  if (num && !seen[num]) {
167
198
  seen[num] = true;
168
- found.push({ number: num, carrier: 'ups', confidence: (num !== ups && conf === 'medium') ? 'high' : conf, ocr_fixed: num !== ups });
199
+ found.push({ number: num, carrier: 'ups', confidence: 'high', ocr_fixed: num !== ups });
169
200
  }
170
201
  }
171
202
  }
@@ -316,6 +347,7 @@
316
347
  // pass already tried the hot zones at high confidence). Repairs are
317
348
  // strict-structure-validated, so they stay high confidence.
318
349
  spacedUpsIn(text, found, seen, 'high');
350
+ expandPunctHotZone(text, found, seen);
319
351
  looseUpsIn(text, found, seen, 'high');
320
352
  // b) long digit-like runs with OCR confusions -> fix -> re-match digit formats
321
353
  var runRe = /[0-9OILSBoilszb]{10,24}/g, run;