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:
committed by
Mark Pollack
parent
f74bec3662
commit
1266c04b6d
53
document-readers/pdf-reader/README.md
Normal file
53
document-readers/pdf-reader/README.md
Normal 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());
|
||||
|
||||
}
|
||||
```
|
||||
57
document-readers/pdf-reader/pom.xml
Normal file
57
document-readers/pdf-reader/pom.xml
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
BIN
document-readers/pdf-reader/src/test/resources/sample1.pdf
Normal file
BIN
document-readers/pdf-reader/src/test/resources/sample1.pdf
Normal file
Binary file not shown.
BIN
document-readers/pdf-reader/src/test/resources/sample2.pdf
Normal file
BIN
document-readers/pdf-reader/src/test/resources/sample2.pdf
Normal file
Binary file not shown.
1
pom.xml
1
pom.xml
@@ -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>
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<!-- production dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.antlr</groupId>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user