diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6de3910..f78d685 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,15 @@ jobs: environment-file: environment-win11.yml use-only-tar-bz2: false + - name: Install Tesseract (bundled into the release for click-and-run OCR) + shell: powershell + run: | + $ErrorActionPreference = "Stop" + choco install tesseract --no-progress -y + if (-not (Test-Path "C:\Program Files\Tesseract-OCR\tesseract.exe")) { + throw "Tesseract install did not produce tesseract.exe" + } + - name: Build exe shell: powershell env: @@ -88,6 +97,15 @@ jobs: C:\Miniconda\condabin\conda.bat activate botty python build.py --conda_path C:\Miniconda + - name: Verify Tesseract was bundled + shell: powershell + run: | + $ErrorActionPreference = "Stop" + $BOTTY_DIR = Get-ChildItem -Directory -Name | Where-Object { $_ -match '^botty_v' } | Select-Object -First 1 + $tess = Join-Path $BOTTY_DIR "tesseract\tesseract.exe" + if (-not (Test-Path $tess)) { throw "Tesseract was not bundled into $BOTTY_DIR" } + Write-Host "Bundled: $tess" + - name: Launch smoke test (built executables) shell: powershell run: | diff --git a/build.py b/build.py index 7035671..90cbc40 100644 --- a/build.py +++ b/build.py @@ -115,6 +115,26 @@ if __name__ == "__main__": shutil.copy("run.bat", f"{botty_dir}/") if os.path.exists("dependencies"): shutil.copytree("dependencies", f"{botty_dir}/dependencies") + + # Bundle a portable Tesseract so the standalone exe is click-and-run with + # working OCR and no separate install. ocr.py prefers /tesseract/ + # tesseract.exe. Source: TESSERACT_DIR env or the default UB Mannheim path. + # Skipped (with a warning) if not present — the bot still works once the + # user runs install.bat, which sets OCR up the conda way. + tesseract_src = os.environ.get("TESSERACT_DIR", r"C:\Program Files\Tesseract-OCR") + tess_exe = os.path.join(tesseract_src, "tesseract.exe") + if os.path.isfile(tess_exe): + print(f"Bundling Tesseract from {tesseract_src}") + # Copy the exe + DLLs; skip their tessdata (we ship our own trained + # models in assets/tessdata and pass --tessdata-dir to point at them). + os.makedirs(f"{botty_dir}/tesseract", exist_ok=True) + for entry in os.listdir(tesseract_src): + src = os.path.join(tesseract_src, entry) + if os.path.isfile(src) and entry.lower().endswith((".exe", ".dll")): + shutil.copy(src, f"{botty_dir}/tesseract/") + else: + print(f"WARNING: Tesseract not found at {tesseract_src} — release will " + f"rely on install.bat for OCR setup. Set TESSERACT_DIR to bundle it.") clean_up() if args.random_name: diff --git a/src/d2r_image/ocr.py b/src/d2r_image/ocr.py index 66b3f03..a9ecf34 100644 --- a/src/d2r_image/ocr.py +++ b/src/d2r_image/ocr.py @@ -1,5 +1,24 @@ import os import sys + +# Application base dir: the folder the exe lives in when frozen (PyInstaller), +# otherwise the current working dir (repo root when running `python src/main.py`). +# Used to locate the bundled tesseract + assets/tessdata regardless of cwd. +if getattr(sys, "frozen", False): + _APP_BASE = os.path.dirname(sys.executable) +else: + _APP_BASE = os.getcwd() + + +def _resolve_under_base(rel_path: str) -> str: + """Return an absolute path to rel_path under the app base, else rel_path as-is.""" + candidate = os.path.join(_APP_BASE, rel_path) + return candidate if os.path.exists(candidate) else rel_path + + +# Absolute tessdata dir so OCR works no matter what the current working dir is. +TESSDATA_DIR = _resolve_under_base("assets/tessdata") + if os.name == 'nt': # Register conda DLL dirs so Python's .pyd loader (LOAD_LIBRARY_SEARCH_USER_DIRS) # can find tesserocr's dependencies (tesseract, leptonica, etc.) @@ -18,12 +37,18 @@ except Exception: try: import pytesseract # pytesseract only looks for plain "tesseract" on PATH; it does not read - # PYTESSERACT_TESSERACT_CMD (set by run_botty.bat), so apply it here. + # PYTESSERACT_TESSERACT_CMD (set by run_botty.bat), so resolve the binary + # here. Search order prefers the tesseract bundled in the release (so the + # standalone exe is click-and-run with no separate tesseract install). import shutil - _cmd = os.environ.get("PYTESSERACT_TESSERACT_CMD") - if not (_cmd and os.path.isfile(_cmd)): - _cmd = shutil.which("tesseract") or r"C:\Program Files\Tesseract-OCR\tesseract.exe" - if os.path.isfile(_cmd): + _candidates = [ + os.environ.get("PYTESSERACT_TESSERACT_CMD"), + os.path.join(_APP_BASE, "tesseract", "tesseract.exe"), # bundled in release + shutil.which("tesseract"), + r"C:\Program Files\Tesseract-OCR\tesseract.exe", + ] + _cmd = next((c for c in _candidates if c and os.path.isfile(c)), None) + if _cmd: pytesseract.pytesseract.tesseract_cmd = _cmd else: pytesseract = None @@ -89,8 +114,8 @@ def image_to_text( check_known_errors=check_known_errors, correct_words=correct_words ) - with PyTessBaseAPI(psm=psm, oem=OEM.LSTM_ONLY, path=f"assets/tessdata", lang=model ) as api: - api.ReadConfigFile("assets/tessdata/ocr_config.txt") + with PyTessBaseAPI(psm=psm, oem=OEM.LSTM_ONLY, path=TESSDATA_DIR, lang=model ) as api: + api.ReadConfigFile(os.path.join(TESSDATA_DIR, "ocr_config.txt")) if word_list: api.SetVariable("user_words_file", word_list) #api.SetSourceResolution(72 * scale) @@ -172,7 +197,7 @@ def _image_to_text_pytesseract( if digits_only: custom_config += " -c tessedit_char_whitelist=0123456789" - tessdata_dir = "assets/tessdata" + tessdata_dir = TESSDATA_DIR results = [] for image in images: diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..ff51b18 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,23 @@ +# Apply the same SSL cert-store workaround the bot entry points use, so the +# test suite can be collected on Windows machines whose cert store is corrupted. +# Importing the bot code pulls in discord/aiohttp, which calls +# ssl.create_default_context() at import time; a malformed Windows cert raises +# ssl.SSLError: [ASN1: NOT_ENOUGH_DATA]. No-op on a healthy cert store (incl. CI). +import sys + +if sys.platform == "win32": + import ssl + + _orig_load_default_certs = ssl.SSLContext.load_default_certs + + def _safe_load_default_certs(self, purpose=ssl.Purpose.CLIENT_AUTH): + try: + _orig_load_default_certs(self, purpose) + except ssl.SSLError: + try: + import certifi + self.load_verify_locations(certifi.where()) + except Exception: + pass + + ssl.SSLContext.load_default_certs = _safe_load_default_certs