diff --git a/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutBidiTest.java b/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutBidiTest.java index ba0a50e9378..a69dd7bdd35 100644 --- a/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutBidiTest.java +++ b/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutBidiTest.java @@ -16,16 +16,27 @@ */ package org.apache.pdfbox.glyphlayout.awt; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.AbstractGlyphLayoutProcessor; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; import java.awt.FontFormatException; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDPageContentStream; -import org.apache.pdfbox.pdmodel.font.PDType0Font; +import static org.junit.jupiter.api.Assertions.assertEquals; /** * Examples for bidirectional text with GlyphLayoutProcessorAwt @@ -73,13 +84,52 @@ private float showLine(PDPageContentStream cs, PDType0Font[] fonts, float fontSi return y; } + /** + * Test, no ActualText + * + * @throws IOException + * @throws FontFormatException + * @throws URISyntaxException + */ + @Test + void testGlyphLayoutBidiNoActualText() throws IOException, FontFormatException, URISyntaxException + { + testGlyphLayoutBidi(false, ""); + } + + /** + * Test with ActualText + * + * @throws IOException + * @throws FontFormatException + * @throws URISyntaxException + */ @Test - void testGlyphLayoutBidi() throws IOException, FontFormatException, URISyntaxException + void testGlyphLayoutBidiActualText() throws IOException, FontFormatException, URISyntaxException { - GlyphLayoutProcessorAwt glyphLayoutProcessorAwt = new GlyphLayoutProcessorAwt(); + testGlyphLayoutBidi(true, "_ActualText"); + } + + /** + * Test + * @param useActualText + * @throws IOException + * @throws FontFormatException + * @throws URISyntaxException + */ + void testGlyphLayoutBidi(boolean useActualText, String sActualText) throws IOException, FontFormatException, URISyntaxException + { + AbstractGlyphLayoutProcessor.GlyphLayoutProcessorOptions options = new AbstractGlyphLayoutProcessor.GlyphLayoutProcessorOptions(); + if (useActualText) + { + options.useActualText(); + } + GlyphLayoutProcessorAwt glyphLayoutProcessor = new GlyphLayoutProcessorAwt(options); + + String outputBaseName = String.format("GlyphLayoutBidi%s", sActualText); + String outputPDFFilename = "target/" + outputBaseName + ".pdf"; + String outputTextFilename = "target/" + outputBaseName + ".txt"; - String outputName = "GlyphLayoutBidi.pdf"; - String outputFilename = "target/" + outputName; String arabicPath = "/ttf/NotoSansArabic-Regular.ttf"; String lgcPath = "/ttf/DejaVuSans.ttf"; @@ -87,14 +137,14 @@ void testGlyphLayoutBidi() throws IOException, FontFormatException, URISyntaxExc try (PDDocument doc = new PDDocument()) { - PDType0Font arabicFont = createPdType0Font(glyphLayoutProcessorAwt, doc, arabicPath); - PDType0Font lgcFont = createPdType0Font(glyphLayoutProcessorAwt, doc, lgcPath); + PDType0Font arabicFont = createPdType0Font(glyphLayoutProcessor, doc, arabicPath); + PDType0Font lgcFont = createPdType0Font(glyphLayoutProcessor, doc, lgcPath); PDPage page = new PDPage(); doc.addPage(page); try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { - cs.setGlyphLayoutProcessor(glyphLayoutProcessorAwt); + cs.setGlyphLayoutProcessor(glyphLayoutProcessor); float x = page.getBBox().getLowerLeftX() + fontSize; float y = page.getBBox().getUpperRightY() - fontSize; @@ -102,8 +152,43 @@ void testGlyphLayoutBidi() throws IOException, FontFormatException, URISyntaxExc y = showLine(cs, arabicFont, fontSize, x, y, TEXT1); showLine(cs, new PDType0Font[]{ lgcFont, arabicFont, lgcFont }, fontSize, x, y, new String[]{ TEXT2, TEXT3, TEXT4 }); } - doc.save(outputFilename); + doc.save(outputPDFFilename); + } + + checkRenderIdent(outputBaseName + ".pdf"); + + // Extract text + try (PDDocument doc = Loader.loadPDF(new File(outputPDFFilename))) { + assertEquals(1, doc.getNumberOfPages()); + + PDFTextStripper stripper = new PDFTextStripper(); + String s = stripper.getText(doc); + String sStripped = s.replace("\r", "").replaceAll(" +", " ") + .replace(" \n", "\n") + .strip(); + + String text = + TEXT1 + "\n" + TEXT2 + TEXT3 + TEXT4; + + if (useActualText) { + // Not correct as of 2026-09-27 + // PDFTextStripper should take the text from ActualText as is and not reorder the text + assertEquals(text, sStripped, "Extracted text should equal the written text for " + outputPDFFilename); + } else { + // Extracted text is wrong + assertEquals(text, sStripped, "Extracted text should equal the written text for " + outputPDFFilename); + } + + try (OutputStream os = new FileOutputStream(outputTextFilename)) { + os.write(0xEF); + os.write(0xBB); + os.write(0xBF); + + try (Writer writer = new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8))) { + // The output is not yet correct as of 27.9.2026 + writer.write(s); + } + } } - checkRenderIdent(outputName); } } diff --git a/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutDin91379FormTest.java b/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutDin91379FormTest.java index f9fd7b41789..02a50eabdd7 100644 --- a/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutDin91379FormTest.java +++ b/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutDin91379FormTest.java @@ -17,11 +17,19 @@ package org.apache.pdfbox.glyphlayout.awt; import java.awt.FontFormatException; +import java.io.BufferedWriter; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; + import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.AbstractGlyphLayoutProcessor; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDResources; @@ -29,8 +37,11 @@ import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; import org.apache.pdfbox.pdmodel.interactive.form.PDField; import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + /* * Example of formatting for letters defined in: * DIN 91379: Characters and defined character sequences in Unicode for the electronic processing of names @@ -84,15 +95,51 @@ class GlyphLayoutDin91379FormTest extends TestBase + "ƒ ʰ ʳ ˆ ˜ ˢ ᵈ ᵗ ‘ ‚ “ ” „ † … ‰ ′ ″ ‹ › ⁰ ⁴ ⁵ ⁶ ⁷ ⁸ " + "⁹ ⁿ ₀ ₁ ₂ ₃ ₄ ₅ ₆ ₇ ₈ ₉ ™ ∞ ≤ ≥ \n" + "Additional non-letters (not included in DIN 91379): – — •�"; + /** + * Test, no ActualText + * + * @throws IOException + * @throws FontFormatException + * @throws URISyntaxException + */ + @Test + void testGlyphLayoutDin91379FormNoActualText() throws IOException, FontFormatException, URISyntaxException + { + testGlyphLayoutDin91379Form(false, ""); + } + /** + * Test with ActualText + * + * @throws IOException + * @throws FontFormatException + * @throws URISyntaxException + */ @Test - void testGlyphLayoutDin91379Form() throws IOException, FontFormatException, URISyntaxException + void testGlyphLayoutDin91379FormActualText() throws IOException, FontFormatException, URISyntaxException { - GlyphLayoutProcessorAwt glyphLayoutProcessor = new GlyphLayoutProcessorAwt(); + testGlyphLayoutDin91379Form(true, "_ActualText"); + } - String outputName = "GlyphLayoutDIN91379Form.pdf"; - String outputFilename = "target/" + outputName; + /** + * Test + * @param useActualText + * @throws IOException + * @throws FontFormatException + * @throws URISyntaxException + */ + void testGlyphLayoutDin91379Form(boolean useActualText, String sActualText) throws IOException, FontFormatException, URISyntaxException + { + AbstractGlyphLayoutProcessor.GlyphLayoutProcessorOptions options = new AbstractGlyphLayoutProcessor.GlyphLayoutProcessorOptions(); + if (useActualText) + { + options.useActualText(); + } + GlyphLayoutProcessorAwt glyphLayoutProcessor = new GlyphLayoutProcessorAwt(options); + String outputBaseName = String.format("GlyphLayoutDIN91379Form%s", sActualText); + String outputPDFFilename = "target/" + outputBaseName + ".pdf"; + String outputTextFilename = "target/" + outputBaseName + ".txt"; String fontSizeString = "12"; InputStream fontStream = GlyphLayoutDin91379FormTest.class.getResourceAsStream("/ttf/Arimo-Regular.ttf"); @@ -125,8 +172,47 @@ void testGlyphLayoutDin91379Form() throws IOException, FontFormatException, URIS } acroForm.refreshAppearances(); acroForm.flatten(); - doc.save(outputFilename); + doc.save(outputPDFFilename); + } + + checkRenderIdent(outputBaseName + ".pdf"); + + // Extract text + try (PDDocument doc = Loader.loadPDF(new File(outputPDFFilename))) { + assertEquals(1, doc.getNumberOfPages()); + + PDFTextStripper stripper = new PDFTextStripper(); + String s = stripper.getText(doc); + String sStripped = s.replace('\n', ' ') + .replaceAll(" +", " ") + .replace(" ", "\n") + .strip(); + + String expectedText = ("Test form for PDFBox glyph layout\n" + LATIN_CHARS_DIN_91379) + .replace('\n', ' ') + .replaceAll(" +", " ") + .replace(" ", "\n") + .strip(); + + try (OutputStream os = new FileOutputStream(outputTextFilename)) { + os.write(0xEF); + os.write(0xBB); + os.write(0xBF); + + try (Writer writer = new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8))) { + // The output without ActualText is not yet correct + writer.write(s); + } + } + + if (useActualText) { + // ok with ActualText + assertEquals(expectedText, sStripped, "Extracted text should equal the written text for " + outputPDFFilename); + } else { + // not ok without ActualText + assertEquals(expectedText, sStripped, "Extracted text should equal the written text for " + outputPDFFilename); + } + } - checkRenderIdent(outputName); } } diff --git a/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutSMPTest.java b/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutSMPTest.java index bbf350d5267..ad4240bd2d7 100644 --- a/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutSMPTest.java +++ b/pdfbox-layout-awt/src/test/java/org/apache/pdfbox/glyphlayout/awt/GlyphLayoutSMPTest.java @@ -18,15 +18,28 @@ package org.apache.pdfbox.glyphlayout.awt; import java.awt.FontFormatException; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.AbstractGlyphLayoutProcessor; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + /** * Examples for Supplementary Multilingual Plane with GlyphLayoutProcessorAwt that require a * representation with two characters in Java. This is not a new functionality but a regression test @@ -97,13 +110,52 @@ private float showLine(PDPageContentStream cs, PDType0Font font, float fontSize, return y; } + /** + * Test, no ActualText + * + * @throws IOException + * @throws FontFormatException + * @throws URISyntaxException + */ + @Test + void testGlyphLayoutSMPNoActualText() throws IOException, FontFormatException, URISyntaxException + { + testGlyphLayoutSMP(false, ""); + } + + /** + * Test with ActualText + * + * @throws IOException + * @throws FontFormatException + * @throws URISyntaxException + */ @Test - void testGlyphLayoutSMP() throws IOException, FontFormatException, URISyntaxException + void testGlyphLayoutSMPActualText() throws IOException, FontFormatException, URISyntaxException + { + testGlyphLayoutSMP(true, "_ActualText"); + } + + /** + * Test + * @param useActualText + * @throws IOException + * @throws FontFormatException + * @throws URISyntaxException + */ + void testGlyphLayoutSMP(boolean useActualText, String sActualText) throws IOException, FontFormatException, URISyntaxException { - GlyphLayoutProcessorAwt glyphLayoutProcessor = new GlyphLayoutProcessorAwt(); + AbstractGlyphLayoutProcessor.GlyphLayoutProcessorOptions options = new AbstractGlyphLayoutProcessor.GlyphLayoutProcessorOptions(); + if (useActualText) + { + options.useActualText(); + } + GlyphLayoutProcessorAwt glyphLayoutProcessor = new GlyphLayoutProcessorAwt(options); + + String outputBaseName = String.format("GlyphLayoutSMP%s", sActualText); + String outputPDFFilename = "target/" + outputBaseName + ".pdf"; + String outputTextFilename = "target/" + outputBaseName + ".txt"; - String outputName = "GlyphLayoutSMP.pdf"; - String outputFilename = "target/" + outputName; String sansFontPath = "/ttf/Arimo-Regular.ttf"; String mathFontPath = "/ttf/NotoSansMath-Regular.ttf"; @@ -128,8 +180,40 @@ void testGlyphLayoutSMP() throws IOException, FontFormatException, URISyntaxExce y = showLine(cs, sansFont, fontSize, x, y, "Font used: " + mathFont.getName()); showLines(cs, mathFont, fontSize, x, y, MATHEMATICAL); } - doc.save(outputFilename); + doc.save(outputPDFFilename); + } + + checkRenderIdent(outputBaseName + ".pdf"); + + // Extract text + try (PDDocument doc = Loader.loadPDF(new File(outputPDFFilename))) { + assertEquals(1, doc.getNumberOfPages()); + + PDFTextStripper stripper = new PDFTextStripper(); + String s = stripper.getText(doc); + String sStripped = s.strip(); + + String[] lines = + Arrays.stream((TEXT_INTRO + "\n" + "Font used: NotoSansMath-Regular" + "\n" + MATHEMATICAL) + .split("\n")).filter(line->line.length()>0).toArray(i ->new String[i]); + String text = String.join("\n", lines); + + if (useActualText) { + assertEquals(text, sStripped, "Extracted text should equal the written text for " + outputPDFFilename); + } else { + assertEquals(text, sStripped, "Extracted text should equal the written text for " + outputPDFFilename); + } + + try (OutputStream os = new FileOutputStream(outputTextFilename)) { + os.write(0xEF); + os.write(0xBB); + os.write(0xBF); + + try (Writer writer = new BufferedWriter(new OutputStreamWriter(os, StandardCharsets.UTF_8))) { + // The output is correct + writer.write(s); + } + } } - checkRenderIdent(outputName); } } diff --git a/pdfbox-layout-awt/src/test/resources/pdf/GlyphLayoutBidi_ActualText.pdf b/pdfbox-layout-awt/src/test/resources/pdf/GlyphLayoutBidi_ActualText.pdf new file mode 100644 index 00000000000..9f6cf33fb2d Binary files /dev/null and b/pdfbox-layout-awt/src/test/resources/pdf/GlyphLayoutBidi_ActualText.pdf differ diff --git a/pdfbox-layout-awt/src/test/resources/pdf/GlyphLayoutDIN91379Form_ActualText.pdf b/pdfbox-layout-awt/src/test/resources/pdf/GlyphLayoutDIN91379Form_ActualText.pdf new file mode 100644 index 00000000000..d177431fd4f Binary files /dev/null and b/pdfbox-layout-awt/src/test/resources/pdf/GlyphLayoutDIN91379Form_ActualText.pdf differ diff --git a/pdfbox-layout-awt/src/test/resources/pdf/GlyphLayoutSMP_ActualText.pdf b/pdfbox-layout-awt/src/test/resources/pdf/GlyphLayoutSMP_ActualText.pdf new file mode 100644 index 00000000000..1990ef67938 Binary files /dev/null and b/pdfbox-layout-awt/src/test/resources/pdf/GlyphLayoutSMP_ActualText.pdf differ diff --git a/pdfbox/src/main/java/org/apache/pdfbox/text/PDFTextStripper.java b/pdfbox/src/main/java/org/apache/pdfbox/text/PDFTextStripper.java index d64f8cb7f7d..2d8e907030d 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/text/PDFTextStripper.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/text/PDFTextStripper.java @@ -33,6 +33,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.SortedMap; import java.util.SortedSet; import java.util.StringTokenizer; @@ -898,6 +899,7 @@ protected void processTextPosition(TextPosition text) { if (firstActualTextPosition) { + text.setFromActualText(true); text.setUnicode(actualText); firstActualTextPosition = false; } @@ -2037,7 +2039,14 @@ private static void parseBidiFile(InputStream inputStream) throws IOException */ private WordWithTextPositions createWord(String word, List wordPositions) { - return new WordWithTextPositions(normalizeWord(word), wordPositions); + WordWithTextPositions wordWithTextPositions; + Objects.requireNonNull(wordPositions.get(0), "wordPositions should have one or more entries"); + if (wordPositions.get(0).isFromActualText()) { + wordWithTextPositions = new WordWithTextPositions(word, wordPositions); + } else { + wordWithTextPositions = new WordWithTextPositions(normalizeWord(word), wordPositions); + } + return wordWithTextPositions; } /** @@ -2124,7 +2133,11 @@ private StringBuilder normalizeAdd(List normalized, else { TextPosition text = item.getTextPosition(); - lineBuilder.append(text.getVisuallyOrderedUnicode()); + if (text.isFromActualText()) { + lineBuilder.append(text.getUnicode()); + } else { + lineBuilder.append(text.getVisuallyOrderedUnicode()); + } wordPositions.add(text); } return lineBuilder; diff --git a/pdfbox/src/main/java/org/apache/pdfbox/text/TextPosition.java b/pdfbox/src/main/java/org/apache/pdfbox/text/TextPosition.java index d3fff34ebee..f0c1837d3b0 100644 --- a/pdfbox/src/main/java/org/apache/pdfbox/text/TextPosition.java +++ b/pdfbox/src/main/java/org/apache/pdfbox/text/TextPosition.java @@ -63,6 +63,8 @@ public final class TextPosition private float[] widths; private String unicode; private float direction = -1; + private boolean fromActualText; + /** * Constructor. @@ -898,6 +900,23 @@ public float getPageWidth() return pageWidth; } + /** + * Return if this is created form ActualText + * @return true, if this is created from ActualText + */ + public boolean isFromActualText() { + return fromActualText; + } + + /** + * Set the parameter fromActualText + * to true, if the text is created from ActualText + * @param fromActualText, true if the text is created from ActualText + */ + public void setFromActualText(boolean fromActualText) { + this.fromActualText = fromActualText; + } + @Override public boolean equals(Object o) {