All files / src/lib Ocr.ts

100% Statements 58/58
90.47% Branches 19/21
100% Functions 12/12
100% Lines 52/52

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166                                                              6x       15x             6x   5x                 2x       1x 1x   6x               6x 5x 5x 5x 5x 5x 5x 5x 5x 8x 8x 8x                         8x 8x         8x 8x 7x 7x 7x 7x 10x 10x 9x 9x             9x 9x   7x     9x       7x   9x 4x     9x   9x 4x     9x 4x 4x 4x 4x 4x 4x 4x                           5x   5x            
import type Tesseract from 'tesseract.js';
 
/** A recognized word; box in source-canvas pixels. */
export interface OcrWord {
  text: string;
  x0: number;
  y0: number;
  x1: number;
  y1: number;
}
 
/** Represents a single recognized text region normalized to image dimensions. */
export interface RecognizedTextBlock {
  text: string;
  /** Normalized coordinates (0 to 1) relative to the source image dimensions */
  bbox: {
    x: number;
    y: number;
    width: number;
    height: number;
  };
  confidence?: number;
}
 
export interface OcrResult {
  text: string;
  words: OcrWord[];
  blocks?: RecognizedTextBlock[];
}
 
/** Words crossing a band edge may be split or missed; overlapping bands would fix it. */
const BAND = 4000;
let worker: Promise<Tesseract.Worker> | undefined;
let onProgress: ((p: number) => void) | undefined;
/** Pages sit at the root of the extension and the desktop app, next to ocr/. */
const asset = (path: string) => new URL(path, location.href).href;
 
/**
 * Initializes a 100% offline bilingual (English + German) OCR worker
 * using locally bundled WASM core and traineddata models.
 */
function getWorker() {
  worker ??= import('tesseract.js')
    .then(({ default: T }) =>
      T.createWorker(['eng', 'deu'], T.OEM.LSTM_ONLY, {
        workerPath: asset('ocr/worker.min.js'),
        corePath: asset('ocr/tesseract-core-simd-lstm.wasm.js'),
        langPath: asset('ocr'),
        gzip: true,
        /** Extension CSP blocks blob: workers; the model is bundled, so skip the IndexedDB cache. */
        workerBlobURL: false,
        cacheMethod: 'none',
        logger: (m) =>
          m.status === 'recognizing text' && onProgress?.(m.progress),
      }),
    )
    .catch((e) => {
      worker = undefined;
      throw e;
    });
  return worker;
}
 
/** Reads mixed English and German text from `canvas`, in bands so tall captures stay within memory. Runs fully on-device. */
export async function recognize(
  canvas: HTMLCanvasElement,
  progress?: (p: number) => void,
): Promise<OcrResult> {
  const w = await getWorker();
  const band = document.createElement('canvas');
  band.width = canvas.width;
  const total = Math.ceil(canvas.height / BAND);
  const words: OcrWord[] = [];
  const blocks: RecognizedTextBlock[] = [];
  const texts: string[] = [];
  try {
    for (let i = 0; i < total; i++) {
      const top = i * BAND;
      band.height = Math.min(BAND, canvas.height - top);
      band
        .getContext('2d')!
        .drawImage(
          canvas,
          0,
          top,
          band.width,
          band.height,
          0,
          0,
          band.width,
          band.height,
        );
      onProgress = (p) => progress?.((i + p) / total);
      const { data } = await w.recognize(
        band,
        {},
        { text: true, blocks: true },
      );
      texts.push(data.text.trim().normalize('NFC'));
      for (const block of data.blocks ?? [])
        for (const para of block.paragraphs)
          for (const line of para.lines) {
            const lineWords: OcrWord[] = [];
            for (const word of line.words) {
              const wordTrimmed = word.text.trim().normalize('NFC');
              if (!wordTrimmed) continue;
              const { x0, y0, x1, y1 } = word.bbox;
              const wObj: OcrWord = {
                text: wordTrimmed,
                x0,
                y0: y0 + top,
                x1,
                y1: y1 + top,
              };
              words.push(wObj);
              lineWords.push(wObj);
            }
            const lineText = (
              line.text?.trim() ||
              lineWords
                .map((w) => w.text)
                .join(' ')
                .trim()
            ).normalize('NFC');
            if (lineText && (line.bbox || lineWords.length > 0)) {
              const lx0 =
                line.bbox?.x0 ?? Math.min(...lineWords.map((w) => w.x0));
              const ly0 =
                (line.bbox
                  ? line.bbox.y0
                  : Math.min(...lineWords.map((w) => w.y0 - top))) + top;
              const lx1 =
                line.bbox?.x1 ?? Math.max(...lineWords.map((w) => w.x1));
              const ly1 =
                (line.bbox
                  ? line.bbox.y1
                  : Math.max(...lineWords.map((w) => w.y1 - top))) + top;
              const imgW = Math.max(1, canvas.width);
              const imgH = Math.max(1, canvas.height);
              const x0 = Math.min(Math.max(lx0, 0), imgW);
              const y0 = Math.min(Math.max(ly0, 0), imgH);
              const x1 = Math.min(Math.max(lx1, 0), imgW);
              const y1 = Math.min(Math.max(ly1, 0), imgH);
              blocks.push({
                text: lineText,
                bbox: {
                  x: x0 / imgW,
                  y: y0 / imgH,
                  width: Math.max(0, (x1 - x0) / imgW),
                  height: Math.max(0, (y1 - y0) / imgH),
                },
                confidence: line.confidence,
              });
            }
          }
    }
  } finally {
    onProgress = undefined;
  }
  return {
    text: texts.filter(Boolean).join('\n\n').normalize('NFC'),
    words,
    blocks,
  };
}