Skip to content

fix(ocr): list the installed OCR languages and keep the chosen one - #195

Merged
nelsonduarte merged 1 commit into
mainfrom
fix/ocr-language-selection
Oct 11, 2026
Merged

nelsonduarte merged 1 commit into
mainfrom
fix/ocr-language-selection

Conversation

@nelsonduarte

Copy link
Copy Markdown
Owner

Summary

Bug. The OCR tool ignored the language the user chose. With Portuguese selected, it ran eng when eng and por were installed, and deu when deu, eng and por were installed.

Cause. TabOCR._run calls _ensure_tesseract, which rebuilt the language combo from pytesseract.get_languages() before _run read the selection. The rebuild called clear() and then addItem, and the first addItem selects index 0, so every run used the first installed code (osd excluded), in the alphabetical order of --list-langs.

Fix.

  • The rebuild restores the selection by Tesseract code. The code is kept in TabOCR._lang_pick, apart from the combo, and is set when the user picks a language or when the default rule picks one. The codes are stored as item data, replacing the parallel _lang_codes list.
  • A chosen code that is not installed leaves nothing selected, and _run stops with a new tool.ocr.lang_unavailable warning (added to the 8 locales) instead of running in another language. The pick comes back when the code is listed again.
  • The combo shows the installed languages. The first time the OCR page is shown, tesseract --list-langs runs in a QThreadPool job and the result reaches the page through a @Slot on a queued connection. While the scan runs, and if it fails, the fixed list stays.
  • The scan uses its own pool, not the global one, which the editor caps at 2 threads: behind two 3 s jobs on the global pool the scan waited 3.02 s, on its own pool 0.04 s.
  • Until the user picks a language, the rule picks the UI language's Tesseract code if it is installed, else eng, else the first installed one. The mapping covers the 8 UI locales (zh to chi_sim, nl to nld). chi_sim and nld were shown as raw codes; tool.ocr.lang.zh and tool.ocr.lang.nl now label them in the 8 locales.
  • --list-langs runs through a new helper, list_installed_langs, with CREATE_NO_WINDOW on Windows and a 5 s timeout, and its output is parsed as pytesseract parses it. pytesseract's get_languages and get_tesseract_version start Tesseract without that flag: under pythonw, one OCR run opened two Windows Terminal windows, and with the helper none. _ensure_tesseract calls the helper in place of both.
  • The selection rules and the helper live in app/tools/_ocr_langs.py, with no Qt imports. CONTRIBUTING.md names _locate_tesseract as the function that sets TESSDATA_PREFIX, and describes the new combo.

Behaviour changes

  • Timeout. A --list-langs timeout keeps the list and lets the OCR call go ahead.
  • Not-found on unexpected exit status. OSError, or an exit status other than 0 or 1, shows the not-found message with the install hint. This is for parity: pytesseract 0.3.13 raises TesseractNotFoundError for those exit statuses, and on 674bef3 get_tesseract_version reached the same message on any non-zero exit.
  • No minimum version check. pytesseract's minimum version check is gone: on 674bef3, a version below 3.05, or one it could not parse, raised SystemExit, which ended the app.
  • No eng placeholder. The eng entry that stood in for an empty list is removed; an empty list now reaches the existing tool.ocr.no_langs warning.
  • Fixed list default. Before the scan's result, the fixed list shows the rule's pick from that list instead of its first entry (Portuguese) in every UI.
  • Exit while the scan runs. Closing the app while the scan runs waits for it. With a single-process stub that sleeps 20 s, the app exited 4.1 s after the window closed, the scan having started 1 s before. A wrapper whose child keeps the pipe open holds it longer on Windows (19.1 to 19.4 s with a tesseract.bat); on Linux, with a sh wrapper, the app exited 4.0 to 4.2 s after the window closed.

Verification

  • tests/test_ocr_language_selection.py adds 110 tests. The page tests run with a fake subprocess.run that answers only --list-langs and fails any other call, a fake pytesseract, and a subprocess.Popen that fails when asked to start Tesseract.
  • On 674bef3 with app/tools/_ocr_langs.py added, 82 of them fail or error: pytest reports 79 failed, 31 passed and 75 errors, all at teardown.
  • CREATE_NO_WINDOW is set in an if sys.platform == "win32" block: mypy reports 49 errors in app/ on linux, win32 and darwin, as on 674bef3 (the conditional-expression form gave 50 on linux).

Known follow-ups

  • Small test-harness tidy-ups are left for a separate cleanup PR. The main one: the env fixture could still lose its teardown if its yield from were replaced by a plain call.

🤖 Generated with Claude Code

