Add PDF Document Readers

- Add a set of PDF readers fro per-page (PagePdfDocumentReader) and per-paragraph (ParagraphPdfDocumentReader) readers.
  - Use a PDFLayoutTextStripper fork and PDFLayoutTextStripperByArea extension to preserve the structure of the extracted document.
  - PdfDocumentReaderConfig and PageExtractedTextFormatter in standalone classes.
  - Craeate a new document-readers top level model and the pdf-reader under.
This commit is contained in:
Christian Tzolov
2023-09-19 15:34:57 +02:00
committed by Mark Pollack
parent f74bec3662
commit 1266c04b6d
17 changed files with 1785 additions and 3 deletions

View File

@@ -0,0 +1,53 @@
# How to configure PDF Reader
## PagePdfDocumentReader
``` java
PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(
"file:document-readers/pdf-reader/src/test/resources/sample.pdf",
PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageBottomMargin(0)
.withPageExtractedTextFormatter(PageExtractedTextFormatter.builder()
.withNumberOfTopTextLinesToDelete(0)
.withNumberOfBottomTextLinesToDelete(3)
.withNumberOfTopPagesToSkipBeforeDelete(0)
.build())
.withPagesPerDocument(1)
.build());
var documents = pdfReader.get();
PdfTestUtils.writeToFile("document-readers/pdf-reader/target/sample.txt", documents, false);
```
```java
public static void main(String[] args) throws IOException {
ParagraphPdfDocumentReader pdfReader = new ParagraphPdfDocumentReader(
"file:document-readers/pdf-reader/src/test/resources/sample2.pdf",
PdfDocumentReaderConfig.builder()
// .withPageBottomMargin(15)
// .withReversedParagraphPosition(true)
// .withTextLeftAlignment(true)
.build());
// ParagraphPdfDocumentReader pdfReader = new ParagraphPdfDocumentReader(
// "file:document-readers/pdf-reader/src/test/resources/spring-framework.pdf",
// PdfDocumentReaderConfig.builder()
// .withPageBottomMargin(15)
// .withReversedParagraphPosition(true)
// // .withTextLeftAlignment(true)
// .build());
// PdfDocumentReader pdfReader = new
// PdfDocumentReader("file:document-readers/pdf-reader/src/test/resources/uber-k-10.pdf",
// PdfDocumentReaderConfig.builder().withPageTopMargin(80).withPageBottomMargin(60).build());
var documents = pdfReader.get();
writeToFile("document-readers/pdf-reader/target/sample2.txt", documents, true);
System.out.println(documents.size());
}
```

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.experimental.ai</groupId>
<artifactId>spring-ai</artifactId>
<version>0.7.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-pdf-document-reader</artifactId>
<packaging>jar</packaging>
<name>Spring AI Document Reader - PDF</name>
<description>Spring AI PDF document reader</description>
<url>https://github.com/spring-projects-experimental/spring-ai</url>
<scm>
<url>https://github.com/spring-projects-experimental/spring-ai</url>
<connection>git://github.com/spring-projects-experimental/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects-experimental/spring-ai.git</developerConnection>
</scm>
<dependencies>
<dependency>
<groupId>org.springframework.experimental.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>${parent.version}</version>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.0</version>
</dependency>
<!-- TESTING -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed 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
*
* https://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.springframework.ai.reader.pdf;
import java.awt.Rectangle;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.pdfbox.pdfparser.PDFParser;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.reader.pdf.layout.PDFLayoutTextStripperByArea;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
* Groups the parsed PDF pages into {@link Document}s. You can group one or more pages
* into a single output document. Use {@link PdfDocumentReaderConfig} for customization
* options. The default configuration is: - pagesPerDocument = 1 - pageTopMargin = 0 -
* pageBottomMargin = 0
*
* @author Christian Tzolov
*/
public class PagePdfDocumentReader implements DocumentReader {
private static final String PDF_PAGE_REGION = "pdfPageRegion";
public static final String METADATA_START_PAGE_NUMBER = "page_number";
public static final String METADATA_END_PAGE_NUMBER = "end_page_number";
public static final String METADATA_FILE_NAME = "file_name";
private final PDDocument document;
private PdfDocumentReaderConfig config;
private File resourceFileName;
public PagePdfDocumentReader(String resourceUrl) {
this(new DefaultResourceLoader().getResource(resourceUrl));
}
public PagePdfDocumentReader(Resource pdfResource) {
this(pdfResource, PdfDocumentReaderConfig.defaultConfig());
}
public PagePdfDocumentReader(String resourceUrl, PdfDocumentReaderConfig config) {
this(new DefaultResourceLoader().getResource(resourceUrl), config);
}
public PagePdfDocumentReader(Resource pdfResource, PdfDocumentReaderConfig config) {
try {
PDFParser pdfParser = new PDFParser(
new org.apache.pdfbox.io.RandomAccessReadBuffer(pdfResource.getInputStream()));
this.document = pdfParser.parse();
this.resourceFileName = pdfResource.getFile();
this.config = config;
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public List<Document> get() {
List<Document> readDocuments = new ArrayList<>();
try {
var pdfTextStripper = new PDFLayoutTextStripperByArea();
int pageNumber = 0;
int pagesPerDocument = 0;
int startPageNumber = pageNumber;
List<String> pageTextGroupList = new ArrayList<>();
for (PDPage page : this.document.getDocumentCatalog().getPages()) {
pagesPerDocument++;
if (this.config.pagesPerDocument != PdfDocumentReaderConfig.ALL_PAGES
&& pagesPerDocument >= this.config.pagesPerDocument) {
pagesPerDocument = 0;
var aggregatedPageTextGroup = pageTextGroupList.stream().collect(Collectors.joining());
if (StringUtils.hasText(aggregatedPageTextGroup)) {
readDocuments.add(toDocument(aggregatedPageTextGroup, startPageNumber, pageNumber));
}
pageTextGroupList.clear();
startPageNumber = pageNumber + 1;
}
int x0 = (int) page.getMediaBox().getLowerLeftX();
int xW = (int) page.getMediaBox().getWidth();
int y0 = (int) page.getMediaBox().getLowerLeftY() + this.config.pageTopMargin;
int yW = (int) page.getMediaBox().getHeight()
- (this.config.pageTopMargin + this.config.pageBottomMargin);
pdfTextStripper.addRegion(PDF_PAGE_REGION, new Rectangle(x0, y0, xW, yW));
pdfTextStripper.extractRegions(page);
var pageText = pdfTextStripper.getTextForRegion(PDF_PAGE_REGION);
if (StringUtils.hasText(pageText)) {
pageText = this.config.pageExtractedTextFormatter.format(pageText, pageNumber);
pageTextGroupList.add(pageText);
}
pageNumber++;
pdfTextStripper.removeRegion(PDF_PAGE_REGION);
}
if (!CollectionUtils.isEmpty(pageTextGroupList)) {
readDocuments.add(toDocument(pageTextGroupList.stream().collect(Collectors.joining()), startPageNumber,
pageNumber));
}
return readDocuments;
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
private Document toDocument(String docText, int startPageNumber, int endPageNumber) {
Document doc = new Document(docText);
doc.getMetadata().put(METADATA_START_PAGE_NUMBER, startPageNumber);
if (startPageNumber != endPageNumber) {
doc.getMetadata().put(METADATA_END_PAGE_NUMBER, endPageNumber);
}
doc.getMetadata().put(METADATA_FILE_NAME, this.resourceFileName);
return doc;
}
}

View File

@@ -0,0 +1,241 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed 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
*
* https://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.springframework.ai.reader.pdf;
import java.awt.Rectangle;
import java.io.File;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.apache.pdfbox.pdfparser.PDFParser;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentReader;
import org.springframework.ai.reader.pdf.config.ParagraphManager;
import org.springframework.ai.reader.pdf.config.ParagraphManager.Paragraph;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.reader.pdf.layout.PDFLayoutTextStripperByArea;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
* Uses the PDF catalog (e.g. TOC) information to split the input PDF into text paragraphs
* and output a single {@link Document} per paragraph.
*
* This class provides methods for reading and processing PDF documents. It uses the
* Apache PDFBox library for parsing PDF content and converting it into text paragraphs.
* The paragraphs are grouped into {@link Document} objects.
*
* @author Christian Tzolov
*/
public class ParagraphPdfDocumentReader implements DocumentReader {
// Constants for metadata keys
private static final String METADATA_START_PAGE = "page_number";
private static final String METADATA_END_PAGE = "end_page_number";
private static final String METADATA_TITLE = "title";
private static final String METADATA_LEVEL = "level";
private static final String METADATA_FILE_NAME = "file_name";
private final ParagraphManager paragraphTextExtractor;
private final PDDocument document;
private PdfDocumentReaderConfig config;
private File resourceFileName;
/**
* Constructs a ParagraphPdfDocumentReader using a resource URL.
* @param resourceUrl The URL of the PDF resource.
*/
public ParagraphPdfDocumentReader(String resourceUrl) {
this(new DefaultResourceLoader().getResource(resourceUrl));
}
/**
* Constructs a ParagraphPdfDocumentReader using a resource.
* @param pdfResource The PDF resource.
*/
public ParagraphPdfDocumentReader(Resource pdfResource) {
this(pdfResource, PdfDocumentReaderConfig.defaultConfig());
}
/**
* Constructs a ParagraphPdfDocumentReader using a resource URL and a configuration.
* @param resourceUrl The URL of the PDF resource.
* @param config The configuration for PDF document processing.
*/
public ParagraphPdfDocumentReader(String resourceUrl, PdfDocumentReaderConfig config) {
this(new DefaultResourceLoader().getResource(resourceUrl), config);
}
/**
* Constructs a ParagraphPdfDocumentReader using a resource and a configuration.
* @param pdfResource The PDF resource.
* @param config The configuration for PDF document processing.
*/
public ParagraphPdfDocumentReader(Resource pdfResource, PdfDocumentReaderConfig config) {
try {
PDFParser pdfParser = new PDFParser(
new org.apache.pdfbox.io.RandomAccessReadBuffer(pdfResource.getInputStream()));
this.document = pdfParser.parse();
this.config = config;
this.paragraphTextExtractor = new ParagraphManager(this.document);
this.resourceFileName = pdfResource.getFile();
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* Reads and processes the PDF document to extract paragraphs.
* @return A list of {@link Document} objects representing paragraphs.
*/
@Override
public List<Document> get() {
var paragraphs = this.paragraphTextExtractor.flatten();
List<Document> documents = new ArrayList<>(paragraphs.size());
if (!CollectionUtils.isEmpty(paragraphs)) {
Iterator<Paragraph> itr = paragraphs.iterator();
var current = itr.next();
if (!itr.hasNext()) {
documents.add(toDocument(current, current));
}
else {
while (itr.hasNext()) {
var next = itr.next();
Document document = toDocument(current, next);
if (document != null && StringUtils.hasText(document.getContent())) {
documents.add(toDocument(current, next));
}
current = next;
}
}
}
return documents;
}
private Document toDocument(Paragraph from, Paragraph to) {
String docText = this.getTextBetweenParagraphs(from, to);
if (!StringUtils.hasText(docText)) {
return null;
}
Document document = new Document(docText);
document.getMetadata().put(METADATA_TITLE, from.title());
document.getMetadata().put(METADATA_START_PAGE, from.startPageNumber());
document.getMetadata().put(METADATA_END_PAGE, to.startPageNumber());
document.getMetadata().put(METADATA_LEVEL, from.level());
document.getMetadata().put(METADATA_FILE_NAME, this.resourceFileName);
return document;
}
public String getTextBetweenParagraphs(Paragraph fromParagraph, Paragraph toParagraph) {
// Page started from index 0, while PDFBOx getPage return them from index 1.
int startPage = fromParagraph.startPageNumber() - 1;
int endPage = toParagraph.startPageNumber() - 1;
try {
StringBuilder sb = new StringBuilder();
var pdfTextStripper = new PDFLayoutTextStripperByArea();
pdfTextStripper.setSortByPosition(true);
for (int pageNumber = startPage; pageNumber <= endPage; pageNumber++) {
var page = this.document.getPage(pageNumber);
int fromPosition = fromParagraph.position();
int toPosition = toParagraph.position();
if (this.config.reversedParagraphPosition) {
fromPosition = (int) (page.getMediaBox().getHeight() - fromPosition);
toPosition = (int) (page.getMediaBox().getHeight() - toPosition);
}
int x0 = (int) page.getMediaBox().getLowerLeftX();
int xW = (int) page.getMediaBox().getWidth();
int y0 = (int) page.getMediaBox().getLowerLeftY();
int yW = (int) page.getMediaBox().getHeight();
if (pageNumber == startPage) {
y0 = fromPosition;
yW = (int) page.getMediaBox().getHeight() - y0;
}
if (pageNumber == endPage) {
yW = toPosition - y0;
}
if ((y0 + yW) == (int) page.getMediaBox().getHeight()) {
yW = yW - this.config.pageBottomMargin;
}
if (y0 == 0) {
y0 = y0 + this.config.pageTopMargin;
yW = yW - this.config.pageTopMargin;
}
pdfTextStripper.addRegion("pdfPageRegion", new Rectangle(x0, y0, xW, yW));
pdfTextStripper.extractRegions(page);
var text = pdfTextStripper.getTextForRegion("pdfPageRegion");
if (StringUtils.hasText(text)) {
sb.append(text);
}
pdfTextStripper.removeRegion("pdfPageRegion");
}
String text = sb.toString();
if (StringUtils.hasText(text)) {
text = this.config.pageExtractedTextFormatter.format(text, startPage);
}
return text;
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed 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
*
* https://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.springframework.ai.reader.pdf;
import java.io.FileWriter;
import java.io.IOException;
import java.util.List;
import org.springframework.ai.document.Document;
/**
* Utility class for working with PDF documents in tests.
*
* This utility class provides methods for writing extracted PDF documents to a file for
* testing purposes. It can write documents with or without document markers.
*
* @author Christian Tzolov
*/
public class PdfTestUtils {
private PdfTestUtils() {
}
/**
* Writes extracted PDF documents to a file.
* @param fileName The name of the file to write the documents to.
* @param docs The list of {@link Document} objects to write.
* @param withDocumentMarkers Whether to include document markers in the output.
* @throws IOException If an I/O error occurs while writing to the file.
*/
public static void writeToFile(String fileName, List<Document> docs, boolean withDocumentMarkers)
throws IOException {
try (var writer = new FileWriter(fileName, false)) {
int i = 0;
for (Document doc : docs) {
if (withDocumentMarkers) {
writer.write(String.format("%n### Doc: %s, pages:[%s,%s]\n", i,
doc.getMetadata().get(PagePdfDocumentReader.METADATA_START_PAGE_NUMBER),
doc.getMetadata().get(PagePdfDocumentReader.METADATA_END_PAGE_NUMBER)));
}
writer.write(doc.getContent());
i++;
}
}
}
}

View File

@@ -0,0 +1,199 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed 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
*
* https://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.springframework.ai.reader.pdf.config;
import java.io.IOException;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageXYZDestination;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
import org.springframework.util.CollectionUtils;
/**
* The ParagraphManager class is responsible for managing the paragraphs and hierarchy of
* a PDF document. It can process bookmarks and generate a structured tree of paragraphs,
* representing the table of contents (TOC) of the PDF document.
*
* @author Christian Tzolov
*/
public class ParagraphManager {
/**
* Represents a document paragraph metadata and hierarchy.
*
* @param parent Parent paragraph that will contain a children paragraphs.
* @param title Paragraph title as it appears in the PDF document.
* @param level The TOC deepness level for this paragraph. The root is at level 0.
* @param startPageNumber The page number in the PDF where this paragraph begins.
* @param endPageNumber The page number in the PDF where this paragraph ends.
* @param children Sub-paragraphs for this paragraph.
*/
public record Paragraph(Paragraph parent, String title, int level, int startPageNumber, int endPageNumber,
int position, List<Paragraph> children) {
public Paragraph(Paragraph parent, String title, int level, int startPageNumber, int endPageNumber,
int position) {
this(parent, title, level, startPageNumber, endPageNumber, position, new ArrayList<>());
}
@Override
public String toString() {
String indent = (level < 0) ? "" : new String(new char[level * 2]).replace('\0', ' ');
return indent + " " + level + ") " + title + " [" + startPageNumber + "," + endPageNumber + "], children = "
+ children.size() + ", pos = " + position;
}
}
/**
* Root of the paragraphs tree.
*/
private final Paragraph rootParagraph;
private final PDDocument document;
public ParagraphManager(PDDocument document) {
try {
this.document = document;
this.rootParagraph = this.generateParagraphs(
new Paragraph(null, "root", -1, 1, this.document.getNumberOfPages(), 0),
this.document.getDocumentCatalog().getDocumentOutline(), 0);
printParagraph(rootParagraph, System.out);
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
public List<Paragraph> flatten() {
List<Paragraph> paragraphs = new ArrayList<>();
for (var child : this.rootParagraph.children()) {
flatten(child, paragraphs);
}
return paragraphs;
}
private void flatten(Paragraph current, List<Paragraph> paragraphs) {
paragraphs.add(current);
for (var child : current.children()) {
flatten(child, paragraphs);
}
}
private void printParagraph(Paragraph paragraph, PrintStream printStream) {
printStream.println(paragraph);
for (Paragraph childParagraph : paragraph.children()) {
printParagraph(childParagraph, printStream);
}
}
/**
* For given {@link PDOutlineNode} bookmark convert all sibling {@link PDOutlineItem}
* items into {@link Paragraph} instances under the parentParagraph. For each
* {@link PDOutlineItem} item, recursively call
* {@link ParagraphManager#generateParagraphs} to process its children items.
* @param parentParagraph Root paragraph that the bookmark sibling items should be
* added to.
* @param bookmark TOC paragraphs to process.
* @param level Current TOC deepness level.
* @return Returns a tree of {@link Paragraph}s that represent the PDF document TOC.
* @throws IOException
*/
protected Paragraph generateParagraphs(Paragraph parentParagraph, PDOutlineNode bookmark, Integer level)
throws IOException {
PDOutlineItem current = bookmark.getFirstChild();
while (current != null) {
int pageNumber = getPageNumber(current);
var nextSiblingNumber = getPageNumber(current.getNextSibling());
if (nextSiblingNumber < 0) {
nextSiblingNumber = getPageNumber(current.getLastChild());
}
var paragraphPosition = (current.getDestination() instanceof PDPageXYZDestination)
? ((PDPageXYZDestination) current.getDestination()).getTop() : 0;
var currentParagraph = new Paragraph(parentParagraph, current.getTitle(), level, pageNumber,
nextSiblingNumber, paragraphPosition);
parentParagraph.children().add(currentParagraph);
// Recursive call to go the current paragraph's children paragraphs.
// E.g. go one level deeper.
this.generateParagraphs(currentParagraph, current, level + 1);
current = current.getNextSibling();
}
return parentParagraph;
}
private int getPageNumber(PDOutlineItem current) throws IOException {
if (current == null) {
return -1;
}
PDPage currentPage = current.findDestinationPage(this.document);
PDPageTree pages = this.document.getDocumentCatalog().getPages();
for (int i = 0; i < pages.getCount(); i++) {
var page = pages.get(i);
if (page.equals(currentPage)) {
return i + 1;
}
}
return -1;
}
public List<Paragraph> getParagraphsByLevel(Paragraph paragraph, int level, boolean interLevelText) {
List<Paragraph> resultList = new ArrayList<>();
if (paragraph.level() < level) {
if (!CollectionUtils.isEmpty(paragraph.children())) {
if (interLevelText) {
var interLevelParagraph = new Paragraph(paragraph.parent(), paragraph.title(), paragraph.level(),
paragraph.startPageNumber(), paragraph.children().get(0).startPageNumber(),
paragraph.position());
resultList.add(interLevelParagraph);
}
for (Paragraph child : paragraph.children()) {
resultList.addAll(getParagraphsByLevel(child, level, interLevelText));
}
}
}
else if (paragraph.level() == level) {
resultList.add(paragraph);
}
return resultList;
}
}

View File

@@ -0,0 +1,133 @@
package org.springframework.ai.reader.pdf.config;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.ParagraphPdfDocumentReader;
import org.springframework.ai.reader.pdf.layout.PageExtractedTextFormatter;
import org.springframework.util.Assert;
/**
* Common configuration builder for the {@link PagePdfDocumentReader} and the
* {@link ParagraphPdfDocumentReader}.
*
* @author Christian Tzolov
*/
public class PdfDocumentReaderConfig {
public static final int ALL_PAGES = 0;
public final boolean reversedParagraphPosition;
public final int pagesPerDocument;
public final int pageTopMargin;
public final int pageBottomMargin;
public final PageExtractedTextFormatter pageExtractedTextFormatter;
/**
* Start building a new configuration.
* @return The entry point for creating a new configuration.
*/
public static PdfDocumentReaderConfig.Builder builder() {
return new Builder();
}
/**
* {@return the default config}
*/
public static PdfDocumentReaderConfig defaultConfig() {
return builder().build();
}
private PdfDocumentReaderConfig(PdfDocumentReaderConfig.Builder builder) {
this.pagesPerDocument = builder.pagesPerDocument;
this.pageBottomMargin = builder.pageBottomMargin;
this.pageTopMargin = builder.pageTopMargin;
this.pageExtractedTextFormatter = builder.pageExtractedTextFormatter;
this.reversedParagraphPosition = builder.reversedParagraphPosition;
}
public static class Builder {
private int pagesPerDocument = 1;
private int pageTopMargin = 0;
private int pageBottomMargin = 0;
private PageExtractedTextFormatter pageExtractedTextFormatter = PageExtractedTextFormatter.defaults();
private boolean reversedParagraphPosition = false;
private Builder() {
}
/**
* Formatter of the extracted text.
* @param pageExtractedTextFormatter Instance of the PageExtractedTextFormatter.
* @return this builder
*/
public PdfDocumentReaderConfig.Builder withPageExtractedTextFormatter(
PageExtractedTextFormatter pageExtractedTextFormatter) {
Assert.notNull(pagesPerDocument >= 0, "PageExtractedTextFormatter must not be null.");
this.pageExtractedTextFormatter = pageExtractedTextFormatter;
return this;
}
/**
* How many pages to put in a single Document instance. 0 stands for all pages.
* Defaults to 0.
* @param pagesPerDocument Number of page's content to group in single Document.
* @return this builder
*/
public PdfDocumentReaderConfig.Builder withPagesPerDocument(int pagesPerDocument) {
Assert.isTrue(pagesPerDocument >= 0, "Page count must be a positive value.");
this.pagesPerDocument = pagesPerDocument;
return this;
}
/**
* Configures the Pdf reader page top margin. Defaults to 0.
* @param topMargin page top margin to use
* @return this builder
*/
public PdfDocumentReaderConfig.Builder withPageTopMargin(int topMargin) {
Assert.isTrue(topMargin >= 0, "Page margins must be a positive value.");
this.pageTopMargin = topMargin;
return this;
}
/**
* Configures the Pdf reader page bottom margin. Defaults to 0.
* @param bottomMargin page top margin to use
* @return this builder
*/
public PdfDocumentReaderConfig.Builder withPageBottomMargin(int bottomMargin) {
Assert.isTrue(bottomMargin >= 0, "Page margins must be a positive value.");
this.pageBottomMargin = bottomMargin;
return this;
}
/**
* Configures the Pdf reader reverse paragraph position. Defaults to false.
* @param reversedParagraphPosition to reverse or not the paragraph position
* withing a page.
* @return this builder
*/
public Builder withReversedParagraphPosition(boolean reversedParagraphPosition) {
this.reversedParagraphPosition = reversedParagraphPosition;
return this;
}
/**
* {@return the immutable configuration}
*/
public PdfDocumentReaderConfig build() {
return new PdfDocumentReaderConfig(this);
}
}
}

View File

@@ -0,0 +1,473 @@
/* Licensed 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.springframework.ai.reader.pdf.layout;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.apache.pdfbox.text.TextPositionComparator;
/**
* This class extends PDFTextStripper to provide custom text extraction and formatting
* capabilities for PDF pages. It includes features like processing text lines, sorting
* text positions, and managing line breaks.
*
* @author Jonathan Link
*
*/
public class ForkPDFLayoutTextStripper extends PDFTextStripper {
public static final boolean DEBUG = false;
public static final int OUTPUT_SPACE_CHARACTER_WIDTH_IN_PT = 4;
private double currentPageWidth;
private TextPosition previousTextPosition;
private List<TextLine> textLineList;
/**
* Constructor
*/
public ForkPDFLayoutTextStripper() throws IOException {
super();
this.previousTextPosition = null;
this.textLineList = new ArrayList<TextLine>();
}
/**
* @param page page to parse
*/
@Override
public void processPage(PDPage page) throws IOException {
PDRectangle pageRectangle = page.getMediaBox();
if (pageRectangle != null) {
this.setCurrentPageWidth(pageRectangle.getWidth() * 1.4);
super.processPage(page);
this.previousTextPosition = null;
this.textLineList = new ArrayList<TextLine>();
}
}
@Override
protected void writePage() throws IOException {
List<List<TextPosition>> charactersByArticle = super.getCharactersByArticle();
for (int i = 0; i < charactersByArticle.size(); i++) {
List<TextPosition> textList = charactersByArticle.get(i);
try {
this.sortTextPositionList(textList);
}
catch (java.lang.IllegalArgumentException e) {
System.err.println(e);
}
this.iterateThroughTextList(textList.iterator());
}
this.writeToOutputStream(this.getTextLineList());
}
private void writeToOutputStream(final List<TextLine> textLineList) throws IOException {
for (TextLine textLine : textLineList) {
char[] line = textLine.getLine().toCharArray();
super.getOutput().write(line);
super.getOutput().write('\n');
super.getOutput().flush();
}
}
/*
* In order to get rid of the warning: TextPositionComparator class should implement
* Comparator<TextPosition> instead of Comparator
*/
@SuppressWarnings("unchecked")
private void sortTextPositionList(final List<TextPosition> textList) {
TextPositionComparator comparator = new TextPositionComparator();
Collections.sort(textList, comparator);
}
private void writeLine(final List<TextPosition> textPositionList) {
if (textPositionList.size() > 0) {
TextLine textLine = this.addNewLine();
boolean firstCharacterOfLineFound = false;
for (TextPosition textPosition : textPositionList) {
CharacterFactory characterFactory = new CharacterFactory(firstCharacterOfLineFound);
Character character = characterFactory.createCharacterFromTextPosition(textPosition,
this.getPreviousTextPosition());
textLine.writeCharacterAtIndex(character);
this.setPreviousTextPosition(textPosition);
firstCharacterOfLineFound = true;
}
}
else {
this.addNewLine(); // white line
}
}
private void iterateThroughTextList(Iterator<TextPosition> textIterator) {
List<TextPosition> textPositionList = new ArrayList<TextPosition>();
while (textIterator.hasNext()) {
TextPosition textPosition = (TextPosition) textIterator.next();
int numberOfNewLines = this.getNumberOfNewLinesFromPreviousTextPosition(textPosition);
if (numberOfNewLines == 0) {
textPositionList.add(textPosition);
}
else {
this.writeTextPositionList(textPositionList);
this.createNewEmptyNewLines(numberOfNewLines);
textPositionList.add(textPosition);
}
this.setPreviousTextPosition(textPosition);
}
if (!textPositionList.isEmpty()) {
this.writeTextPositionList(textPositionList);
}
}
private void writeTextPositionList(final List<TextPosition> textPositionList) {
this.writeLine(textPositionList);
textPositionList.clear();
}
private void createNewEmptyNewLines(int numberOfNewLines) {
for (int i = 0; i < numberOfNewLines - 1; ++i) {
this.addNewLine();
}
}
private int getNumberOfNewLinesFromPreviousTextPosition(final TextPosition textPosition) {
TextPosition previousTextPosition = this.getPreviousTextPosition();
if (previousTextPosition == null) {
return 1;
}
float textYPosition = Math.round(textPosition.getY());
float previousTextYPosition = Math.round(previousTextPosition.getY());
if (textYPosition > previousTextYPosition && (textYPosition - previousTextYPosition > 5.5)) {
double height = textPosition.getHeight();
int numberOfLines = (int) (Math.floor(textYPosition - previousTextYPosition) / height);
numberOfLines = Math.max(1, numberOfLines - 1); // exclude current new line
if (DEBUG)
System.out.println(height + " " + numberOfLines);
return numberOfLines;
}
else {
return 0;
}
}
private TextLine addNewLine() {
TextLine textLine = new TextLine(this.getCurrentPageWidth());
textLineList.add(textLine);
return textLine;
}
private TextPosition getPreviousTextPosition() {
return this.previousTextPosition;
}
private void setPreviousTextPosition(final TextPosition setPreviousTextPosition) {
this.previousTextPosition = setPreviousTextPosition;
}
private int getCurrentPageWidth() {
return (int) Math.round(this.currentPageWidth);
}
private void setCurrentPageWidth(double currentPageWidth) {
this.currentPageWidth = currentPageWidth;
}
private List<TextLine> getTextLineList() {
return this.textLineList;
}
}
class TextLine {
private static final char SPACE_CHARACTER = ' ';
private int lineLength;
private String line;
private int lastIndex;
public TextLine(int lineLength) {
this.line = "";
this.lineLength = lineLength / ForkPDFLayoutTextStripper.OUTPUT_SPACE_CHARACTER_WIDTH_IN_PT;
this.completeLineWithSpaces();
}
public void writeCharacterAtIndex(final Character character) {
character.setIndex(this.computeIndexForCharacter(character));
int index = character.getIndex();
char characterValue = character.getCharacterValue();
if (this.indexIsInBounds(index) && this.line.charAt(index) == SPACE_CHARACTER) {
this.line = this.line.substring(0, index) + characterValue
+ this.line.substring(index + 1, this.getLineLength());
}
}
public int getLineLength() {
return this.lineLength;
}
public String getLine() {
return line;
}
private int computeIndexForCharacter(final Character character) {
int index = character.getIndex();
boolean isCharacterPartOfPreviousWord = character.isCharacterPartOfPreviousWord();
boolean isCharacterAtTheBeginningOfNewLine = character.isCharacterAtTheBeginningOfNewLine();
boolean isCharacterCloseToPreviousWord = character.isCharacterCloseToPreviousWord();
if (!this.indexIsInBounds(index)) {
return -1;
}
else {
if (isCharacterPartOfPreviousWord && !isCharacterAtTheBeginningOfNewLine) {
index = this.findMinimumIndexWithSpaceCharacterFromIndex(index);
}
else if (isCharacterCloseToPreviousWord) {
if (this.line.charAt(index) != SPACE_CHARACTER) {
index = index + 1;
}
else {
index = this.findMinimumIndexWithSpaceCharacterFromIndex(index) + 1;
}
}
index = this.getNextValidIndex(index, isCharacterPartOfPreviousWord);
return index;
}
}
private boolean isSpaceCharacterAtIndex(int index) {
return this.line.charAt(index) != SPACE_CHARACTER;
}
private boolean isNewIndexGreaterThanLastIndex(int index) {
int lastIndex = this.getLastIndex();
return (index > lastIndex);
}
private int getNextValidIndex(int index, boolean isCharacterPartOfPreviousWord) {
int nextValidIndex = index;
int lastIndex = this.getLastIndex();
if (!this.isNewIndexGreaterThanLastIndex(index)) {
nextValidIndex = lastIndex + 1;
}
if (!isCharacterPartOfPreviousWord && this.isSpaceCharacterAtIndex(index - 1)) {
nextValidIndex = nextValidIndex + 1;
}
this.setLastIndex(nextValidIndex);
return nextValidIndex;
}
private int findMinimumIndexWithSpaceCharacterFromIndex(int index) {
int newIndex = index;
while (newIndex >= 0 && this.line.charAt(newIndex) == SPACE_CHARACTER) {
newIndex = newIndex - 1;
}
return newIndex + 1;
}
private boolean indexIsInBounds(int index) {
return (index >= 0 && index < this.lineLength);
}
private void completeLineWithSpaces() {
for (int i = 0; i < this.getLineLength(); ++i) {
line += SPACE_CHARACTER;
}
}
private int getLastIndex() {
return this.lastIndex;
}
private void setLastIndex(int lastIndex) {
this.lastIndex = lastIndex;
}
}
class Character {
private char characterValue;
private int index;
private boolean isCharacterPartOfPreviousWord;
private boolean isFirstCharacterOfAWord;
private boolean isCharacterAtTheBeginningOfNewLine;
private boolean isCharacterCloseToPreviousWord;
public Character(char characterValue, int index, boolean isCharacterPartOfPreviousWord,
boolean isFirstCharacterOfAWord, boolean isCharacterAtTheBeginningOfNewLine,
boolean isCharacterPartOfASentence) {
this.characterValue = characterValue;
this.index = index;
this.isCharacterPartOfPreviousWord = isCharacterPartOfPreviousWord;
this.isFirstCharacterOfAWord = isFirstCharacterOfAWord;
this.isCharacterAtTheBeginningOfNewLine = isCharacterAtTheBeginningOfNewLine;
this.isCharacterCloseToPreviousWord = isCharacterPartOfASentence;
if (ForkPDFLayoutTextStripper.DEBUG)
System.out.println(this.toString());
}
public char getCharacterValue() {
return this.characterValue;
}
public int getIndex() {
return this.index;
}
public void setIndex(int index) {
this.index = index;
}
public boolean isCharacterPartOfPreviousWord() {
return this.isCharacterPartOfPreviousWord;
}
public boolean isFirstCharacterOfAWord() {
return this.isFirstCharacterOfAWord;
}
public boolean isCharacterAtTheBeginningOfNewLine() {
return this.isCharacterAtTheBeginningOfNewLine;
}
public boolean isCharacterCloseToPreviousWord() {
return this.isCharacterCloseToPreviousWord;
}
public String toString() {
String toString = "";
toString += index;
toString += " ";
toString += characterValue;
toString += " isCharacterPartOfPreviousWord=" + isCharacterPartOfPreviousWord;
toString += " isFirstCharacterOfAWord=" + isFirstCharacterOfAWord;
toString += " isCharacterAtTheBeginningOfNewLine=" + isCharacterAtTheBeginningOfNewLine;
toString += " isCharacterPartOfASentence=" + isCharacterCloseToPreviousWord;
toString += " isCharacterCloseToPreviousWord=" + isCharacterCloseToPreviousWord;
return toString;
}
}
class CharacterFactory {
private TextPosition previousTextPosition;
private boolean firstCharacterOfLineFound;
private boolean isCharacterPartOfPreviousWord;
private boolean isFirstCharacterOfAWord;
private boolean isCharacterAtTheBeginningOfNewLine;
private boolean isCharacterCloseToPreviousWord;
public CharacterFactory(boolean firstCharacterOfLineFound) {
this.firstCharacterOfLineFound = firstCharacterOfLineFound;
}
public Character createCharacterFromTextPosition(final TextPosition textPosition,
final TextPosition previousTextPosition) {
this.setPreviousTextPosition(previousTextPosition);
this.isCharacterPartOfPreviousWord = this.isCharacterPartOfPreviousWord(textPosition);
this.isFirstCharacterOfAWord = this.isFirstCharacterOfAWord(textPosition);
this.isCharacterAtTheBeginningOfNewLine = this.isCharacterAtTheBeginningOfNewLine(textPosition);
this.isCharacterCloseToPreviousWord = this.isCharacterCloseToPreviousWord(textPosition);
char character = this.getCharacterFromTextPosition(textPosition);
int index = (int) textPosition.getX() / ForkPDFLayoutTextStripper.OUTPUT_SPACE_CHARACTER_WIDTH_IN_PT;
return new Character(character, index, isCharacterPartOfPreviousWord, isFirstCharacterOfAWord,
isCharacterAtTheBeginningOfNewLine, isCharacterCloseToPreviousWord);
}
private boolean isCharacterAtTheBeginningOfNewLine(final TextPosition textPosition) {
if (!firstCharacterOfLineFound) {
return true;
}
TextPosition previousTextPosition = this.getPreviousTextPosition();
float previousTextYPosition = previousTextPosition.getY();
return (Math.round(textPosition.getY()) < Math.round(previousTextYPosition));
}
private boolean isFirstCharacterOfAWord(final TextPosition textPosition) {
if (!firstCharacterOfLineFound) {
return true;
}
double numberOfSpaces = this.numberOfSpacesBetweenTwoCharacters(previousTextPosition, textPosition);
return (numberOfSpaces > 1) || this.isCharacterAtTheBeginningOfNewLine(textPosition);
}
private boolean isCharacterCloseToPreviousWord(final TextPosition textPosition) {
if (!firstCharacterOfLineFound) {
return false;
}
double numberOfSpaces = this.numberOfSpacesBetweenTwoCharacters(previousTextPosition, textPosition);
return (numberOfSpaces > 1 && numberOfSpaces <= ForkPDFLayoutTextStripper.OUTPUT_SPACE_CHARACTER_WIDTH_IN_PT);
}
private boolean isCharacterPartOfPreviousWord(final TextPosition textPosition) {
TextPosition previousTextPosition = this.getPreviousTextPosition();
if (previousTextPosition.getUnicode().equals(" ")) {
return false;
}
double numberOfSpaces = this.numberOfSpacesBetweenTwoCharacters(previousTextPosition, textPosition);
return (numberOfSpaces <= 1);
}
private double numberOfSpacesBetweenTwoCharacters(final TextPosition textPosition1,
final TextPosition textPosition2) {
double previousTextXPosition = textPosition1.getX();
double previousTextWidth = textPosition1.getWidth();
double previousTextEndXPosition = (previousTextXPosition + previousTextWidth);
double numberOfSpaces = Math.abs(Math.round(textPosition2.getX() - previousTextEndXPosition));
return numberOfSpaces;
}
private char getCharacterFromTextPosition(final TextPosition textPosition) {
String string = textPosition.getUnicode();
char character = string.charAt(0);
return character;
}
private TextPosition getPreviousTextPosition() {
return this.previousTextPosition;
}
private void setPreviousTextPosition(final TextPosition previousTextPosition) {
this.previousTextPosition = previousTextPosition;
}
}

View File

@@ -0,0 +1,154 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed 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
*
* https://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.springframework.ai.reader.pdf.layout;
import java.awt.geom.Rectangle2D;
import java.io.IOException;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.text.TextPosition;
/**
* Re-implement the PDFLayoutTextStripperByArea on top of the PDFLayoutTextStripper
* instead the original PDFTextStripper.
*
* This class allows cropping pages (e.g., removing headers, footers, and between-page
* empty spaces) while extracting layout text, preserving the PDF's internal text
* formatting.
*
* @author Christian Tzolov
*/
public class PDFLayoutTextStripperByArea extends ForkPDFLayoutTextStripper {
private final List<String> regions = new ArrayList<String>();
private final Map<String, Rectangle2D> regionArea = new HashMap<String, Rectangle2D>();
private final Map<String, ArrayList<List<TextPosition>>> regionCharacterList = new HashMap<String, ArrayList<List<TextPosition>>>();
private final Map<String, StringWriter> regionText = new HashMap<String, StringWriter>();
/**
* Constructor.
* @throws IOException If there is an error loading properties.
*/
public PDFLayoutTextStripperByArea() throws IOException {
super.setShouldSeparateByBeads(false);
}
/**
* This method does nothing in this derived class, because beads and regions are
* incompatible. Beads are ignored when stripping by area.
* @param aShouldSeparateByBeads The new grouping of beads.
*/
@Override
public final void setShouldSeparateByBeads(boolean aShouldSeparateByBeads) {
}
/**
* Add a new region to group text by.
* @param regionName The name of the region.
* @param rect The rectangle area to retrieve the text from. The y-coordinates are
* java coordinates (y == 0 is top), not PDF coordinates (y == 0 is bottom).
*/
public void addRegion(String regionName, Rectangle2D rect) {
regions.add(regionName);
regionArea.put(regionName, rect);
}
/**
* Delete a region to group text by. If the region does not exist, this method does
* nothing.
* @param regionName The name of the region to delete.
*/
public void removeRegion(String regionName) {
regions.remove(regionName);
regionArea.remove(regionName);
}
/**
* Get the list of regions that have been setup.
* @return A list of java.lang.String objects to identify the region names.
*/
public List<String> getRegions() {
return regions;
}
/**
* Get the text for the region, this should be called after extractRegions().
* @param regionName The name of the region to get the text from.
* @return The text that was identified in that region.
*/
public String getTextForRegion(String regionName) {
StringWriter text = regionText.get(regionName);
return text.toString();
}
/**
* Process the page to extract the region text.
* @param page The page to extract the regions from.
* @throws IOException If there is an error while extracting text.
*/
public void extractRegions(PDPage page) throws IOException {
for (String regionName : regions) {
setStartPage(getCurrentPageNo());
setEndPage(getCurrentPageNo());
// reset the stored text for the region so this class can be reused.
ArrayList<List<TextPosition>> regionCharactersByArticle = new ArrayList<List<TextPosition>>();
regionCharactersByArticle.add(new ArrayList<TextPosition>());
regionCharacterList.put(regionName, regionCharactersByArticle);
regionText.put(regionName, new StringWriter());
}
if (page.hasContents()) {
processPage(page);
}
}
/**
* {@inheritDoc}
*/
@Override
protected void processTextPosition(TextPosition text) {
for (Map.Entry<String, Rectangle2D> regionAreaEntry : regionArea.entrySet()) {
Rectangle2D rect = regionAreaEntry.getValue();
if (rect.contains(text.getX(), text.getY())) {
charactersByArticle = regionCharacterList.get(regionAreaEntry.getKey());
super.processTextPosition(text);
}
}
}
/**
* This will print the processed page text to the output stream.
* @throws IOException If there is an error writing the text.
*/
@Override
protected void writePage() throws IOException {
for (String region : regionArea.keySet()) {
charactersByArticle = regionCharacterList.get(region);
output = regionText.get(region);
super.writePage();
}
}
}

View File

@@ -0,0 +1,186 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed 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
*
* https://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.springframework.ai.reader.pdf.layout;
import org.springframework.util.StringUtils;
/**
* Provides text formatting options for extracted PDF page text, including left alignment
* and the ability to trim and delete lines from the top and bottom of the text.
*
* This class allows customization of text formatting applied to extracted PDF page text.
* It can align the text to the left, remove specified lines from the top and bottom of
* the text, and trim adjacent blank lines.
*
* @author Christian Tzolov
*/
public class PageExtractedTextFormatter {
private boolean leftAlignment;
private int numberOfTopPagesToSkipBeforeDelete;
private int numberOfTopTextLinesToDelete;
private int numberOfBottomTextLinesToDelete;
private PageExtractedTextFormatter(Builder builder) {
this.leftAlignment = builder.leftAlignment;
this.numberOfBottomTextLinesToDelete = builder.numberOfBottomTextLinesToDelete;
this.numberOfTopPagesToSkipBeforeDelete = builder.numberOfTopPagesToSkipBeforeDelete;
this.numberOfTopTextLinesToDelete = builder.numberOfTopTextLinesToDelete;
}
public static Builder builder() {
return new Builder();
}
public static PageExtractedTextFormatter defaults() {
return new Builder().build();
}
public String format(String pageText, int pageNumber) {
var text = trimAdjacentBlankLines(pageText);
if (pageNumber >= this.numberOfTopPagesToSkipBeforeDelete) {
text = deleteTopTextLines(text, this.numberOfTopTextLinesToDelete);
text = deleteBottomTextLines(text, this.numberOfBottomTextLinesToDelete);
}
if (this.leftAlignment) {
text = alignToLeft(text);
}
return text;
}
public static class Builder {
private boolean leftAlignment = false;
private int numberOfTopPagesToSkipBeforeDelete = 0;
private int numberOfTopTextLinesToDelete = 0;
private int numberOfBottomTextLinesToDelete = 0;
/**
* Align the document text to the left. Defaults to false.
* @param leftAlignment Flag to align the text to the left.
* @return this builder
*/
public Builder withLeftAlignment(boolean leftAlignment) {
this.leftAlignment = leftAlignment;
return this;
}
/**
* Withdraw the top N pages from the text top/bottom line deletion. Defaults to 0.
* @param numberOfTopPagesToSkipBeforeDelete Number of pages to skip from
* top/bottom line deletion policy.
* @return this builder
*/
public Builder withNumberOfTopPagesToSkipBeforeDelete(int numberOfTopPagesToSkipBeforeDelete) {
this.numberOfTopPagesToSkipBeforeDelete = numberOfTopPagesToSkipBeforeDelete;
return this;
}
/**
* Remove the top N lines from the page text. Defaults to 0.
* @param numberOfTopTextLinesToDelete Number of top text lines to delete.
* @return this builder
*/
public Builder withNumberOfTopTextLinesToDelete(int numberOfTopTextLinesToDelete) {
this.numberOfTopTextLinesToDelete = numberOfTopTextLinesToDelete;
return this;
}
/**
* Remove the bottom N lines from the page text. Defaults to 0.
* @param numberOfBottomTextLinesToDelete Number of bottom text lines to delete.
* @return this builder
*/
public Builder withNumberOfBottomTextLinesToDelete(int numberOfBottomTextLinesToDelete) {
this.numberOfBottomTextLinesToDelete = numberOfBottomTextLinesToDelete;
return this;
}
public PageExtractedTextFormatter build() {
return new PageExtractedTextFormatter(this);
}
}
/**
* Replaces multiple, adjacent blank lines into a single blank line.
* @param pageText text to adjust the blank lines for.
* @return Returns the same text but with blank lines trimmed.
*/
public static String trimAdjacentBlankLines(String pageText) {
return pageText.replaceAll("(?m)(^ *\n)", "\n").replaceAll("(?m)^$([\r\n]+?)(^$[\r\n]+?^)+", "$1");
}
/**
* @param pageText text to align.
* @return Returns the same text but aligned to the left side.
*/
public static String alignToLeft(String pageText) {
return pageText.replaceAll("(?m)(^ *| +(?= |$))", "").replaceAll("(?m)^$( ?)(^$[\r\n]+?^)+", "$1");
}
/**
* Removes the specified number of lines from the bottom part of the text.
* @param pageText Text to remove lines from.
* @param numberOfLines Number of lines to remove.
* @return Returns the text striped from last lines.
*/
public static String deleteBottomTextLines(String pageText, int numberOfLines) {
if (!StringUtils.hasText(pageText)) {
return pageText;
}
int lineCount = 0;
int truncateIndex = pageText.length();
int nextTruncateIndex = truncateIndex;
while (lineCount < numberOfLines && nextTruncateIndex >= 0) {
nextTruncateIndex = pageText.lastIndexOf(System.lineSeparator(), truncateIndex - 1);
truncateIndex = nextTruncateIndex < 0 ? truncateIndex : nextTruncateIndex;
lineCount++;
}
return pageText.substring(0, truncateIndex);
}
public static String deleteTopTextLines(String pageText, int numberOfLines) {
if (!StringUtils.hasText(pageText)) {
return pageText;
}
int lineCount = 0;
int truncateIndex = 0;
int nextTruncateIndex = truncateIndex;
while (lineCount < numberOfLines && nextTruncateIndex >= 0) {
nextTruncateIndex = pageText.indexOf(System.lineSeparator(), truncateIndex + 1);
truncateIndex = nextTruncateIndex < 0 ? truncateIndex : nextTruncateIndex;
lineCount++;
}
return pageText.substring(truncateIndex, pageText.length());
}
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2023-2023 the original author or authors.
*
* Licensed 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
*
* https://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.springframework.ai.reader.pdf;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.reader.pdf.layout.PageExtractedTextFormatter;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Christian Tzolov
*/
public class PagePdfDocumentReaderTests {
@Test
public void test1() {
PagePdfDocumentReader pdfReader = new PagePdfDocumentReader("classpath:/sample1.pdf",
PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageBottomMargin(0)
.withPageExtractedTextFormatter(PageExtractedTextFormatter.builder()
.withNumberOfTopTextLinesToDelete(0)
.withNumberOfBottomTextLinesToDelete(3)
.withNumberOfTopPagesToSkipBeforeDelete(0)
.build())
.withPagesPerDocument(1)
.build());
List<Document> docs = pdfReader.get();
assertThat(docs).hasSize(4);
String allText = docs.stream().map(d -> d.getContent()).collect(Collectors.joining("\n"));
assertThat(allText).doesNotContain(
List.of("Page 1 of 4", "Page 2 of 4", "Page 3 of 4", "Page 4 of 4", "PDF Bookmark Sample"));
}
}

View File

@@ -23,6 +23,7 @@
<module>vector-stores/spring-ai-milvus-store</module>
<module>vector-stores/spring-ai-neo4j-store</module>
<module>embedding-clients/spring-ai-postgresml-embedding-client</module>
<module>document-readers/pdf-reader</module>
</modules>
<organization>

View File

@@ -24,7 +24,6 @@
</properties>
<dependencies>
<!-- production dependencies -->
<dependency>
<groupId>org.antlr</groupId>

View File

@@ -21,7 +21,6 @@ import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.TextReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;

View File

@@ -4121,4 +4121,4 @@
target bean, bypassing the proxy. Hence, it would be inconsistent to apply the interceptors to the
init method, because doing so would couple the lifecycle of the target bean to its proxy or
interceptors and leave strange semantics when your code interacts directly with the raw target
bean.
bean.