This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 4f0e11bafa TIKA-4875: improve tika-eval performance (#3123)
4f0e11bafa is described below

commit 4f0e11bafacf55fd79e48be7dc83eff3b2baf6e0
Author: Tim Allison <[email protected]>
AuthorDate: Thu Sep 3 15:55:17 2026 -0400

    TIKA-4875: improve tika-eval performance (#3123)
---
 CHANGES.txt                                        |   8 ++
 .../tika/eval/app/ExtractComparerRunner.java       |  12 +-
 .../apache/tika/eval/app/ExtractProfileRunner.java |  12 +-
 .../org/apache/tika/eval/app/StatusReporter.java   |  18 ++-
 .../java/org/apache/tika/eval/app/db/JDBCUtil.java |  41 ++++++
 .../org/apache/tika/eval/app/db/JDBCUtilTest.java  |  65 +++++++++
 .../charsoup/core/CharSoupFeatureExtractor.java    | 151 ++++++++++++++++++---
 .../core/CharSoupFeatureExtractorTest.java         |  87 ++++++++++++
 .../tika/langdetect/opennlp/OpenNLPDetector.java   |   6 +-
 9 files changed, 354 insertions(+), 46 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index b1791047ec..2e739e0f21 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
 Release 4.1.0 - unreleased
 
+   * tika-eval Profile/Compare speedups: single-pass URL/mail stripping
+     replaces the bounded regexes in langdetect preprocessing (same output,
+     17-290x faster on web text), the default H2 db URL drops MVStore chunk
+     retention and sizes the page cache at a quarter of the heap clamped to
+     [64MB, 1GB] (override with -Dtika.eval.h2.cacheSizeKb=<kb>), and the
+     status log adds a last-interval docs-per-sec rate next to the cumulative
+     average (TIKA-4875).
+     
    * New "content-enrichers" config list (TIKA-4872): select the OCR engine
      ("tesseract-ocr-parser", "tess4j-parser", "openai-vlm-parser", ...) by
      name instead of by classpath registration of the image/ocr-* pseudo
diff --git 
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparerRunner.java
 
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparerRunner.java
index 6a2c2fdea5..b76abe7688 100644
--- 
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparerRunner.java
+++ 
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractComparerRunner.java
@@ -142,7 +142,7 @@ public class ExtractComparerRunner {
         }
 
         try {
-            String jdbcString = getJdbcConnectionString(dbPath);
+            String jdbcString = JDBCUtil.getJdbcConnectionString(dbPath);
             Map<String, String> runInfo = RunInfo.evalInfo(args, evalConfig, 
inputDir);
             execute(inputDir, extractsADir, extractsBDir, jdbcString, 
evalConfig, sideA.pipesReport(), sideB.pipesReport(), runInfo, runInfoA, 
runInfoB);
 
@@ -177,16 +177,6 @@ public class ExtractComparerRunner {
         return commandLine.hasOption(opt) ? 
Paths.get(commandLine.getOptionValue(opt)) : null;
     }
 
-    private static String getJdbcConnectionString(String dbPath) {
-        if (dbPath.startsWith("jdbc:")) {
-            return dbPath;
-        }
-        //default to h2
-        Path p = Paths.get(dbPath);
-        return "jdbc:h2:file:" + p.toAbsolutePath();
-
-    }
-
     private static void execute(Path inputDir, Path extractsA, Path extractsB, 
String dbPath, EvalConfig evalConfig, PipesReport pipesReportA,
                                 PipesReport pipesReportB, Map<String, String> 
runInfo, Map<String, String> runInfoA, Map<String, String> runInfoB)
             throws SQLException, IOException {
diff --git 
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfileRunner.java
 
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfileRunner.java
index daa2ddc81d..3288f145f2 100644
--- 
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfileRunner.java
+++ 
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/ExtractProfileRunner.java
@@ -98,7 +98,7 @@ public class ExtractProfileRunner {
         Path extractsDir = commandLine.hasOption('e') ? 
Paths.get(commandLine.getOptionValue('e')) : Paths.get(USAGE_FAIL("Must specify 
extracts dir: -i"));
         Path inputDir = commandLine.hasOption('i') ? 
Paths.get(commandLine.getOptionValue('i')) : extractsDir;
         String dbPath = commandLine.hasOption('d') ? 
commandLine.getOptionValue('d') : USAGE_FAIL("Must specify the db name: -d");
-        String jdbcString = getJdbcConnectionString(dbPath);
+        String jdbcString = JDBCUtil.getJdbcConnectionString(dbPath);
         if (commandLine.hasOption('n')) {
             
evalConfig.setNumWorkers(Integer.parseInt(commandLine.getOptionValue('n')));
         }
@@ -118,16 +118,6 @@ public class ExtractProfileRunner {
         return commandLine.hasOption(opt) ? 
Paths.get(commandLine.getOptionValue(opt)) : null;
     }
 
-    private static String getJdbcConnectionString(String dbPath) {
-        if (dbPath.startsWith("jdbc:")) {
-            return dbPath;
-        }
-        //default to h2
-        Path p = Paths.get(dbPath);
-        return "jdbc:h2:file:" + p.toAbsolutePath();
-
-    }
-
     private static void execute(Path inputDir, Path extractsDir, String 
dbPath, EvalConfig evalConfig, PipesReport pipesReport,
                                 Map<String, String> runInfo) throws 
SQLException, IOException {
 
diff --git 
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/StatusReporter.java
 
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/StatusReporter.java
index 3f81f17758..484f327fc8 100644
--- 
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/StatusReporter.java
+++ 
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/StatusReporter.java
@@ -38,6 +38,8 @@ public class StatusReporter implements Callable<Long> {
     private final AtomicBoolean crawlerIsActive;
     private final long start;
     private final NumberFormat numberFormat = 
NumberFormat.getNumberInstance(Locale.ROOT);
+    private int lastCnt = 0;
+    private long lastReportMillis;
 
 
     public StatusReporter(CallablePipesIterator pipesIterator, AtomicInteger 
filesProcessed, AtomicInteger activeWorkers, AtomicBoolean crawlerIsActive) {
@@ -46,6 +48,7 @@ public class StatusReporter implements Callable<Long> {
         this.activeWorkers = activeWorkers;
         this.crawlerIsActive = crawlerIsActive;
         this.start = System.currentTimeMillis();
+        this.lastReportMillis = this.start;
     }
 
     @Override
@@ -68,13 +71,22 @@ public class StatusReporter implements Callable<Long> {
     }
 
     private void report() {
+        long now = System.currentTimeMillis();
         int cnt = filesProcessed.get();
-        long elapsed = System.currentTimeMillis() - start;
+        long elapsed = now - start;
         double elapsedSecs = (double) elapsed / (double) 1000;
         int avg = (elapsedSecs > 5 || cnt > 100) ? (int) ((double) cnt / 
elapsedSecs) : -1;
 
-        String elapsedString = 
DurationFormatUtils.formatMillis(System.currentTimeMillis() - start);
-        String docsPerSec = avg > -1 ? String.format(Locale.ROOT, " (%s docs 
per sec)", numberFormat.format(avg)) : "";
+        // the cumulative average declines by construction and masks cliffs
+        double windowSecs = (double) (now - lastReportMillis) / (double) 1000;
+        int windowRate = windowSecs > 0 ? (int) ((double) (cnt - lastCnt) / 
windowSecs) : avg;
+        lastCnt = cnt;
+        lastReportMillis = now;
+
+        String elapsedString = DurationFormatUtils.formatMillis(elapsed);
+        String docsPerSec = avg > -1 ?
+                String.format(Locale.ROOT, " (%s docs per sec overall; %s in 
the last interval)",
+                        numberFormat.format(avg), 
numberFormat.format(windowRate)) : "";
         String msg = String.format(Locale.ROOT, "Processed %s documents in 
%s%s.", numberFormat.format(cnt), elapsedString, docsPerSec);
         LOGGER.info(msg);
 
diff --git 
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/db/JDBCUtil.java
 
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/db/JDBCUtil.java
index 8d0de20e38..43e439722d 100644
--- 
a/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/db/JDBCUtil.java
+++ 
b/tika-eval/tika-eval-app/src/main/java/org/apache/tika/eval/app/db/JDBCUtil.java
@@ -19,6 +19,8 @@ package org.apache.tika.eval.app.db;
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
 import java.sql.DriverManager;
@@ -40,6 +42,17 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 public class JDBCUtil {
+
+    /**
+     * Override for the h2 page cache in KB; unclamped, so a big box can go 
well
+     * beyond the heap-relative default.
+     */
+    public static final String H2_CACHE_SIZE_KB_PROPERTY = 
"tika.eval.h2.cacheSizeKb";
+
+    //h2's own default: 64MB
+    private static final long MIN_H2_CACHE_SIZE_KB = 65_536L;
+    private static final long MAX_H2_CACHE_SIZE_KB = 1_048_576L;
+
     private static final Logger LOG = LoggerFactory.getLogger(JDBCUtil.class);
     private final String connectionString;
     private String driverClass;
@@ -73,6 +86,34 @@ public class JDBCUtil {
         }
     }
 
+    /**
+     * If dbPath is already a jdbc string, it is used as is; otherwise this 
builds the
+     * tika-eval h2 default: RETENTION_TIME=0 drops the 45s MVStore chunk 
retention
+     * (bloat + growing compaction cost) and CACHE_SIZE (KB) is sized by
+     * {@link #getH2CacheSizeKb()}.
+     */
+    public static String getJdbcConnectionString(String dbPath) {
+        if (dbPath.startsWith("jdbc:")) {
+            return dbPath;
+        }
+        Path p = Paths.get(dbPath);
+        return "jdbc:h2:file:" + p.toAbsolutePath() + 
";RETENTION_TIME=0;CACHE_SIZE=" + getH2CacheSizeKb();
+    }
+
+    /**
+     * H2's page cache is on heap, so the default is a quarter of the heap 
clamped to
+     * [64MB, 1GB] rather than a fixed size that a small JVM cannot afford. Set
+     * {@value #H2_CACHE_SIZE_KB_PROPERTY} to override.
+     */
+    public static long getH2CacheSizeKb() {
+        String override = System.getProperty(H2_CACHE_SIZE_KB_PROPERTY);
+        if (override != null) {
+            return Long.parseLong(override.trim());
+        }
+        long quarterHeapKb = Runtime.getRuntime().maxMemory() / 4 / 1024;
+        return Math.min(MAX_H2_CACHE_SIZE_KB, Math.max(MIN_H2_CACHE_SIZE_KB, 
quarterHeapKb));
+    }
+
     public static void batchInsert(PreparedStatement insertStatement, 
TableInfo table, Map<Cols, String> data) throws SQLException {
 
         try {
diff --git 
a/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/db/JDBCUtilTest.java
 
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/db/JDBCUtilTest.java
new file mode 100644
index 0000000000..79df461426
--- /dev/null
+++ 
b/tika-eval/tika-eval-app/src/test/java/org/apache/tika/eval/app/db/JDBCUtilTest.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.eval.app.db;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class JDBCUtilTest {
+
+    private String originalCacheSize;
+
+    @BeforeEach
+    public void stashCacheSizeProperty() {
+        originalCacheSize = 
System.getProperty(JDBCUtil.H2_CACHE_SIZE_KB_PROPERTY);
+        System.clearProperty(JDBCUtil.H2_CACHE_SIZE_KB_PROPERTY);
+    }
+
+    @AfterEach
+    public void restoreCacheSizeProperty() {
+        if (originalCacheSize == null) {
+            System.clearProperty(JDBCUtil.H2_CACHE_SIZE_KB_PROPERTY);
+        } else {
+            System.setProperty(JDBCUtil.H2_CACHE_SIZE_KB_PROPERTY, 
originalCacheSize);
+        }
+    }
+
+    @Test
+    public void testJdbcStringPassesThrough() {
+        String jdbc = "jdbc:postgresql://localhost/tika_eval";
+        assertEquals(jdbc, JDBCUtil.getJdbcConnectionString(jdbc));
+    }
+
+    @Test
+    public void testH2Defaults() {
+        long cacheSizeKb = JDBCUtil.getH2CacheSizeKb();
+        assertTrue(cacheSizeKb >= 65_536L && cacheSizeKb <= 1_048_576L, 
"clamped to [64MB, 1GB]: " + cacheSizeKb);
+        String connectionString = JDBCUtil.getJdbcConnectionString("mydb");
+        assertTrue(connectionString.startsWith("jdbc:h2:file:"), 
connectionString);
+        assertTrue(connectionString.endsWith(";RETENTION_TIME=0;CACHE_SIZE=" + 
cacheSizeKb), connectionString);
+    }
+
+    @Test
+    public void testH2CacheSizeOverrideIsUnclamped() {
+        System.setProperty(JDBCUtil.H2_CACHE_SIZE_KB_PROPERTY, "8388608");
+        assertEquals(8_388_608L, JDBCUtil.getH2CacheSizeKb());
+    }
+}
diff --git 
a/tika-langdetect/tika-langdetect-charsoup-core/src/main/java/org/apache/tika/langdetect/charsoup/core/CharSoupFeatureExtractor.java
 
b/tika-langdetect/tika-langdetect-charsoup-core/src/main/java/org/apache/tika/langdetect/charsoup/core/CharSoupFeatureExtractor.java
index faae8cb85b..dd02bf273d 100644
--- 
a/tika-langdetect/tika-langdetect-charsoup-core/src/main/java/org/apache/tika/langdetect/charsoup/core/CharSoupFeatureExtractor.java
+++ 
b/tika-langdetect/tika-langdetect-charsoup-core/src/main/java/org/apache/tika/langdetect/charsoup/core/CharSoupFeatureExtractor.java
@@ -17,7 +17,6 @@
 package org.apache.tika.langdetect.charsoup.core;
 
 import java.text.Normalizer;
-import java.util.regex.Pattern;
 
 /**
  * Extracts character n-gram features from text using the hashing trick 
(FNV-1a).
@@ -36,7 +35,7 @@ import java.util.regex.Pattern;
  * <h3>Pipeline</h3>
  * <ol>
  *   <li>Truncate input at {@link #MAX_TEXT_LENGTH} chars</li>
- *   <li>Strip URLs and emails (TIKA-2777 bounded patterns)</li>
+ *   <li>Strip URLs and emails</li>
  *   <li>NFC normalize</li>
  *   <li>Iterate codepoints (surrogate-safe)</li>
  *   <li>Skip transparent characters (see {@link #isTransparent(int)})</li>
@@ -73,11 +72,26 @@ public class CharSoupFeatureExtractor {
     /** Underscore sentinel codepoint used for word boundary bigrams. */
     static final int SENTINEL = '_';
 
-    // TIKA-2777: bounded regexes to avoid catastrophic backtracking
-    private static final Pattern URL_REGEX =
-            Pattern.compile("https?://[-_.?&~;+=/#0-9A-Za-z]{10,10000}");
-    private static final Pattern MAIL_REGEX =
-            
Pattern.compile("[-_.0-9A-Za-z]{1,100}@[-_0-9A-Za-z]{1,100}[-_.0-9A-Za-z]{1,100}");
+    // char classes for the URL/mail scanners below (TIKA-4875)
+    private static final boolean[] URL_CHARS = new boolean[128];
+    private static final boolean[] MAIL_CHARS = new boolean[128];     // 
domain head: no '.'
+    private static final boolean[] MAIL_DOT_CHARS = new boolean[128]; // local 
part / domain tail
+
+    static {
+        for (int c = '0'; c <= '9'; c++) {
+            URL_CHARS[c] = MAIL_CHARS[c] = MAIL_DOT_CHARS[c] = true;
+        }
+        for (int c = 'A'; c <= 'Z'; c++) {
+            URL_CHARS[c] = MAIL_CHARS[c] = MAIL_DOT_CHARS[c] = true;
+            int l = c + ('a' - 'A');
+            URL_CHARS[l] = MAIL_CHARS[l] = MAIL_DOT_CHARS[l] = true;
+        }
+        for (char c : "-_.?&~;+=/#".toCharArray()) {
+            URL_CHARS[c] = true;
+        }
+        MAIL_CHARS['-'] = MAIL_CHARS['_'] = true;
+        MAIL_DOT_CHARS['-'] = MAIL_DOT_CHARS['_'] = MAIL_DOT_CHARS['.'] = true;
+    }
 
     /** Arabic Tatweel (kashida) — a typographic stretching character 
(U+0640). */
     private static final int TATWEEL = 0x0640;
@@ -244,17 +258,9 @@ public class CharSoupFeatureExtractor {
      * @return cleaned, NFC-normalized text
      */
     public static String preprocessNoTruncate(String rawText) {
-        // Strip URLs and emails. Both regexes scan the entire input on every 
call;
-        // skip each unless its required marker is present ("://" for 
URL_REGEX, "@"
-        // for MAIL_REGEX). This is a no-op for the common (markerless) case — 
the
-        // output is identical — but avoids a full-buffer regex scan + Matcher 
alloc.
-        String text = rawText;
-        if (text.indexOf("://") >= 0) {
-            text = URL_REGEX.matcher(text).replaceAll(" ");
-        }
-        if (text.indexOf('@') >= 0) {
-            text = MAIL_REGEX.matcher(text).replaceAll(" ");
-        }
+        // order matters: the URL replacement is a barrier 
("http://aaaaaaaaaa@bb"; is not a mail match)
+        String text = stripUrls(rawText);
+        text = stripEmails(text);
 
         // NFC normalize
         if (!Normalizer.isNormalized(text, Normalizer.Form.NFC)) {
@@ -264,6 +270,115 @@ public class CharSoupFeatureExtractor {
         return text;
     }
 
+    /**
+     * Equivalent to {@code "https?://[-_.?&~;+=/#0-9A-Za-z]{10,10000}"} 
replaced with " ".
+     * Matches cannot overlap (':' is outside the char class), so consuming 
them left to
+     * right reproduces the regex's leftmost order.
+     */
+    private static String stripUrls(String text) {
+        int n = text.length();
+        StringBuilder sb = null;
+        int emitted = 0;
+        int i = 0;
+        while (i < n) {
+            if (text.charAt(i) == 'h') {
+                int afterScheme = -1;
+                if (text.startsWith("http://";, i)) {
+                    afterScheme = i + 7;
+                } else if (text.startsWith("https://";, i)) {
+                    afterScheme = i + 8;
+                }
+                if (afterScheme > 0) {
+                    int max = Math.min(n, afterScheme + 10000);
+                    int k = afterScheme;
+                    while (k < max && text.charAt(k) < 128 && 
URL_CHARS[text.charAt(k)]) {
+                        k++;
+                    }
+                    if (k - afterScheme >= 10) {
+                        if (sb == null) {
+                            sb = new StringBuilder(n);
+                        }
+                        sb.append(text, emitted, i).append(' ');
+                        emitted = k;
+                        i = k;
+                        continue;
+                    }
+                }
+            }
+            i++;
+        }
+        if (sb == null) {
+            return text;
+        }
+        return sb.append(text, emitted, n).toString();
+    }
+
+    /**
+     * Equivalent to
+     * {@code 
"[-_.0-9A-Za-z]{1,100}@[-_0-9A-Za-z]{1,100}[-_.0-9A-Za-z]{1,100}"} replaced 
with " ".
+     * Rebuilt around each '@': the local part scans back (max 100, not past 
the previous
+     * match, where find() resumes); ahead, the dotless head runs greedily 
(max 100) and
+     * yields exactly one char to the tail when the tail cannot otherwise 
start -- the
+     * only backtrack the regex can take ("a@bb" matches, "a@b" does not).
+     * No match spans a second '@', so left to right is leftmost order.
+     */
+    private static String stripEmails(String text) {
+        int n = text.length();
+        StringBuilder sb = null;
+        int emitted = 0;
+        int floor = 0;
+        int i = 0;
+        while (i < n) {
+            if (text.charAt(i) == '@') {
+                int s = i;
+                while (s > floor && i - s < 100) {
+                    char p = text.charAt(s - 1);
+                    if (p < 128 && MAIL_DOT_CHARS[p]) {
+                        s--;
+                    } else {
+                        break;
+                    }
+                }
+                if (s < i) {
+                    int m = i + 1;
+                    int cap2 = Math.min(n, m + 100);
+                    int j = m;
+                    while (j < cap2 && text.charAt(j) < 128 && 
MAIL_CHARS[text.charAt(j)]) {
+                        j++;
+                    }
+                    int end = -1;
+                    if (j > m) {
+                        if (j < n && text.charAt(j) < 128 && 
MAIL_DOT_CHARS[text.charAt(j)]) {
+                            int cap3 = Math.min(n, j + 100);
+                            int k = j;
+                            while (k < cap3 && text.charAt(k) < 128 && 
MAIL_DOT_CHARS[text.charAt(k)]) {
+                                k++;
+                            }
+                            end = k;
+                        } else if (j - m >= 2) {
+                            end = j;
+                        }
+                    }
+                    if (end >= 0) {
+                        if (sb == null) {
+                            sb = new StringBuilder(n);
+                        }
+                        sb.append(text, emitted, s).append(' ');
+                        emitted = end;
+                        floor = end;
+                        i = end;
+                        continue;
+                    }
+                }
+            }
+            i++;
+        }
+        if (sb == null) {
+            return text;
+        }
+        return sb.append(text, emitted, n).toString();
+    }
+
     /**
      * Determine whether a codepoint should be treated as transparent (skipped)
      * during bigram extraction and word tokenization.
diff --git 
a/tika-langdetect/tika-langdetect-charsoup/src/test/java/org/apache/tika/langdetect/charsoup/core/CharSoupFeatureExtractorTest.java
 
b/tika-langdetect/tika-langdetect-charsoup/src/test/java/org/apache/tika/langdetect/charsoup/core/CharSoupFeatureExtractorTest.java
index 9a557b8e86..8d8c3f5f4e 100644
--- 
a/tika-langdetect/tika-langdetect-charsoup/src/test/java/org/apache/tika/langdetect/charsoup/core/CharSoupFeatureExtractorTest.java
+++ 
b/tika-langdetect/tika-langdetect-charsoup/src/test/java/org/apache/tika/langdetect/charsoup/core/CharSoupFeatureExtractorTest.java
@@ -21,6 +21,11 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.regex.Pattern;
+
 import org.junit.jupiter.api.Test;
 
 
@@ -93,6 +98,88 @@ public class CharSoupFeatureExtractorTest {
         assertArrayEquals(countsC, countsD);
     }
 
+    @Test
+    public void testUrlMailStrippingMatchesGreedyRegexReference() {
+        // TIKA-4875: the scanners must stay byte-identical to the regexes 
they replaced --
+        // the langdetect and junkdetect models were trained on this exact 
preprocessing.
+        // Inputs are NFC-stable, so preprocessNoTruncate's NFC step is an 
identity here.
+        Pattern greedyUrl = 
Pattern.compile("https?://[-_.?&~;+=/#0-9A-Za-z]{10,10000}");
+        Pattern greedyMail = 
Pattern.compile("[-_.0-9A-Za-z]{1,100}@[-_0-9A-Za-z]{1,100}[-_.0-9A-Za-z]{1,100}");
+
+        List<String> cases = new ArrayList<>();
+        // middle mail repeat must backtrack to feed the dot-class tail
+        cases.add("a@bb");
+        cases.add("a@b");
+        cases.add("[email protected]");
+        cases.add("[email protected]");
+        cases.add("a@" + "b".repeat(250));
+        cases.add("a".repeat(150) + "@x.y");
+        cases.add("a".repeat(200) + "@");
+        cases.add("local@local@local");
+        cases.add("[email protected] a@bb [email protected]");
+        cases.add("@@@@@@");
+        cases.add("a@a@a@a@a@a@");
+        // mail caps: head {1,100} then tail {1,100} over one long run
+        cases.add("a@" + "b".repeat(100) + "." + "c".repeat(150));
+        cases.add("a@" + "b".repeat(99) + "." + "c".repeat(99));
+        cases.add("a@" + "b".repeat(300));
+        // find() resumes after a match: leftover run chars are not a fresh 
local part
+        cases.add("aa@bb cc@dd ee@ff");
+        cases.add("a@bb@cc@dd");
+        // URL length boundaries: min 10 after scheme, cap 10000
+        cases.add("http://"; + "a".repeat(9));
+        cases.add("http://"; + "a".repeat(10));
+        cases.add("https://"; + "a".repeat(10000));
+        cases.add("https://"; + "a".repeat(10001));
+        cases.add("https://"; + "a".repeat(10005) + "@bb");
+        cases.add("http://http://aaaaaaaaaa";);
+        cases.add("http://aaaahttp://bbbbbbbbbb";);
+        cases.add("hhttp://aaaaaaaaaaa";);
+        cases.add("http:/notaurl http//nope https:/x");
+        // the URL pass runs first; its replacement is a barrier for the mail 
pass
+        cases.add("http://aaaaaaaaaa@bb";);
+        cases.add("a@http://aaaaaaaaaa";);
+        cases.add("[email protected]");
+        cases.add("[email protected]/http://foobarbazqux";);
+        // non-ASCII neighbors exercise the < 128 guards
+        cases.add("é@bb");
+        cases.add("aé@bb");
+        cases.add("a@büc.d");
+        cases.add("http://aéaaaaaaaaaa";);
+        cases.add("see http://example.com/a/b?q=1#f and mail 
bob.smith@sub-domain_x.example.org.");
+
+        // random by default so the corpus keeps moving; rerun a failure with 
-Dtika.test.seed=<seed>
+        long seed = Long.getLong("tika.test.seed", new Random().nextLong());
+        Random random = new Random(seed);
+        String[] atoms = {"a", "B", "9", ".", "-", "_", "@", ":", "/", "#", 
"?", "=", " ",
+                "http://";, "https://";, "http", "://", "@a.", "é", 
"aaaaaaaaaa"};
+        for (int i = 0; i < 5000; i++) {
+            int len = 1 + random.nextInt(60);
+            StringBuilder sb = new StringBuilder();
+            for (int j = 0; j < len; j++) {
+                sb.append(atoms[random.nextInt(atoms.length)]);
+            }
+            cases.add(sb.toString());
+        }
+        // long-run shapes that hit the 100/10000 caps
+        for (int i = 0; i < 50; i++) {
+            StringBuilder sb = new StringBuilder();
+            while (sb.length() < 3000) {
+                sb.append("a".repeat(1 + random.nextInt(400)));
+                sb.append(atoms[random.nextInt(atoms.length)]);
+            }
+            cases.add(sb.toString());
+        }
+
+        for (String text : cases) {
+            String expected = greedyMail
+                    .matcher(greedyUrl.matcher(text).replaceAll(" "))
+                    .replaceAll(" ");
+            String actual = 
CharSoupFeatureExtractor.preprocessNoTruncate(text);
+            assertEquals(expected, actual, "-Dtika.test.seed=" + seed + " 
input=" + text);
+        }
+    }
+
     @Test
     public void testURLStripping() {
         CharSoupFeatureExtractor ext = new 
CharSoupFeatureExtractor(NUM_BUCKETS);
diff --git 
a/tika-langdetect/tika-langdetect-opennlp/src/main/java/org/apache/tika/langdetect/opennlp/OpenNLPDetector.java
 
b/tika-langdetect/tika-langdetect-opennlp/src/main/java/org/apache/tika/langdetect/opennlp/OpenNLPDetector.java
index 243976104c..5bae71b4d4 100644
--- 
a/tika-langdetect/tika-langdetect-opennlp/src/main/java/org/apache/tika/langdetect/opennlp/OpenNLPDetector.java
+++ 
b/tika-langdetect/tika-langdetect-opennlp/src/main/java/org/apache/tika/langdetect/opennlp/OpenNLPDetector.java
@@ -220,11 +220,11 @@ public class OpenNLPDetector extends LanguageDetector {
 
     private static class TikaUrlCharSequenceNormalizer implements 
CharSequenceNormalizer {
         //use this custom copy/paste of opennlp to avoid long, long hang with 
mail_regex
-        //TIKA-2777
+        //TIKA-2777. TIKA-4875: possessive where match-equivalent; the middle 
repeat must stay greedy ("a@bb")
         private static final Pattern URL_REGEX =
-                Pattern.compile("https?://[-_.?&~;+=/#0-9A-Za-z]{10,10000}");
+                Pattern.compile("https?://[-_.?&~;+=/#0-9A-Za-z]{10,10000}+");
         private static final Pattern MAIL_REGEX =
-                
Pattern.compile("[-_.0-9A-Za-z]{1,100}@[-_0-9A-Za-z]{1,100}[-_.0-9A-Za-z]{1,100}");
+                
Pattern.compile("[-_.0-9A-Za-z]{1,100}+@[-_0-9A-Za-z]{1,100}[-_.0-9A-Za-z]{1,100}+");
         private static final TikaUrlCharSequenceNormalizer INSTANCE =
                 new TikaUrlCharSequenceNormalizer();
 

Reply via email to