Add edit text support to stirling engine (#6245)

# Description of Changes
Hooks up the (alpha) PDF Editor backend to the AI engine Edit Agent via
an intermediary API which is easier for the agent to call. It suffers
from all the same issues that the PDF Editor does in actually editing
the text, but should also benefit from any fixes to that.

It also adds protection against the underlying tools misbehaving by
hanging, and fixes a hanging bug in the PDF Editor.

---------

Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
James Brunton
2026-05-11 09:57:41 +00:00
committed by GitHub
co-authored by EthanHealy01
parent 294b616a63
commit 575684ee4b
17 changed files with 1937 additions and 52 deletions
@@ -0,0 +1,664 @@
package stirling.software.SPDF.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
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.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.SPDF.model.api.general.EditTextRequest;
import stirling.software.SPDF.model.json.PdfJsonDocument;
import stirling.software.SPDF.model.json.PdfJsonPage;
import stirling.software.SPDF.model.json.PdfJsonTextElement;
import stirling.software.SPDF.service.PdfJsonConversionService;
import stirling.software.common.model.api.general.EditTextOperation;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class EditTextControllerTest {
@Mock private PdfJsonConversionService pdfJsonConversionService;
@Mock private TempFileManager tempFileManager;
@InjectMocks private EditTextController controller;
@BeforeEach
void setUp() throws Exception {
lenient()
.when(tempFileManager.createManagedTempFile(anyString()))
.thenAnswer(
inv -> {
File f =
Files.createTempFile("test", inv.<String>getArgument(0))
.toFile();
TempFile tf = mock(TempFile.class);
lenient().when(tf.getFile()).thenReturn(f);
lenient().when(tf.getPath()).thenReturn(f.toPath());
return tf;
});
}
private static MultipartFile pdfFile() {
return new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "stub-pdf-bytes".getBytes());
}
private static EditTextOperation edit(String find, String replace) {
EditTextOperation op = new EditTextOperation();
op.setFind(find);
op.setReplace(replace);
return op;
}
private static PdfJsonTextElement textElement(String text) {
PdfJsonTextElement el = new PdfJsonTextElement();
el.setText(text);
el.setCharCodes(new int[] {1, 2, 3});
return el;
}
private static PdfJsonDocument documentWithElements(List<PdfJsonTextElement> elements) {
PdfJsonDocument doc = new PdfJsonDocument();
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(1);
page.setTextElements(new ArrayList<>(elements));
doc.setPages(new ArrayList<>(List.of(page)));
return doc;
}
private static PdfJsonDocument documentWithText(String... textsByPage) {
PdfJsonDocument doc = new PdfJsonDocument();
List<PdfJsonPage> pages = new ArrayList<>();
for (int i = 0; i < textsByPage.length; i++) {
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(i + 1);
PdfJsonTextElement el = new PdfJsonTextElement();
el.setText(textsByPage[i]);
el.setCharCodes(new int[] {1, 2, 3});
page.setTextElements(new ArrayList<>(List.of(el)));
pages.add(page);
}
doc.setPages(pages);
return doc;
}
/** Build a minimal real PDF so tests can run end-to-end without mocking the conversion. */
private static byte[] buildEmptyPdf() throws Exception {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
doc.addPage(new PDPage());
doc.save(baos);
return baos.toByteArray();
}
}
@Test
void editText_nullFileInputThrows() {
EditTextRequest request = new EditTextRequest();
request.setFileInput(null);
request.setEdits(List.of(edit("foo", "bar")));
assertThrows(IllegalArgumentException.class, () -> controller.editText(request));
}
@Test
void editText_emptyEditsThrows() {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of());
assertThrows(IllegalArgumentException.class, () -> controller.editText(request));
}
@Test
void editText_nullEditsThrows() {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(null);
assertThrows(IllegalArgumentException.class, () -> controller.editText(request));
}
@Test
void editText_emptyFindStringThrows() {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("", "replacement")));
assertThrows(IllegalArgumentException.class, () -> controller.editText(request));
}
@Test
void editText_findStringWithRegexMetacharsIsTreatedLiterally() throws Exception {
// Find strings are always treated as literals (Pattern.quote'd internally) — no ReDoS
// exposure from regex metacharacters supplied by the caller.
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("(unclosed", "fixed")));
PdfJsonDocument input = documentWithText("text with (unclosed paren");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
assertEquals(
"text with fixed paren",
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
}
@Test
void editText_literalFindReplace_mutatesMatchingSpansAndClearsCharCodes() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("foo", "bar")));
PdfJsonDocument input = documentWithText("foo and foo", "no match here");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
ResponseEntity<Resource> response = controller.editText(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
PdfJsonDocument mutated = captor.getValue();
assertEquals("bar and bar", mutated.getPages().get(0).getTextElements().get(0).getText());
assertNull(mutated.getPages().get(0).getTextElements().get(0).getCharCodes());
assertEquals("no match here", mutated.getPages().get(1).getTextElements().get(0).getText());
// Char codes preserved on unmodified spans.
assertNotNull(mutated.getPages().get(1).getTextElements().get(0).getCharCodes());
}
@Test
void editText_wholeWordSearch_doesNotMatchInsideWords() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("cat", "dog")));
request.setWholeWordSearch(true);
PdfJsonDocument input = documentWithText("the cat is in the catalogue");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
assertEquals(
"the dog is in the catalogue",
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
}
@Test
void editText_wholeWordSearch_matchesFindStartingWithNonWordChar() throws Exception {
// Regression: \b only fires on a word/non-word transition. A find that starts with a
// non-word char (e.g. "-foo") preceded by another non-word char in the source (a space)
// would never match under \b. The lookaround-based bound handles this correctly.
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("-foo", "-bar")));
request.setWholeWordSearch(true);
PdfJsonDocument input = documentWithText("space then -foo here");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
assertEquals(
"space then -bar here",
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
}
@Test
void editText_wholeWordSearch_doesNotMatchWhenAdjacentToWordChar() throws Exception {
// The lookaround bound must still reject matches that are part of a larger word.
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("-foo", "-bar")));
request.setWholeWordSearch(true);
PdfJsonDocument input = documentWithText("inline-foo should not match");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
// "inline-foo" has 'e' (word) before '-foo' so the lookbehind blocks the match. The
// trailing 'o' is followed by a space (non-word) so the trailing lookahead would pass on
// its own; the leading lookbehind is what rejects it.
assertEquals(
"inline-foo should not match",
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
}
@Test
void editText_pageFilter_onlyAffectsListedPages() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("foo", "bar")));
request.setPageNumbers("2");
PdfJsonDocument input = documentWithText("foo on page 1", "foo on page 2", "foo on page 3");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
PdfJsonDocument mutated = captor.getValue();
assertEquals("foo on page 1", mutated.getPages().get(0).getTextElements().get(0).getText());
assertEquals("bar on page 2", mutated.getPages().get(1).getTextElements().get(0).getText());
assertEquals("foo on page 3", mutated.getPages().get(2).getTextElements().get(0).getText());
}
@Test
void editText_pageRange_appliesToAllPagesInRange() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("foo", "bar")));
request.setPageNumbers("2-3");
PdfJsonDocument input =
documentWithText("foo page 1", "foo page 2", "foo page 3", "foo page 4");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
PdfJsonDocument mutated = captor.getValue();
assertTrue(mutated.getPages().get(0).getTextElements().get(0).getText().startsWith("foo"));
assertTrue(mutated.getPages().get(1).getTextElements().get(0).getText().startsWith("bar"));
assertTrue(mutated.getPages().get(2).getTextElements().get(0).getText().startsWith("bar"));
assertTrue(mutated.getPages().get(3).getTextElements().get(0).getText().startsWith("foo"));
}
@Test
void editText_orderedEdits_applyInSequence() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("foo", "bar"), edit("bar", "baz")));
PdfJsonDocument input = documentWithText("foo");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
assertEquals("baz", captor.getValue().getPages().get(0).getTextElements().get(0).getText());
}
@Test
void editText_replaceWithEmptyString_deletesMatch() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("DRAFT ", "")));
PdfJsonDocument input = documentWithText("DRAFT Confidential Memo");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
assertEquals(
"Confidential Memo",
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
}
@Test
void editText_noMatches_returnsPdfWithoutMutation() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("notfound", "replacement")));
PdfJsonDocument input = documentWithText("nothing to match");
int[] originalCodes = input.getPages().get(0).getTextElements().get(0).getCharCodes();
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
ResponseEntity<Resource> response = controller.editText(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
// Char codes left intact when nothing was replaced.
assertEquals(
originalCodes, input.getPages().get(0).getTextElements().get(0).getCharCodes());
assertEquals(
"nothing to match", input.getPages().get(0).getTextElements().get(0).getText());
}
@Test
void editText_dollarInLiteralReplacement_isQuoted() throws Exception {
// Without quoting, '$1' would be interpreted as a backreference and crash.
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("price", "$100")));
PdfJsonDocument input = documentWithText("the price is set");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
assertEquals(
"the $100 is set",
captor.getValue().getPages().get(0).getTextElements().get(0).getText());
}
@Test
void editText_emptyDocument_returnsResponseWithoutErrors() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("foo", "bar")));
PdfJsonDocument input = new PdfJsonDocument();
input.setPages(new ArrayList<>());
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
ResponseEntity<Resource> response = controller.editText(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
}
@Test
void editText_textElementWithNullText_isSkipped() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("foo", "bar")));
PdfJsonDocument input = new PdfJsonDocument();
PdfJsonPage page = new PdfJsonPage();
page.setPageNumber(1);
PdfJsonTextElement nullText = new PdfJsonTextElement();
nullText.setText(null);
PdfJsonTextElement realText = new PdfJsonTextElement();
realText.setText("foo here");
page.setTextElements(new ArrayList<>(List.of(nullText, realText)));
input.setPages(new ArrayList<>(List.of(page)));
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
PdfJsonDocument mutated = captor.getValue();
assertNull(mutated.getPages().get(0).getTextElements().get(0).getText());
assertEquals("bar here", mutated.getPages().get(0).getTextElements().get(1).getText());
}
@Test
void editText_crossElement_matchSpansTwoElements() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("Hello World", "Goodbye Earth")));
PdfJsonDocument input =
documentWithElements(List.of(textElement("Hello "), textElement("World")));
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
// Whole replacement lands in the first matched element; the second is emptied. The
// JSON->PDF rebuild concatenates per-token text so the font lays out the replacement as
// one run anchored at the first element's X position.
assertEquals("Goodbye Earth", elements.get(0).getText());
assertEquals("", elements.get(1).getText());
assertNull(elements.get(0).getCharCodes());
assertNull(elements.get(1).getCharCodes());
}
@Test
void editText_crossElement_matchSpansFiveElementsLikeFragmentedTitle() throws Exception {
// Reproduces the real-world case: a multi-word title fragmented one word per text span.
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(
List.of(edit("The Free Adobe Acrobat Alternative", "The PDF automation pipeline")));
PdfJsonDocument input =
documentWithElements(
List.of(
textElement("The "),
textElement("Free "),
textElement("Adobe "),
textElement("Acrobat "),
textElement("Alternative")));
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
// Whole replacement is written into the first matched element; the remaining four are
// emptied. Centered titles will become left-aligned at the original first-word X position.
assertEquals("The PDF automation pipeline", elements.get(0).getText());
for (int i = 1; i < elements.size(); i++) {
assertEquals("", elements.get(i).getText(), "element " + i + " should be empty");
}
}
@Test
void editText_crossElement_preservesPrefixAndSuffix() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("Hello World", "Goodbye Earth")));
PdfJsonDocument input =
documentWithElements(
List.of(textElement("Greeting: Hello "), textElement("World! And more")));
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
// First element keeps its prefix and gets the entire replacement; last element keeps its
// suffix only.
assertEquals("Greeting: Goodbye Earth", elements.get(0).getText());
assertEquals("! And more", elements.get(1).getText());
}
@Test
void editText_matchInOneElementOfMany_onlyTouchesThatElement() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("World", "Earth")));
PdfJsonDocument input =
documentWithElements(
List.of(
textElement("Hello "),
textElement("World!"),
textElement(" Goodbye")));
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
assertEquals("Hello ", elements.get(0).getText());
assertEquals("Earth!", elements.get(1).getText());
assertEquals(" Goodbye", elements.get(2).getText());
// Only the modified element's char codes get cleared.
assertNotNull(elements.get(0).getCharCodes());
assertNull(elements.get(1).getCharCodes());
assertNotNull(elements.get(2).getCharCodes());
}
@Test
void editText_crossElement_multipleMatchesAppliedRightToLeft() throws Exception {
// Two matches in the same page text. Right-to-left application keeps earlier indices
// valid as later matches are written.
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("foo bar", "X")));
PdfJsonDocument input =
documentWithElements(
List.of(
textElement("foo "),
textElement("bar baz "),
textElement("foo "),
textElement("bar")));
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
// Joined was "foo bar baz foo bar"; both "foo bar" runs replaced with "X".
StringBuilder joined = new StringBuilder();
for (PdfJsonTextElement el : elements) {
joined.append(el.getText() == null ? "" : el.getText());
}
assertEquals("X baz X", joined.toString());
}
@Test
void editText_subWordFragmentation_writesIntoFirstElement() throws Exception {
// When the matched text is split into many character-level spans (typical of Type3 glyph
// runs), the whole replacement is dumped into the first matched element and the rest are
// emptied. The font lays out the replacement as one run at the first glyph's X position.
EditTextRequest request = new EditTextRequest();
request.setFileInput(pdfFile());
request.setEdits(List.of(edit("Hello World", "Goodbye Earth")));
// 11 sub-word elements covering "Hello World".
PdfJsonDocument input =
documentWithElements(
List.of(
textElement("H"),
textElement("e"),
textElement("l"),
textElement("l"),
textElement("o"),
textElement(" "),
textElement("W"),
textElement("o"),
textElement("r"),
textElement("l"),
textElement("d")));
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
controller.editText(request);
ArgumentCaptor<PdfJsonDocument> captor = ArgumentCaptor.forClass(PdfJsonDocument.class);
org.mockito.Mockito.verify(pdfJsonConversionService)
.convertJsonToPdf(captor.capture(), any(OutputStream.class));
List<PdfJsonTextElement> elements = captor.getValue().getPages().get(0).getTextElements();
// Entire replacement goes into the first matched element; the other 10 are emptied.
assertEquals("Goodbye Earth", elements.get(0).getText());
for (int i = 1; i < elements.size(); i++) {
assertEquals("", elements.get(i).getText(), "element " + i + " should be empty");
}
}
@Test
void editText_outputFilenameDerivedFromInput() throws Exception {
EditTextRequest request = new EditTextRequest();
request.setFileInput(
new MockMultipartFile(
"fileInput", "report.pdf", "application/pdf", buildEmptyPdf()));
request.setEdits(List.of(edit("anything", "x")));
PdfJsonDocument input = documentWithText("nothing matches");
when(pdfJsonConversionService.convertPdfToJsonDocument(any(MultipartFile.class)))
.thenReturn(input);
ResponseEntity<Resource> response = controller.editText(request);
String contentDisposition =
response.getHeaders()
.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(contentDisposition);
assertTrue(contentDisposition.contains("report_edited.pdf"));
assertFalse(contentDisposition.contains(".pdf_edited.pdf"));
}
}
@@ -0,0 +1,161 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link PdfJsonConversionService#parseToUnicodeCodepoint(String)}.
*
* <p>The function exists because PDF ToUnicode CMap entries can encode supplementary-plane
* codepoints (above U+FFFF) as UTF-16 surrogate pairs, e.g. {@code <D837DF0E>} for U+1F40E. The
* naive implementation that called {@link Integer#parseInt(String, int)} on the whole hex string
* threw {@link NumberFormatException} for any 8-char value because they overflow {@code int}. That
* triggered a fallback that left fonts with no proper CID mapping and made downstream JSON&rarr;PDF
* rebuilds slow/hung when rendering text with those fonts.
*/
class PdfJsonConversionServiceUnicodeParsingTest {
@Test
void parsesSingleBmpCodeUnit() {
// Latin capital A.
assertEquals(0x41, PdfJsonConversionService.parseToUnicodeCodepoint("0041"));
}
@Test
void parsesShortBmpHexValue() {
// Some ToUnicode entries omit leading zeros for low codepoints.
assertEquals(0x41, PdfJsonConversionService.parseToUnicodeCodepoint("41"));
}
@Test
void parsesSupplementaryCodepointFromSurrogatePair() {
// U+1F40E HORSE encoded as UTF-16 surrogate pair D83D DC0E. The bug we are fixing was
// that Integer.parseInt("D83DDC0E", 16) overflows because D83DDC0E > Integer.MAX_VALUE.
assertEquals(0x1F40E, PdfJsonConversionService.parseToUnicodeCodepoint("D83DDC0E"));
}
@Test
void parsesSurrogatePairFromUserReportedHang() {
// The exact hex value from the user's hang reproducer log:
// "Failed to build Unicode mapping for font NotoSans-Regular: For input string:
// \"D837DF0E\" under radix 16"
// D837 DF0E decodes to U+1DF0E (CJK supplementary). Important assertion: it returns a
// valid codepoint instead of overflowing Integer.parseInt and throwing.
int expected = new String(new char[] {(char) 0xD837, (char) 0xDF0E}).codePointAt(0);
assertEquals(0x1DF0E, expected); // sanity check on the test setup itself
assertEquals(expected, PdfJsonConversionService.parseToUnicodeCodepoint("D837DF0E"));
}
@Test
void parsesLigatureDecompositionAsFirstCodepoint() {
// A ToUnicode entry can map one charCode to multiple Unicode chars (a ligature). PDF
// spec allows e.g. <0041 0042> for "AB". Our best-effort behavior is to return the
// first codepoint so the mapping is at least roughly meaningful for search/copy.
assertEquals(0x41, PdfJsonConversionService.parseToUnicodeCodepoint("00410042"));
}
@Test
void rejectsEmptyHex() {
assertThrows(
NumberFormatException.class,
() -> PdfJsonConversionService.parseToUnicodeCodepoint(""));
}
@Test
void rejectsNullHex() {
assertThrows(
NumberFormatException.class,
() -> PdfJsonConversionService.parseToUnicodeCodepoint(null));
}
@Test
void rejectsOddByteCountAboveBmp() {
// 6 hex chars is 3 bytes — not a valid UTF-16BE sequence.
assertThrows(
NumberFormatException.class,
() -> PdfJsonConversionService.parseToUnicodeCodepoint("D83DDC"));
}
@Test
void countCodesProtectedTerminatesWhenReaderMakesNoProgress() {
// Reproduces the user's hang: PDFBox's CMap.readCode can return a successful code (0)
// from a stream where no bytes were consumed (corrupt codespace matching 0x00 bytes from
// the buffer's uninitialized region after EOF). Without the no-progress guard, the
// counting loop in countGlyphs ran forever.
ByteArrayInputStream stream = new ByteArrayInputStream(new byte[] {1, 2, 3, 4});
PdfJsonConversionService.CodeReader reader = in -> 0; // never reads, always "succeeds"
int count =
assertTimeoutPreemptively(
Duration.ofSeconds(2),
() -> PdfJsonConversionService.countCodesProtected(stream, reader));
// First iteration sees no progress and breaks immediately.
assertEquals(0, count);
}
@Test
void countCodesProtectedTerminatesOnEmptyStream() {
ByteArrayInputStream stream = new ByteArrayInputStream(new byte[0]);
PdfJsonConversionService.CodeReader reader =
in -> {
throw new AssertionError("reader must not be called when stream is empty");
};
int count =
assertTimeoutPreemptively(
Duration.ofSeconds(2),
() -> PdfJsonConversionService.countCodesProtected(stream, reader));
assertEquals(0, count);
}
@Test
void countCodesProtectedHonorsExplicitMinusOneReturn() throws IOException {
ByteArrayInputStream stream = new ByteArrayInputStream(new byte[] {1, 2, 3});
PdfJsonConversionService.CodeReader reader =
in -> {
int b = in.read();
return b == -1 ? -1 : b;
};
int count = PdfJsonConversionService.countCodesProtected(stream, reader);
assertEquals(3, count);
}
@Test
void countCodesProtectedTerminatesIfReaderReadsThenStops() throws IOException {
// A reader that consumes one byte then hits a corrupt-CMap pattern returning 0 without
// consuming further must still terminate after counting the consumed bytes.
ByteArrayInputStream stream = new ByteArrayInputStream(new byte[] {1, 2, 3, 4});
PdfJsonConversionService.CodeReader reader =
new PdfJsonConversionService.CodeReader() {
boolean firstCall = true;
@Override
public int readCode(InputStream in) throws IOException {
if (firstCall) {
firstCall = false;
return in.read();
}
return 0; // simulates corrupt CMap thereafter
}
};
int count =
assertTimeoutPreemptively(
Duration.ofSeconds(2),
() -> PdfJsonConversionService.countCodesProtected(stream, reader));
assertEquals(1, count);
}
}