Commit 7d52a498 for tesseract

commit 7d52a4981096140ddc9493790e23a935c458cb2f
Author: Stefan Weil <sw@weilnetz.de>
Date:   Fri Nov 13 17:37:46 2026 +0100

    hocr: escape the language name and the document title

    GetHOCRText() writes the input name, the font name and the recognized
    text with HOcrEscape(), but not the language name in the lang
    attribute of ocr_par and ocrx_word. That is the name under which the
    model was loaded, so a quote or another markup character in the file
    name of a traineddata gave broken or unwanted markup.

    The same is true for the document title in the title element.

    Escape both, and add unit tests.

    The proposed test for the language name copied the model with
    file::GetContents() and file::SetContents(), which truncate binary
    data, so the model could not be loaded and the test was skipped. Copy
    the file with std::filesystem, and fail if the model cannot be loaded.

    Based on the fix proposed by the Anthropic OSS Scanner
    (finding ANT-2026-IYB11TST).

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

diff --git a/src/api/hocrrenderer.cpp b/src/api/hocrrenderer.cpp
index 32da90e4..5a67af34 100644
--- a/src/api/hocrrenderer.cpp
+++ b/src/api/hocrrenderer.cpp
@@ -224,7 +224,7 @@ char *TessBaseAPI::GetHOCRText(ETEXT_DESC *monitor, int page_number) {
                << "par_" << page_id << "_" << pcnt << "'";
       paragraph_lang = res_it->WordRecognitionLanguage();
       if (paragraph_lang) {
-        hocr_str << " lang='" << paragraph_lang << "'";
+        hocr_str << " lang='" << HOcrEscape(paragraph_lang).c_str() << "'";
       }
       AddBoxTohOCR(res_it.get(), RIL_PARA, hocr_str);
     }
@@ -283,7 +283,7 @@ char *TessBaseAPI::GetHOCRText(ETEXT_DESC *monitor, int page_number) {
     hocr_str << "'";
     const char *lang = res_it->WordRecognitionLanguage();
     if (lang && (!paragraph_lang || strcmp(lang, paragraph_lang))) {
-      hocr_str << " lang='" << lang << "'";
+      hocr_str << " lang='" << HOcrEscape(lang).c_str() << "'";
     }
     switch (res_it->WordDirection()) {
       // Only emit direction if different from current paragraph direction
@@ -485,7 +485,7 @@ bool TessHOcrRenderer::BeginDocumentHandler() {
       "    \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n"
       "<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"en\" "
       "lang=\"en\">\n <head>\n  <title>");
-  AppendString(title());
+  AppendString(HOcrEscape(title()).c_str());
   AppendString(
       "</title>\n"
       "  <meta http-equiv=\"Content-Type\" content=\"text/html;"
diff --git a/unittest/baseapi_test.cc b/unittest/baseapi_test.cc
index dbfc5b8c..4e4e563c 100644
--- a/unittest/baseapi_test.cc
+++ b/unittest/baseapi_test.cc
@@ -29,12 +29,14 @@
 #include <regex>
 #include <sstream>
 #include <string>
+#include <system_error>
 #include <vector>

 namespace tesseract {

 using ::testing::ContainsRegex;
 using ::testing::HasSubstr;
+using ::testing::Not;

 static const char *langs[] = {"eng", "vie", "hin", "ara", nullptr};
 static const char *image_files[] = {"HelloGoogle.tif", "viet.tif", "raaj.tif", "arabic.tif",
@@ -886,4 +888,66 @@ TEST_F(TesseractTest, PAGEXMLRightToLeftPolygon) {
   EXPECT_THAT(page_str, HasSubstr("<Coords points=\""));
 }

+// The hOCR lang attribute carries the name the model was loaded under, which
+// must be escaped like the other strings in the output (input name, font
+// name, text). Install a copy of the eng model under a name that contains a
+// markup character and check the attribute value.
+TEST_F(TesseractTest, HOCREscapesRecognitionLanguage) {
+  REQUIRE_TRAINEDDATA(TESSDATA_DIR, "eng");
+  file::MakeTmpdir();
+  constexpr const char *kLang = "eng&test";
+  // The model is binary data, so it cannot be copied as a string with
+  // file::GetContents and file::SetContents.
+  const std::string model =
+      file::JoinPath(FLAGS_test_tmpdir, std::string(kLang) + ".traineddata");
+  std::error_code ec;
+  std::filesystem::copy_file(file::JoinPath(TessdataPath(), "eng.traineddata"), model,
+                             std::filesystem::copy_options::overwrite_existing, ec);
+  ASSERT_FALSE(ec) << ec.message();
+
+  tesseract::TessBaseAPI api;
+  ASSERT_EQ(0, api.Init(FLAGS_test_tmpdir, kLang, tesseract::OEM_LSTM_ONLY));
+  Image src_pix = pixRead(TestDataNameToPath("HelloGoogle.tif").c_str());
+  ASSERT_TRUE(src_pix);
+  api.SetImage(src_pix);
+
+  std::unique_ptr<const char[]> hocr(api.GetHOCRText(0));
+  ASSERT_TRUE(hocr != nullptr);
+  EXPECT_THAT(hocr.get(), HasSubstr("lang='eng&amp;test'"));
+  EXPECT_THAT(hocr.get(), Not(HasSubstr("lang='eng&test'")));
+
+  src_pix.destroy();
+  api.End();
+  std::filesystem::remove(model, ec);
+}
+
+// The document title (document_title parameter) goes into <title> and must be
+// escaped too.
+TEST_F(TesseractTest, HOCREscapesDocumentTitle) {
+  file::MakeTmpdir();
+  const std::string output_base = file::JoinPath(FLAGS_test_tmpdir, "hocr-escaped-title");
+  tesseract::TessBaseAPI api;
+  if (api.Init(TessdataPath().c_str(), "eng", tesseract::OEM_LSTM_ONLY) == -1) {
+    GTEST_SKIP();
+  }
+
+  Image src_pix = pixRead(TestDataNameToPath("HelloGoogle.tif").c_str());
+  ASSERT_TRUE(src_pix);
+  api.SetInputName("HelloGoogle.tif");
+  api.SetImage(src_pix);
+
+  {
+    tesseract::TessHOcrRenderer renderer(output_base.c_str());
+    ASSERT_TRUE(renderer.BeginDocument("<b>title</b> & co"));
+    ASSERT_TRUE(renderer.AddImage(&api));
+    ASSERT_TRUE(renderer.EndDocument());
+  }
+
+  std::string hocr;
+  CHECK_OK(file::GetContents(output_base + ".hocr", &hocr, file::Defaults()));
+  EXPECT_THAT(hocr, HasSubstr("<title>&lt;b&gt;title&lt;/b&gt; &amp; co</title>"));
+
+  src_pix.destroy();
+}
+
 } // namespace tesseract