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 7cd3b60905 TIKA-4891: avoid overdraw (#3177)
7cd3b60905 is described below

commit 7cd3b60905e22905e1ccb9d51330281d372c712d
Author: Tim Allison <[email protected]>
AuthorDate: Tue Sep 15 13:50:07 2026 -0400

    TIKA-4891: avoid overdraw (#3177)
---
 CHANGES.txt                                        |   7 +-
 .../pages/configuration/parsers/pdf-parser.adoc    |  12 ++-
 .../tika/parser/pdf/PDFMarkedContent2XHTML.java    | 102 +++++++++++++++++++--
 .../tika/parser/pdf/PDFMarkedContentGateTest.java  |  94 +++++++++++++++++++
 4 files changed, 205 insertions(+), 10 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 44ec131a74..27a3d6d8af 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,10 @@
 Release 4.1.0 - unreleased
 
+   * Tagged PDFs: text drawn twice for a bold effect with one copy tagged
+     came out as letters with spaces between them; each copy now reads as
+     its words. A tree that puts every glyph in its own paragraph (some form
+     generators) is left to the text stripper under AUTO (TIKA-4891).
+     
    * Inference results in the single-object outputs: with /tika as JSON,
      tika-app -j, or pipes CONCATENATE, the vectors of an attachment's pages
      and pictures now land on the one metadata object handed back, each
@@ -28,7 +33,7 @@ Release 4.1.0 - unreleased
      and the pipes server closes the registry at shutdown, so an
      openai-embedding-engine or a VLM used as a text recognizer releases its
      HTTP client (TIKA-4889).
-     
+
    * Per-request switch for text recognizers: {"parse-context":
      {"text-recognizers": {"enabled": false}}} runs no recognizer or annotator
      from the list for that parse, in every container, and OCR_ONLY no longer
diff --git a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc 
b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
index 83f8e2ec59..039beacf62 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/pdf-parser.adoc
@@ -113,9 +113,12 @@ text. `NONE` ignores the tree and writes what the stripper 
writes, the output of
 The `AUTO` gate, per page. A page uses its tags only when the tree claims at 
least `minCoverage`
 of the page's text (artifact content counts, since a producer that marks the 
body as artifact
 has not described the page), at most `maxDanglingRatio` of the tree's leaves 
for the page
-point at content the page never draws, and the tree puts at least some of the 
page's text in
+point at content the page never draws, the tree puts at least some of the 
page's text in
 an element that holds text (a paragraph, heading, cell or item: a tree of bare 
spans or
-divisions has no paragraphs to offer). Both start loose (0.5 and 0.2) until a 
corpus says
+divisions has no paragraphs to offer), and the tree does not cut the page's 
words into
+pieces (some form generators put every glyph in its own paragraph, which would 
write each
+word as a column of letters; a page where more than 30% of its words span 
three blocks
+or more goes to the stripper). Both thresholds start loose (0.5 and 0.2) until 
a corpus says
 otherwise; `minCoverage` above 1 turns every page back to the stripper.
 
 With tags, a structure element becomes the XHTML element of the same meaning 
