DEV Community

Joe Lin for BeGoodTool.com

Posted on

Browser OCR got a lot better once I stopped feeding Tesseract the raw image

I assumed an image-to-text tool would be a very thin wrapper around Tesseract: upload a file, call recognize(), dump the text, done.

That version technically worked. It was also the kind of "works" that immediately annoys people: first run felt frozen, the wrong language silently trashed accuracy, and low-contrast screenshots produced junk even when the text looked readable to me.

What ended up mattering most wasn't some exotic OCR trick. It was making sure the browser OCR engine gets the right pixels, loads at the right time, and reports what it's doing while it warms up.

The OCR engine is loaded lazily, not at page startup

The tool is a Vue page, but the OCR engine is tesseract.js, which means WebAssembly plus a worker. Loading that eagerly would be wasteful, and in an SSR-ish setup it's also the kind of dependency you don't want touching the server bundle unless the user actually starts recognition.

So the OCR import happens inside the click handler:

const startRecognition = async () => {
  if (!previewCanvasEl.value || !naturalWidth.value) return;
  isRecognizing.value = true;
  progressPercent.value = 0;
  progressStatusText.value = t("imageOcrTextExtractor.statusPreparing");

  try {
    const { createWorker } = await import("tesseract.js");
    const worker = await createWorker(selectedLang.value, 1, {
      logger: (m) => {
        if (m && m.status) {
          progressStatusText.value = mapStatusText(m.status);
          progressPercent.value = Math.round((m.progress || 0) * 100);
        }
      },
    });
    activeWorker = worker;

    const { data } = await worker.recognize(previewCanvasEl.value);
    resultText.value = data && data.text ? data.text.trim() : "";
    await worker.terminate();
    activeWorker = null;
  } catch (err) {
    message.error({ content: t("imageOcrTextExtractor.errorRecognizeFailed") });
  }
};
Enter fullscreen mode Exit fullscreen mode

Two practical things are happening here.

First, await import("tesseract.js") keeps the heavy OCR dependency out of the initial page path. Second, the logger callback turns Tesseract's internal status events into a real progress bar instead of a dead-looking button. That's especially important because the first run may need to fetch the language model before recognition even starts.

The preview canvas is also the OCR input, which is the right trade-off

The most useful implementation detail in this tool is that it doesn't preprocess a hidden copy and OCR the original file anyway. It draws the image onto a canvas, applies the optional filters there, and then sends that exact canvas to Tesseract.

function renderCanvas() {
  if (!imgEl.value || !previewCanvasEl.value || !naturalWidth.value) return;
  const canvas = previewCanvasEl.value;
  canvas.width = naturalWidth.value;
  canvas.height = naturalHeight.value;
  const ctx = canvas.getContext("2d");
  ctx.drawImage(imgEl.value, 0, 0, naturalWidth.value, naturalHeight.value);

  if (grayscaleEnabled.value || contrastEnabled.value) {
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const data = imageData.data;
    const c = 55;
    const factor = (259 * (c + 255)) / (255 * (259 - c));
    for (let i = 0; i < data.length; i += 4) {
      let r = data[i];
      let g = data[i + 1];
      let b = data[i + 2];
      if (grayscaleEnabled.value) {
        const avg = 0.299 * r + 0.587 * g + 0.114 * b;
        r = avg;
        g = avg;
        b = avg;
      }
      if (contrastEnabled.value) {
        r = clampByte(factor * (r - 128) + 128);
        g = clampByte(factor * (g - 128) + 128);
        b = clampByte(factor * (b - 128) + 128);
      }
      data[i] = r;
      data[i + 1] = g;
      data[i + 2] = b;
    }
    ctx.putImageData(imageData, 0, 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

That means the user can see the same grayscale / contrast-adjusted pixels the OCR engine is about to read. For OCR tools, that feedback loop matters a lot more than people think. If the text gets clearer after preprocessing, the user immediately understands why accuracy improves. If it gets worse, they can turn the option back off before wasting another run.

Also: this is intentionally simple preprocessing. No deskewing, no denoising pipeline, no adaptive thresholding. Just a luminance-based grayscale pass and a fixed contrast boost. For a browser tool, that's a reasonable speed/benefit trade-off.

Language selection is treated as OCR configuration, not UI decoration

One subtle bug with multilingual OCR tools is assuming the page locale and the image language are the same thing. This component avoids that by making the OCR language an explicit control, even though it defaults from the current site locale:

const tessLangMap = {
  tw: "chi_tra",
  cn: "chi_sim",
  en: "eng",
  jp: "jpn",
  fr: "fra",
  ru: "rus",
  kr: "kor",
  th: "tha",
  de: "deu",
  id: "ind",
  es: "spa",
  vi: "vie",
  pl: "pol",
  tr: "tur",
  it: "ita",
  pt: "por",
  nl: "nld",
  uk: "ukr",
};

const selectedLang = ref(tessLangMap[locale.value] || "eng");
Enter fullscreen mode Exit fullscreen mode

That looks small, but it's one of the highest-impact decisions in the whole tool. OCR quality drops fast when the engine is loaded with the wrong language data, especially for scripts like Traditional Chinese, Simplified Chinese, Japanese, Korean, or Thai where character shapes and segmentation rules differ a lot from Latin text.

The UI copy in the language file even spells this out: the text language in the image is not necessarily the same as the website language. That's exactly the kind of thing users need to be told, because otherwise they blame the OCR engine for what is really a configuration mistake.

The honest limitations

This implementation is solid, but it's also refreshingly honest about what it does not do.

  • It only accepts jpeg, jpg, png, webp, and bmp. No PDF pipeline, no TIFF, no camera RAW.
  • The preprocessing is basic. It can help faint text and messy backgrounds, but it won't fix rotation, perspective distortion, motion blur, or handwriting.
  • The result uses data.text.trim() and stops there. The tool doesn't surface word boxes, line geometry, or confidence scores, even though those would be useful for debugging weak OCR output.
  • First use can still feel slow because language data has to be loaded before recognition begins. The progress bar helps, but it doesn't make the download disappear.

I like this kind of constraint, honestly. It keeps the tool focused on the common case: screenshots, photos, and scans where you mainly want copyable text without sending the image to a server. I turned that into a small free tool here: Image to Text OCR Tool.


Available in other languages

Top comments (0)