TabOCR._run calls _ensure_tesseract, which rebuilt the language combo
from pytesseract.get_languages() before _run read the selection. The
rebuild called clear() and then addItem, and the first addItem selects
index 0, so every run used the first installed code (osd excluded),
whatever the user had chosen. --list-langs lists the codes in
alphabetical order: with eng and por installed, choosing Portuguese
ran eng, and with deu, eng and por, it ran deu.

The rebuild now restores the selection by Tesseract code. The code is
kept in TabOCR._lang_pick, apart from the combo, and is set when the
user picks a language or when the rule below picks one. A code that is
not installed leaves nothing selected, and _run stops with a new
tool.ocr.lang_unavailable warning, added to the 8 locales, instead of
running in another language. The pick comes back when the code is
listed again: after a run with an empty list, or after its pack is
removed and reinstalled. The codes are stored as item data, which
replaces the parallel _lang_codes list.

The combo shows the installed languages. The first time the OCR page
is shown, tesseract --list-langs runs in a QThreadPool job and the
result reaches the page through a @slot on a queued connection. The
pool is the scan's own, not the global one, which the editor caps at
2 threads: behind two 3 s jobs on the global pool the scan waited
3.02 s, on its own pool 0.04 s. While the scan runs, and if it fails,
the fixed list stays.

Until the user picks a language, the rule picks the UI language's
Tesseract code if it is installed, else eng, else the first installed
one. The mapping covers the 8 UI locales (zh to chi_sim, nl to nld).
Before the scan's result, the fixed list now shows the rule's pick
from that list instead of its first entry, Portuguese, in every UI.
chi_sim and nld, the pick of the zh and nl UIs, were shown as raw
codes; tool.ocr.lang.zh and tool.ocr.lang.nl label them in the 8
locales.

--list-langs runs through list_installed_langs, with CREATE_NO_WINDOW on
Windows and a 5 s timeout, and its output is parsed as pytesseract
parses it. pytesseract's get_languages and get_tesseract_version start
Tesseract without that flag: under pythonw, one OCR run opened two
Windows Terminal windows, and with the helper none. _ensure_tesseract
calls the helper in place of both. OSError, or an exit status other than
0 or 1, shows the not-found message with the install hint, for parity:
pytesseract 0.3.13 raises TesseractNotFoundError for those exit
statuses, and on 674bef3 get_tesseract_version reached the same message
on any non-zero exit. A tesseract.exe copied without its DLLs exits
0xC0000135, one without libtesseract exits 127 on Linux, and Tesseract
exits 0 even with an unusable tessdata. A timeout keeps the list and
lets the OCR call go ahead. pytesseract's minimum version check goes
too: on 674bef3, a version below 3.05, or one it could not parse, raised
SystemExit, which ended the app.

CREATE_NO_WINDOW is set in an if sys.platform == "win32" block. As a
conditional expression, mypy --platform linux reported it missing from
subprocess: 50 errors in app/ against 49 on 674bef3. As a block, 49 on
linux, win32 and darwin, as on 674bef3.

Closing the app while the scan runs waits for it. With a single-process
stub that sleeps 20 s, the app exited 4.1 s after the window closed, the
scan having started 1 s before. On Windows, a wrapper whose child keeps
the pipe open holds it longer: with a tesseract.bat, 19.1 to 19.4 s, as
subprocess.run reads the pipe to its end after the kill. On Linux,
subprocess.run only waits for the killed process: with a sh wrapper
whose child kept the pipe, the app exited 4.0 to 4.2 s after the window
closed, and the child ran on. When the teardown has already deleted the
signals object, the scan's emit raises RuntimeError; the job catches it,
so pdfapps._excepthook does not log it as CRITICAL. A pool deleted with
an earlier QApplication is replaced by a new one.

The "eng" entry that stood in for an empty list is removed: it was
offered after --list-langs had listed no language. An empty list now
reaches the existing tool.ocr.no_langs warning.

The selection rules and the helper live in app/tools/_ocr_langs.py,
with no Qt imports. CONTRIBUTING.md names _locate_tesseract as the
function that sets TESSDATA_PREFIX, and describes the new combo.

tests/test_ocr_language_selection.py adds 110 tests. The page tests run
with a fake subprocess.run that answers only --list-langs and fails any
other call, a fake pytesseract and a subprocess.Popen that fails when
asked to start Tesseract; the scan job runs on its thread pool and the
tests wait for it to finish. The list_installed_langs tests replace
subprocess.run with a recorder that answers any argv, or with one that
raises. On 674bef3 with app/tools/_ocr_langs.py added, 82 of them fail
or error: pytest reports 79 failed, 31 passed and 75 errors, all at
teardown, three of them in tests that otherwise passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@nelsonduarte
nelsonduarte merged commit 739a7e6 into main Oct 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant