Commit 565e3bd7 for tesseract

commit 565e3bd72a0ecc4814ccffbda3f17afcb40d75ef
Author: Aryan R. Ganjoo <51299383+AryanRG@users.noreply.github.com>
Date:   Mon Oct 19 01:20:19 2026 +0530

    Escape input names in ALTO and PAGE XML (#4652)

    Escape XML-special characters when serializing ALTO and PAGE input names, while preserving the raw PAGE name for reading-order identity. Add focused regressions for ALTO fileName and PAGE imageFilename/externalRef output.

    Assisted-by: Codex and Claude Opus 5.5

diff --git a/src/api/altorenderer.cpp b/src/api/altorenderer.cpp
index f207c1d2..7225ddba 100644
--- a/src/api/altorenderer.cpp
+++ b/src/api/altorenderer.cpp
@@ -95,7 +95,10 @@ bool TessAltoRenderer::AddImageHandler(TessBaseAPI *api) {
       "\t\t<MeasurementUnit>pixel</MeasurementUnit>\n"
       "\t\t<sourceImageInformation>\n"
       "\t\t\t<fileName>");
-    AppendString(api->GetInputName());
+    const char *input_name = api->GetInputName();
+    if (input_name != nullptr) {
+      AppendString(HOcrEscape(input_name).c_str());
+    }
     AppendString(
       "</fileName>\n"
       "\t\t</sourceImageInformation>\n"
diff --git a/src/api/pagerenderer.cpp b/src/api/pagerenderer.cpp
index 591edefe..41f5f550 100644
--- a/src/api/pagerenderer.cpp
+++ b/src/api/pagerenderer.cpp
@@ -641,10 +641,11 @@ bool TessPAGERenderer::AddImageHandler(TessBaseAPI *api) {

     // If a URL is used to recognize an image add it as <Metadata
     // externalRef="url">
-    if (std::regex_search(api->GetInputName(),
-                          std::regex("^(https?|ftp|ssh):"))) {
+    const char *input_name = api->GetInputName();
+    if (input_name != nullptr &&
+        std::regex_search(input_name, std::regex("^(https?|ftp|ssh):"))) {
       AppendString(" externalRef=\"");
-      AppendString(api->GetInputName());
+      AppendString(HOcrEscape(input_name).c_str());
       AppendString("\" ");
     }

@@ -762,11 +763,14 @@ char *TessBaseAPI::GetPAGEText(ETEXT_DESC *monitor, int /*page_number*/) {

   // Use "C" locale (needed for int values larger than 999).
   page_str.imbue(std::locale::classic());
-  reading_order_str << "\t<Page " << "imageFilename=\"" << GetInputName();
-  // AppendString(api->GetInputName());
+  reading_order_str << "\t<Page " << "imageFilename=\"";
+  const char *input_name = GetInputName();
+  if (input_name != nullptr) {
+    reading_order_str << HOcrEscape(input_name);
+  }
   reading_order_str << "\" " << "imageWidth=\"" << rect_width_ << "\" "
                     << "imageHeight=\"" << rect_height_ << "\">\n";
-  std::size_t ro_id = std::hash<std::string>{}(GetInputName());
+  std::size_t ro_id = std::hash<std::string>{}(input_name ? input_name : "");
   reading_order_str << "\t\t<ReadingOrder>\n"
                     << "\t\t\t<OrderedGroup id=\"ro" << ro_id
                     << "\" caption=\"Regions reading order\">\n";
diff --git a/unittest/baseapi_test.cc b/unittest/baseapi_test.cc
index 9336b5ad..d8985d25 100644
--- a/unittest/baseapi_test.cc
+++ b/unittest/baseapi_test.cc
@@ -18,6 +18,7 @@
 #include "pageres.h"

 #include <tesseract/baseapi.h>
+#include <tesseract/renderer.h>

 #include "gmock/gmock-matchers.h"

@@ -471,4 +472,80 @@ TEST_F(TesseractTest, PAGEXMLMultiPageClosingTags) {
   src_pix.destroy();
 }

+TEST_F(TesseractTest, PAGEEscapesImageFilename) {
+  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("alpha&beta.pgm");
+  api.SetImage(src_pix);
+
+  std::unique_ptr<const char[]> page(api.GetPAGEText(0));
+  ASSERT_TRUE(page != nullptr);
+  EXPECT_THAT(page.get(), HasSubstr("imageFilename=\"alpha&amp;beta.pgm\""));
+
+  src_pix.destroy();
+}
+
+TEST_F(TesseractTest, ALTOEscapesFileName) {
+  file::MakeTmpdir();
+  const std::string output_base = file::JoinPath(FLAGS_test_tmpdir, "alto-escaped-input-name");
+  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("alpha&beta.pgm");
+  api.SetImage(src_pix);
+
+  {
+    tesseract::TessAltoRenderer renderer(output_base.c_str());
+    ASSERT_TRUE(renderer.BeginDocument("escaped input name"));
+    ASSERT_TRUE(renderer.AddImage(&api));
+    ASSERT_TRUE(renderer.EndDocument());
+  }
+
+  std::string alto;
+  CHECK_OK(file::GetContents(output_base + ".xml", &alto, file::Defaults()));
+  EXPECT_THAT(alto, HasSubstr("<fileName>alpha&amp;beta.pgm</fileName>"));
+
+  src_pix.destroy();
+}
+
+TEST_F(TesseractTest, PAGERendererEscapesExternalRefAndImageFilename) {
+  file::MakeTmpdir();
+  const std::string output_base = file::JoinPath(FLAGS_test_tmpdir, "page-escaped-input-name");
+  constexpr const char *kInputName = "https://example.test/image?a=1&b=2";
+  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(kInputName);
+  api.SetImage(src_pix);
+
+  {
+    tesseract::TessPAGERenderer renderer(output_base.c_str());
+    ASSERT_TRUE(renderer.BeginDocument("escaped input name"));
+    ASSERT_TRUE(renderer.AddImage(&api));
+    ASSERT_TRUE(renderer.EndDocument());
+  }
+
+  std::string page;
+  CHECK_OK(file::GetContents(output_base + ".page.xml", &page, file::Defaults()));
+  EXPECT_THAT(page,
+              HasSubstr("externalRef=\"https://example.test/image?a=1&amp;b=2\""));
+  EXPECT_THAT(page,
+              HasSubstr("imageFilename=\"https://example.test/image?a=1&amp;b=2\""));
+
+  src_pix.destroy();
+}
+
 } // namespace tesseract