Commit ceb526ea for tesseract

commit ceb526eab6998cd292139be90f0c696df8df50f4
Author: Stefan Weil <sw@weilnetz.de>
Date:   Mon Nov 9 12:37:23 2026 +0100

    wordrec: handle words skipped by the classification budget in more places

    A word which is not recognized because the blob classification budget
    is used up gets a fake result: it has boxes, but no blobs, no ratings
    matrix and no text. Some code which runs later did not expect that.

    - worst_noise_blob() read uninitialized noise scores for such a word
      with a fixed pitch font and at least five boxes. Return early for
      words which were not recognized, and skip the fuzzy space pass when
      no language has budget left, because it needs the classifier.

    - ambigs_classify_and_output() dereferenced the missing ratings
      matrix. Return before the matrix paths are written.

    - PrerecAllWordsPar() (tessedit_parallelize) classifies the blobs of
      all words before the first word is recognized, so it could use up
      the budget before the checks between words were reached, and the
      page got no text. Do not prerecognize if the budget of any language
      is limited.

    - ClassificationBudgetTest did not skip when the traineddata has no
      legacy model, because CHECK_EQ only logs in the unit tests. Check
      the initialization of the legacy engine in the test body.

    All four problems were reported by the Copilot review of the pull
    request.

    Assisted-by: Claude Fable 5.1 (Anthropic)
    Signed-off-by: Stefan Weil <sw@weilnetz.de>

diff --git a/src/ccmain/control.cpp b/src/ccmain/control.cpp
index c02de8dc..6d5b84d5 100644
--- a/src/ccmain/control.cpp
+++ b/src/ccmain/control.cpp
@@ -433,8 +433,9 @@ bool Tesseract::recog_all_words(PAGE_RES *page_res, ETEXT_DESC *monitor,
     // ****************** Pass 3 *******************
     // Fix fuzzy spaces.

+    // This needs the classifier, so it is skipped without budget.
     if (!tessedit_test_adaption && tessedit_fix_fuzzy_spaces && !tessedit_word_for_word &&
-        !right_to_left()) {
+        !right_to_left() && !AllClassificationBudgetsExhausted()) {
       fix_fuzzy_spaces(monitor, stats_.word_count, page_res);
     }

diff --git a/src/ccmain/fixspace.cpp b/src/ccmain/fixspace.cpp
index 941d86c7..4e122609 100644
--- a/src/ccmain/fixspace.cpp
+++ b/src/ccmain/fixspace.cpp
@@ -695,6 +695,11 @@ int16_t Tesseract::worst_noise_blob(WERD_RES *word_res, float *worst_noise_score
   if (word_res->rebuild_word == nullptr) {
     return -1; // Can't handle cube words.
   }
+  if (word_res->tess_failed) {
+    // A word which was not recognized (for example because the classification
+    // budget was used up) has boxes, but no blobs to get noise scores from.
+    return -1;
+  }

   // Normalised.
   auto blob_count = word_res->box_word->length();
diff --git a/src/ccmain/par_control.cpp b/src/ccmain/par_control.cpp
index efad3355..731bd19a 100644
--- a/src/ccmain/par_control.cpp
+++ b/src/ccmain/par_control.cpp
@@ -17,6 +17,9 @@
 ///////////////////////////////////////////////////////////////////////

 #include "tesseractclass.h"
+
+#include <algorithm> // for std::any_of
+#include <cstdint>   // for INT32_MAX
 #ifdef _OPENMP
 #  include <omp.h>
 #endif // _OPENMP
@@ -36,6 +39,18 @@ struct BlobData {
 };

 void Tesseract::PrerecAllWordsPar(const std::vector<WordData> &words) {
+  // The prerecognition classifies the blobs of all words before the first
+  // word is recognized. That does not fit to a blob-classification budget,
+  // which is enforced between words (see
+  // Wordrec::ClassificationBudgetExhausted()): the budget could be used up
+  // before any word is recognized. So there is no prerecognition if the
+  // budget of any language is limited.
+  if (chop_max_classifications_pass < INT32_MAX ||
+      std::any_of(sub_langs_.begin(), sub_langs_.end(), [](const Tesseract *lang) {
+        return lang->chop_max_classifications_pass < INT32_MAX;
+      })) {
+    return;
+  }
   // Prepare all the blobs.
   std::vector<BlobData> blobs;
   for (const auto &w : words) {
diff --git a/src/ccmain/recogtraining.cpp b/src/ccmain/recogtraining.cpp
index 148f8820..4669cfe9 100644
--- a/src/ccmain/recogtraining.cpp
+++ b/src/ccmain/recogtraining.cpp
@@ -220,6 +220,12 @@ void Tesseract::ambigs_classify_and_output(const char *label, PAGE_RES_IT *pr_it
     return;
   }

+  if (werd_res->ratings == nullptr) {
+    // The word was not recognized (for example because the classification
+    // budget was used up), so there are no paths to output.
+    return;
+  }
+
   // Dump all paths through the ratings matrix (which is normally small).
   int dim = werd_res->ratings->dimension();
   const auto **blob_choices = new const BLOB_CHOICE *[dim];
diff --git a/unittest/baseapi_test.cc b/unittest/baseapi_test.cc
index e148dd0f..f927d585 100644
--- a/unittest/baseapi_test.cc
+++ b/unittest/baseapi_test.cc
@@ -108,6 +108,15 @@ TEST_F(TesseractTest, ClassificationBudgetTest) {
   GTEST_SKIP();
 #else
   REQUIRE_TRAINEDDATA(TESSDATA_DIR, "eng");
+  // Returns true if the legacy engine can be used with the given languages.
+  auto has_legacy_model = [](const char *languages) {
+    tesseract::TessBaseAPI api;
+    return api.Init(TessdataPath().c_str(), languages, tesseract::OEM_TESSERACT_ONLY) == 0;
+  };
+  if (!has_legacy_model("eng")) {
+    // eng.traineddata without legacy model.
+    GTEST_SKIP();
+  }
   // Returns the text of phototest which the legacy engine gets with the given
   // languages. A budget which is not nullptr is set for the first language.
   auto recognize = [](const char *languages, const char *budget) {
@@ -150,6 +159,10 @@ TEST_F(TesseractTest, ClassificationBudgetTest) {
   // The budget is per language, so a second language without limit takes
   // over when the budget of the first language is used up.
   REQUIRE_TRAINEDDATA(TESSDATA_DIR, "deu");
+  if (!has_legacy_model("eng+deu")) {
+    // deu.traineddata without legacy model.
+    GTEST_SKIP();
+  }
   EXPECT_GE(count_words(recognize("eng+deu", "100")) * 10, full_words * 9);
 #endif
 }