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 {