(`P` to `<p>`, `H1`
@@ -126,7 +129,10 @@ when its content is an image, and its `Alt` text is 
written as its text, so the
 as a screen reader would read it. Text the tree does not reference follows the 
tagged content
 of its page in `<div class="untagged">`, and artifact content in `<div 
class="artifact">`, so
 nothing is lost and a consumer can drop headers and footers. Whitespace at the 
edges of a
-block element is dropped; the separators between words stay outside links and 
spans.
+block element is dropped; the separators between words stay outside links and 
spans. Text a
+producer draws twice for a bold effect, tagging one copy, reads once per copy: 
the tagged
+copy in its element, the other in the untagged division, where the stripper 
alone reads the
+interleaved glyphs as doubled letters.
 
 The output is well-formed XHTML whose nesting is the tree's, repaired where a 
tree leaves text
 or elements somewhere HTML gives them no place, the way an HTML parser would 
repair the same
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFMarkedContent2XHTML.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFMarkedContent2XHTML.java
index ff2da9adf7..7bbbc7cd55 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFMarkedContent2XHTML.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/PDFMarkedContent2XHTML.java
@@ -91,6 +91,10 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
     private static final Set<String> STRUCTURAL_TAGS = Set.of("table", 
"thead", "tbody", "tfoot",
             "tr", "td", "th", "caption", "ul", "ol", "li");
     private static final Set<String> TABLE_TAGS = Set.of("table", "thead", 
"tbody", "tfoot", "tr");
+    /** The AUTO gate's shredding check needs this many words of three or more 
glyphs. */
+    private static final int MIN_WORDS_FOR_SHREDDING = 10;
+    /** ...and rejects the page when more than this share of them span three 
or more leaves. */
+    private static final float MAX_SHREDDED_RATIO = 0.3f;
     private static final Set<String> ORDERED_LIST_NUMBERING = Set.of(
             PDListAttributeObject.LIST_NUMBERING_DECIMAL,
             PDListAttributeObject.LIST_NUMBERING_LOWER_ALPHA,
@@ -178,6 +182,10 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
         /** The word as the stripper wrote it; what a flat replay writes. */
         final String text;
         final List<Segment> segments;
+        /** Whitespace-delimited words of three or more glyphs, and those 
drawn across three
+         *  or more blocks. */
+        int words;
+        int shredded;
 
         Event(EventKind kind, String text, List<Segment> segments) {
             this.kind = kind;
@@ -207,6 +215,21 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
             Object last = pieces.get(pieces.size() - 1);
             return last == NEWLINE || last == PARAGRAPH_BREAK || ((String) 
last).isBlank();
         }
+
+        boolean hasWhitespace() {
+            for (Object piece : pieces) {
+                if (piece == NEWLINE || piece == PARAGRAPH_BREAK) {
+                    return true;
+                }
+                String s = (String) piece;
+                for (int i = 0; i < s.length(); i++) {
+                    if (Character.isWhitespace(s.charAt(i))) {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        }
     }
 
     /** How a structure element is written: its element name (null for 
transparent) and attributes. */
@@ -285,6 +308,9 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
         int artifact;
         int leaves;
         int dangling;
+        /** Words of three or more glyphs, and those of them drawn across 
three or more blocks. */
+        int words;
+        int shredded;
     }
 
     private final MarkedContentConfig markedContentConfig;
@@ -299,6 +325,8 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
     /** Every (scope, MCID) the page's content streams opened, with or without 
text. */
     private final Set<Label> openedMcids = new HashSet<>();
     private final List<Event> events = new ArrayList<>();
+    /** The page's runs in content order, while a tagged page is written. */
+    private List<Run> pageRuns = Collections.emptyList();
     private final Set<StructureIndex.Node> altWritten =
             Collections.newSetFromMap(new IdentityHashMap<>());
     /** Whitespace after the last word, written only if more text follows in 
the same block. */
@@ -643,7 +671,44 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
             }
             segments.add(new Segment(label, part.toString(), count));
         }
-        events.add(new Event(EventKind.WORD, text, segments));
+        Event event = new Event(EventKind.WORD, text, segments);
+        countShredding(event, textPositions);
+        events.add(event);
+    }
+
+    /**
+     * Counts the event's words and those whose glyphs the tree spreads over 
three blocks or
+     * more. Glyphs under many leaves of one block are still one word; a block 
per glyph (some
+     * form generators) makes each word a column of letters.
+     */
+    private void countShredding(Event event, List<TextPosition> textPositions) 
{
+        if (textPositions == null) {
+            return;
+        }
+        int glyphs = 0;
+        Set<StructureIndex.Node> nodes = Collections.newSetFromMap(new 
IdentityHashMap<>());
+        for (int i = 0; i <= textPositions.size(); i++) {
+            TextPosition position = i < textPositions.size() ? 
textPositions.get(i) : null;
+            String u = position == null ? null : position.getUnicode();
+            boolean boundary = position == null || u == null || u.isBlank();
+            if (boundary) {
+                if (glyphs >= 3) {
+                    event.words++;
+                    if (nodes.size() >= 3) {
+                        event.shredded++;
+                    }
+                }
+                glyphs = 0;
+                nodes.clear();
+                continue;
+            }
+            glyphs++;
+            Label l = labels.getOrDefault(position, Label.UNTAGGED);
+            StructureIndex.Leaf leaf = l.tagged() ? index.leaf(l.scope, 
l.mcid) : null;
+            if (leaf != null) {
+                nodes.add(blockOf(leaf.node));
+            }
+        }
     }
 
     private static String rawText(List<TextPosition> textPositions) {
@@ -729,6 +794,8 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
             if (event.kind != EventKind.WORD) {
                 continue;
             }
+            stats.words += event.words;
+            stats.shredded += event.shredded;
             for (Segment segment : event.segments) {
                 StructureIndex.Leaf leaf = segment.label.tagged() ?
                         index.leaf(segment.label.scope, segment.label.mcid) : 
null;
@@ -768,6 +835,12 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
         if (stats.inBlock == 0) {
             return "no-block-structure";
         }
+        // a tree with an element per glyph (some form generators) would write 
each word as
+        // letters, one block each; the stripper's words are better
+        if (stats.words >= MIN_WORDS_FOR_SHREDDING && stats.shredded > 
stats.words * MAX_SHREDDED_RATIO) {
+            return "shredded=" + String.format(Locale.ROOT, "%.2f",
+                    (float) stats.shredded / stats.words);
+        }
         // artifacts count: a producer that marks the body /Artifact and tags 
fragments has
         // not described the page, and the stripper does better with it
         float coverage = (float) stats.tagged / (stats.tagged + stats.untagged 
+ stats.artifact);
@@ -792,6 +865,7 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
      */
     private void emitTagged() throws SAXException {
         List<Run> runs = runs();
+        pageRuns = runs;
         Map<StructureIndex.Node, BlockGroup> groups = new IdentityHashMap<>();
         List<BlockGroup> ordered = new ArrayList<>();
         List<Run> untagged = new ArrayList<>();
@@ -877,9 +951,8 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
                 if (run == null) {
                     continue;
                 }
-                // other content sat between these two runs; the separator 
went with it
-                if (previous != null && run.ordinal != previous.ordinal + 1
-                        && !previous.endsWithSeparator() && (wrapper == null 
|| wrapper.open)) {
+                if (previous != null && separatedByOthers(previous, run)
+                        && (wrapper == null || wrapper.open)) {
                     sinkText(" ");
                 }
                 previous = run;
@@ -899,6 +972,24 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
         emitBlocks("artifact", artifacts);
     }
 
+    /**
+     * Whether other content sat between two runs of one block and took the 
separator with
+     * it: a space glyph under another label. Glyphs of another label with no 
whitespace,
+     * as when a second copy of the text is drawn glyph by glyph over the 
first, separate
+     * nothing.
+     */
+    private boolean separatedByOthers(Run previous, Run run) {
+        if (run.ordinal == previous.ordinal + 1 || 
previous.endsWithSeparator()) {
+            return false;
+        }
+        for (int i = previous.ordinal + 1; i < run.ordinal && i < 
pageRuns.size(); i++) {
+            if (pageRuns.get(i).hasWhitespace()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /** Closes the open path from the innermost node down to index {@code 
from}. */
     private void closeOpenFrom(int from, Wrapper wrapper) throws SAXException {
         for (int i = open.size() - 1; i >= from; i--) {
@@ -1381,8 +1472,7 @@ public class PDFMarkedContent2XHTML extends PDF2XHTML {
             if (!run.hasText && !inParagraph) {
                 continue;
             }
-            if (inParagraph && previous != null && run.ordinal != 
previous.ordinal + 1
-                    && !previous.endsWithSeparator()) {
+            if (inParagraph && previous != null && separatedByOthers(previous, 
run)) {
                 sinkText(" ");
             }
             previous = run;
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFMarkedContentGateTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFMarkedContentGateTest.java
index a6874d14ca..f670e863f5 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFMarkedContentGateTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFMarkedContentGateTest.java
@@ -822,6 +822,100 @@ public class PDFMarkedContentGateTest extends TikaTest {
         }
     }
 
+    /**
+     * Text drawn twice for a bold effect, one copy tagged and one not, glyph 
by glyph: the
+     * stripper reads the interleaved glyphs as one doubled word; with tags 
each copy is the
+     * word it is, in its own place.
+     */
+    @Test
+    public void testTextDrawnTwiceReadsOncePerCopy() throws Exception {
+        try (TaggedPdfBuilder b = new TaggedPdfBuilder()) {
+            PDPage page = b.page();
+            b.leaf("P", b.document, page, 0);
+            PDPageContentStream cs = b.text(page);
+            String word = "Hello";
+            for (int i = 0; i < word.length(); i++) {
+                String c = word.substring(i, i + 1);
+                cs.beginMarkedContent(COSName.P, TaggedPdfBuilder.mcid(0));
+                cs.showText(c);
+                cs.endMarkedContent();
+                // back to the line start, the untagged copy over the tagged 
one
+                cs.newLineAtOffset(0, 0);
+                cs.showText(c);
+                cs.newLineAtOffset(b.font.getStringWidth(c) / 1000 * 12, 0);
+            }
+            finish(cs);
+            byte[] pdf = b.bytes();
+
+            Result none = parse(pdf, 
config(MarkedContentConfig.Strategy.NONE));
+            assertContains("HHeelllloo", none.xml);
+            Result tags = parse(pdf, 
config(MarkedContentConfig.Strategy.TAGS));
+            assertContains("<p>Hello</p>", tags.xml);
+            assertContains("<div class=\"untagged\"><p>Hello</p>", tags.xml);
+            assertNotContained("H e l l o", tags.xml);
+            assertNotContained("HHeelllloo", tags.xml);
+        }
+    }
+
+    /**
+     * A tree with an element per glyph, as some form generators write, would 
turn every word
+     * into a column of letters: AUTO leaves such a page to the stripper, TAGS 
follows the tree.
+     */
+    @Test
+    public void testElementPerGlyphFallsBack() throws Exception {
+        try (TaggedPdfBuilder b = new TaggedPdfBuilder()) {
+            PDPage page = b.page();
+            PDStructureElement sect = b.element("Sect", b.document, page);
+            PDPageContentStream cs = b.text(page);
+            String[] words = {"Twelve", "words", "drawn", "letter", "by", 
"letter", "under",
+                    "their", "own", "elements", "each", "time"};
+            int mcid = 0;
+            for (String word : words) {
+                for (int i = 0; i < word.length(); i++) {
+                    PDStructureElement p = b.element("P", sect, page);
+                    b.leaf("Span", p, page, mcid);
+                    cs.beginMarkedContent(COSName.getPDFName("Span"), 
TaggedPdfBuilder.mcid(mcid++));
+                    cs.showText(word.substring(i, i + 1));
+                    cs.endMarkedContent();
+                }
+                cs.showText(" ");
+            }
+            finish(cs);
+            byte[] pdf = b.bytes();
+
+            Result auto = parse(pdf, 
config(MarkedContentConfig.Strategy.AUTO));
+            assertEquals("1:shredded=1.00", 
auto.metadata.get(PDF.MARKED_CONTENT_REJECTIONS));
+            assertContains("Twelve words drawn letter by letter", auto.xml);
+            Result tags = parse(pdf, 
config(MarkedContentConfig.Strategy.TAGS));
+            assertEquals(1, 
tags.metadata.getInt(PDF.MARKED_CONTENT_PAGES_TAGGED));
+            assertContains("<p>T</p>", tags.xml);
+        }
+        // a leaf per glyph under one paragraph is a paragraph, not shredding
+        try (TaggedPdfBuilder b = new TaggedPdfBuilder()) {
+            PDPage page = b.page();
+            PDStructureElement p = b.element("P", b.document, page);
+            PDPageContentStream cs = b.text(page);
+            String[] words = {"Twelve", "words", "drawn", "letter", "by", 
"letter", "under",
+                    "their", "own", "leaves", "each", "time"};
+            int mcid = 0;
+            for (String word : words) {
+                for (int i = 0; i < word.length(); i++) {
+                    b.leaf("Span", p, page, mcid);
+                    cs.beginMarkedContent(COSName.getPDFName("Span"), 
TaggedPdfBuilder.mcid(mcid++));
+                    cs.showText(word.substring(i, i + 1));
+                    cs.endMarkedContent();
+                }
+                cs.showText(" ");
+            }
+            finish(cs);
+
+            Result auto = parse(b.bytes(), 
config(MarkedContentConfig.Strategy.AUTO));
+            assertEquals(1, 
auto.metadata.getInt(PDF.MARKED_CONTENT_PAGES_TAGGED));
+            assertContains("<p>Twelve words drawn letter by letter under their 
own leaves each time",
+                    auto.xml);
+        }
+    }
+
     /** A custom container type is a division, and text straight in it gets 
paragraphs too. */
     @Test
     public void testCustomContainerTextTakesParagraphs() throws Exception {

Reply via email to