Pdf comment agent (#6196)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-05-01 10:19:38 +01:00
committed by GitHub
co-authored by James Brunton
parent 2dc5276e8b
commit 86774d556e
78 changed files with 5091 additions and 112 deletions
@@ -0,0 +1,134 @@
package stirling.software.proprietary.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver;
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
import tools.jackson.databind.json.JsonMapper;
/**
* Controller tests for {@link PdfCommentAgentController}. The orchestrator is mocked so the test
* never hits the engine or real filesystem.
*/
@ExtendWith(MockitoExtension.class)
class PdfCommentAgentControllerTest {
@Mock private PdfCommentAgentOrchestrator orchestrator;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
PdfCommentAgentController controller =
new PdfCommentAgentController(orchestrator, JsonMapper.builder().build());
mockMvc =
MockMvcBuilders.standaloneSetup(controller)
// standaloneSetup's defaults don't handle ResponseStatusException; wire up
// both the ResponseStatusException resolver (for orchestrator 400s) and
// DefaultHandlerExceptionResolver (so missing @RequestParam still 400s).
.setHandlerExceptionResolvers(
new ResponseStatusExceptionResolver(),
new DefaultHandlerExceptionResolver())
.build();
}
@Test
void acceptsValidPdfAndReturnsAnnotatedBytes() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput",
"input.pdf",
MediaType.APPLICATION_PDF_VALUE,
"%PDF-1.4\n%%EOF".getBytes());
byte[] annotatedBytes = "%PDF-1.4\n<annotated>\n%%EOF".getBytes();
AnnotatedPdf stub = new AnnotatedPdf(annotatedBytes, "input-commented.pdf", 2, 2, "ok");
when(orchestrator.applyComments(any(MultipartFile.class), eq("flag dates")))
.thenReturn(stub);
mockMvc.perform(
multipart("/api/v1/ai/tools/pdf-comment-agent")
.file(pdfFile)
.param("prompt", "flag dates"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(
header().string(
"Content-Disposition",
org.hamcrest.Matchers.containsString(
"input-commented.pdf")))
.andExpect(content().bytes(annotatedBytes));
verify(orchestrator).applyComments(any(MultipartFile.class), eq("flag dates"));
}
@Test
void propagatesOrchestratorBadRequestForNonPdfUpload() throws Exception {
// The controller delegates validation to the orchestrator; a ResponseStatusException
// thrown by the orchestrator should propagate to Spring as a 400.
MockMultipartFile notPdf =
new MockMultipartFile(
"fileInput", "input.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes());
when(orchestrator.applyComments(any(MultipartFile.class), eq("whatever")))
.thenThrow(
new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Only application/pdf uploads are supported"));
mockMvc.perform(
multipart("/api/v1/ai/tools/pdf-comment-agent")
.file(notPdf)
.param("prompt", "whatever"))
.andExpect(status().isBadRequest());
verify(orchestrator).applyComments(any(MultipartFile.class), eq("whatever"));
}
@Test
void rejectsMissingFileInput() throws Exception {
mockMvc.perform(multipart("/api/v1/ai/tools/pdf-comment-agent").param("prompt", "test"))
.andExpect(status().is4xxClientError());
verify(orchestrator, never()).applyComments(any(), anyString());
}
@Test
void rejectsMissingPromptParameter() throws Exception {
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput",
"input.pdf",
MediaType.APPLICATION_PDF_VALUE,
"%PDF-1.4\n%%EOF".getBytes());
mockMvc.perform(multipart("/api/v1/ai/tools/pdf-comment-agent").file(pdfFile))
.andExpect(status().is4xxClientError());
verify(orchestrator, never()).applyComments(any(), anyString());
}
}
@@ -0,0 +1,87 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.net.ConnectException;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
/**
* Verifies that AiEngineClient surfaces network-layer failures as structured HTTP statuses so every
* AI tool caller sees a consistent, meaningful error rather than a raw 500.
*/
class AiEngineClientTest {
private ApplicationProperties applicationProperties;
private HttpClient httpClient;
private AiEngineClient client;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getAiEngine().setEnabled(true);
applicationProperties.getAiEngine().setUrl("http://localhost:5001");
applicationProperties.getAiEngine().setTimeoutSeconds(5);
httpClient = mock(HttpClient.class);
client = new AiEngineClient(applicationProperties, httpClient);
}
@Test
void postWrapsConnectIOExceptionAsServiceUnavailable() throws Exception {
ConnectException cause = new ConnectException("Connection refused");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
assertSame(cause, ex.getCause(), "Original cause should be preserved for diagnostics");
}
@Test
void postWrapsTimeoutAsGatewayTimeout() throws Exception {
HttpTimeoutException cause = new HttpTimeoutException("request timed out");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertEquals(HttpStatus.GATEWAY_TIMEOUT, ex.getStatusCode());
assertSame(cause, ex.getCause());
}
@Test
void getWrapsGenericIOExceptionAsServiceUnavailable() throws Exception {
IOException cause = new IOException("socket reset");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenThrow(cause);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.get("/x"));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
@Test
void postShortCircuitsWhenEngineDisabled() {
applicationProperties.getAiEngine().setEnabled(false);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> client.post("/x", "{}"));
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
}
@@ -0,0 +1,80 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
class AiToolInputValidatorTest {
@Test
void acceptsValidPdfUpload() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "a.pdf", "application/pdf", new byte[] {1, 2});
assertDoesNotThrow(() -> AiToolInputValidator.validatePdfUpload(file));
}
@Test
void rejectsNullFile() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(null));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
void rejectsEmptyFile() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "a.pdf", "application/pdf", new byte[0]);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(file));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
void rejectsNonPdfContentType() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "a.txt", "text/plain", new byte[] {1, 2});
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(file));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
void rejectsMissingContentType() {
MockMultipartFile file =
new MockMultipartFile("fileInput", "a.pdf", null, new byte[] {1, 2});
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(file));
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
}
@Test
void rejectsOversizedFile() {
// Mock getSize() to avoid allocating a 50 MB test payload.
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getContentType()).thenReturn("application/pdf");
when(file.getSize()).thenReturn(AiToolInputValidator.MAX_INPUT_FILE_BYTES + 1);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> AiToolInputValidator.validatePdfUpload(file));
assertEquals(HttpStatus.PAYLOAD_TOO_LARGE, ex.getStatusCode());
}
}
@@ -0,0 +1,255 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfAnnotationService;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentEngineResponse;
import stirling.software.proprietary.model.api.ai.comments.PdfCommentInstruction;
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* Smoke tests for {@link PdfCommentAgentOrchestrator}. Collaborators (engine client, PDF factory,
* chunk extractor) are mocked; the orchestrator is exercised end-to-end on an in-memory PDF so the
* returned bytes can be re-loaded and inspected.
*/
@ExtendWith(MockitoExtension.class)
class PdfCommentAgentOrchestratorTest {
@Mock private AiEngineClient aiEngineClient;
@Mock private PdfTextChunkExtractor pdfTextChunkExtractor;
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
private ObjectMapper objectMapper;
private PdfAnnotationService pdfAnnotationService;
private PdfCommentAgentOrchestrator orchestrator;
@BeforeEach
void setUp() {
objectMapper = JsonMapper.builder().build();
// Real (not mocked) — it's a pure primitive; exercising it in the test gives us stronger
// assertions (the annotated PDF actually has the expected sticky notes).
pdfAnnotationService = new PdfAnnotationService();
orchestrator =
new PdfCommentAgentOrchestrator(
aiEngineClient,
pdfTextChunkExtractor,
pdfDocumentFactory,
objectMapper,
pdfAnnotationService);
}
@Test
void happyPathAppliesValidInstructionsOnCorrectPagesAndReturnsBytes() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
byte[] pdfBytes = twoPagePdfBytes();
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk zero");
TextChunk c1 = new TextChunk("p0-c1", 0, 72f, 680f, 100f, 12f, "Chunk one");
TextChunk c2 = new TextChunk("p1-c0", 1, 72f, 700f, 100f, 12f, "Chunk two");
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0, c1, c2));
PdfCommentEngineResponse engineResponse =
new PdfCommentEngineResponse(
"session-1",
List.of(
new PdfCommentInstruction(
"p0-c0", "Comment on page 0", "alice", "Heads up"),
new PdfCommentInstruction(
"p1-c0", "Comment on page 1", null, null)),
"reviewed");
when(aiEngineClient.post(anyString(), anyString()))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "please comment");
assertEquals("doc-commented.pdf", result.fileName());
assertNotNull(result.bytes(), "Returned bytes must not be null");
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
List<PDAnnotationText> page0Texts = textAnnotations(saved.getPage(0).getAnnotations());
List<PDAnnotationText> page1Texts = textAnnotations(saved.getPage(1).getAnnotations());
assertEquals(1, page0Texts.size(), "Exactly one annotation on page 0");
assertEquals(1, page1Texts.size(), "Exactly one annotation on page 1");
assertEquals("Comment on page 0", page0Texts.get(0).getContents());
assertEquals("Comment on page 1", page1Texts.get(0).getContents());
}
}
@Test
void unknownChunkIdsAreSkippedButValidOnesApplied() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
byte[] pdfBytes = twoPagePdfBytes();
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk zero");
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0));
PdfCommentEngineResponse engineResponse =
new PdfCommentEngineResponse(
"session-2",
List.of(
new PdfCommentInstruction("p0-c0", "Valid", null, null),
new PdfCommentInstruction("p999-c999", "Bogus", null, null)),
"mixed");
when(aiEngineClient.post(anyString(), anyString()))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "test");
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
int totalAnnotations = 0;
for (int i = 0; i < saved.getNumberOfPages(); i++) {
totalAnnotations += textAnnotations(saved.getPage(i).getAnnotations()).size();
}
assertEquals(1, totalAnnotations, "Only the valid chunk annotation should be applied");
}
}
@Test
void emptyCommentsListReturnsDocumentWithoutAnnotations() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
byte[] pdfBytes = twoPagePdfBytes();
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
TextChunk c0 = new TextChunk("p0-c0", 0, 72f, 700f, 100f, 12f, "Chunk");
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of(c0));
PdfCommentEngineResponse engineResponse =
new PdfCommentEngineResponse("s", List.of(), "no comments worth making");
when(aiEngineClient.post(anyString(), anyString()))
.thenReturn(objectMapper.writeValueAsString(engineResponse));
AnnotatedPdf result = orchestrator.applyComments(input, "test");
assertEquals("doc-commented.pdf", result.fileName());
try (PDDocument saved = Loader.loadPDF(result.bytes())) {
for (int i = 0; i < saved.getNumberOfPages(); i++) {
assertTrue(
textAnnotations(saved.getPage(i).getAnnotations()).isEmpty(),
"Page " + i + " should have no text annotations");
}
}
}
@Test
void emptyChunksListThrowsBadRequestAndDoesNotCallEngine() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
byte[] pdfBytes = twoPagePdfBytes();
when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
when(pdfTextChunkExtractor.extract(any(PDDocument.class))).thenReturn(List.of());
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> orchestrator.applyComments(input, "whatever"));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString());
}
@Test
void promptTooLongThrowsBadRequestAndDoesNotCallEngine() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
String tooLong = "x".repeat(4001);
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> orchestrator.applyComments(input, tooLong));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString());
}
@Test
void blankPromptThrowsBadRequestAndDoesNotCallEngine() throws IOException {
MockMultipartFile input = pdf("doc.pdf");
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class,
() -> orchestrator.applyComments(input, " "));
assertEquals(400, ex.getStatusCode().value());
verify(aiEngineClient, never()).post(anyString(), anyString());
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private static MockMultipartFile pdf(String filename) {
return new MockMultipartFile(
"fileInput",
filename,
MediaType.APPLICATION_PDF_VALUE,
"%PDF-1.4\n%%EOF".getBytes());
}
private static byte[] twoPagePdfBytes() throws IOException {
try (PDDocument doc = new PDDocument()) {
for (int i = 0; i < 2; i++) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 700);
cs.showText("Page " + i + " content");
cs.endText();
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
List<PDAnnotationText> out = new ArrayList<>();
for (PDAnnotation a : annotations) {
if (a instanceof PDAnnotationText t) {
out.add(t);
}
}
return out;
}
}
@@ -0,0 +1,157 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.model.api.ai.comments.TextChunk;
/**
* Unit tests for {@link PdfTextChunkExtractor}. Exercises chunk id format, bounding-box validity,
* multi-page extraction, the empty-PDF path, and the 2000-chunk cap.
*/
class PdfTextChunkExtractorTest {
private static final Pattern CHUNK_ID_PATTERN = Pattern.compile("^p\\d+-c\\d+$");
private final PdfTextChunkExtractor extractor = new PdfTextChunkExtractor();
@Test
void extractsOneChunkPerVisualLineWithValidBoundingBoxes() throws IOException {
byte[] pdf = buildTwoPagePdf("Line A on page one", "Line B on page two");
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TextChunk> chunks = extractor.extract(doc);
assertFalse(chunks.isEmpty(), "Extractor should produce at least one chunk");
for (TextChunk chunk : chunks) {
assertTrue(
CHUNK_ID_PATTERN.matcher(chunk.id()).matches(),
"Chunk id should match p{page}-c{idx}, got: " + chunk.id());
assertTrue(chunk.width() > 0f, "width > 0, chunk=" + chunk);
assertTrue(chunk.height() > 0f, "height > 0, chunk=" + chunk);
assertFalse(chunk.text() == null || chunk.text().isBlank(), "text non-blank");
PDRectangle box = doc.getPage(chunk.page()).getMediaBox();
assertTrue(chunk.x() >= 0f, "x >= 0, chunk=" + chunk);
assertTrue(chunk.y() >= 0f, "y >= 0, chunk=" + chunk);
assertTrue(
chunk.x() + chunk.width() <= box.getWidth() + 0.01f,
"x + width fits within page width, chunk=" + chunk);
assertTrue(
chunk.y() + chunk.height() <= box.getHeight() + 0.01f,
"y + height fits within page height, chunk=" + chunk);
}
assertTrue(
chunks.stream().anyMatch(c -> c.page() == 0 && c.text().contains("Line A")),
"Expected a page-0 chunk containing 'Line A'; chunks=" + chunks);
assertTrue(
chunks.stream().anyMatch(c -> c.page() == 1 && c.text().contains("Line B")),
"Expected a page-1 chunk containing 'Line B'; chunks=" + chunks);
}
}
@Test
void returnsEmptyListForPdfWithNoExtractableText() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(PDRectangle.A4));
doc.addPage(new PDPage(PDRectangle.A4));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
byte[] pdf = baos.toByteArray();
try (PDDocument loaded = Loader.loadPDF(pdf)) {
List<TextChunk> chunks = extractor.extract(loaded);
assertTrue(chunks.isEmpty(), "Expected no chunks, got=" + chunks);
}
}
}
@Test
void enforcesHardCapOf2000Chunks() throws IOException {
byte[] pdf = buildPdfWithManyLines(2500);
try (PDDocument doc = Loader.loadPDF(pdf)) {
List<TextChunk> chunks = extractor.extract(doc);
assertEquals(
2000,
chunks.size(),
"Extractor should cap at MAX_CHUNKS_PER_DOC (2000); got=" + chunks.size());
}
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private static byte[] buildTwoPagePdf(String page1Text, String page2Text) throws IOException {
try (PDDocument doc = new PDDocument()) {
addPageWithLine(doc, page1Text);
addPageWithLine(doc, page2Text);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
private static void addPageWithLine(PDDocument doc, String text) throws IOException {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(72, 700);
cs.showText(text);
cs.endText();
}
}
/**
* Build a PDF with {@code totalLines} short lines of text spread across pages so the extractor
* has to produce one chunk per line.
*/
private static byte[] buildPdfWithManyLines(int totalLines) throws IOException {
int linesPerPage = 50;
try (PDDocument doc = new PDDocument()) {
int remaining = totalLines;
int lineCounter = 0;
while (remaining > 0) {
PDPage page = new PDPage(PDRectangle.A4);
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10);
cs.beginText();
cs.newLineAtOffset(72, 780);
int toWrite = Math.min(linesPerPage, remaining);
for (int i = 0; i < toWrite; i++) {
cs.showText("line-" + lineCounter++);
if (i < toWrite - 1) {
cs.newLineAtOffset(0, -14);
}
}
cs.endText();
}
remaining -= linesPerPage;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
}