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,333 @@
package stirling.software.SPDF.controller.api;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
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.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.model.api.general.EditTextOperation;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.common.util.propertyeditor.StringToArrayListPropertyEditor;
/**
* Find/replace text editing for PDFs. Round-trips through {@link PdfJsonConversionService}: the
* input PDF is parsed into the editable JSON model, find/replace operations are applied to the
* {@code text} field of each text element, and the mutated model is rebuilt into a PDF.
*
* <p>Matching joins all text elements on a page into a single string before searching, so find
* strings can span multiple visual runs (titles split per word, kerning-broken phrases, etc.).
*
* <p>For cross-element matches the entire replacement is written into the first matched element,
* any intermediate elements are emptied, and the suffix of the last matched element is preserved.
* This anchors the new text at the leftmost match position and lets the font lay it out as one run,
* which is more visually reliable than redistributing words to the original per-element X positions
* (those positions are calibrated to the original word widths and rarely match the replacement
* widths). Centered or tracked layouts will become left-aligned at the original first-word position
* when their text is replaced - the trade-off is accepted in favour of avoiding glyph overlaps and
* gaps from variable-width word distribution.
*/
@Slf4j
@GeneralApi
@RequiredArgsConstructor
public class EditTextController {
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
private final PdfJsonConversionService pdfJsonConversionService;
private final TempFileManager tempFileManager;
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(
List.class,
"edits",
new StringToArrayListPropertyEditor<>(EditTextOperation.class));
}
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/edit-text")
@StandardPdfResponse
@Operation(
summary = "Edit text in a PDF via find and replace",
description =
"Applies an ordered list of find/replace operations to the text in a PDF and"
+ " returns the edited PDF. Useful for find-and-replace, bulk renames"
+ " (e.g. updating a company name throughout a document), and copy"
+ " editing where the AI agent has identified specific replacements."
+ " Matching is performed against the joined text of each page, so"
+ " find strings can span multiple visual runs (titles split per word,"
+ " kerning-broken phrases). Cross-element matches are written as a"
+ " single replacement run anchored at the leftmost matched position;"
+ " centered or tracked text may shift left when its content changes."
+ " Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> editText(@ModelAttribute EditTextRequest request)
throws Exception {
MultipartFile inputFile = request.getFileInput();
if (inputFile == null) {
throw ExceptionUtils.createFileNullOrEmptyException();
}
List<EditTextOperation> edits = request.getEdits();
if (edits == null || edits.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.editText.no.edits",
"No find/replace operations provided for text editing");
}
for (EditTextOperation edit : edits) {
if (edit == null || edit.getFind() == null || edit.getFind().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.editText.empty.find", "Each edit must have a non-empty find string");
}
}
boolean wholeWordSearch = Boolean.TRUE.equals(request.getWholeWordSearch());
List<CompiledEdit> compiledEdits = compileEdits(edits, wholeWordSearch);
PdfJsonDocument document = pdfJsonConversionService.convertPdfToJsonDocument(inputFile);
Set<Integer> pageFilter = resolvePageFilter(request, document);
int modifiedSpans = applyEdits(document, compiledEdits, pageFilter);
log.info(
"edit-text: modified {} text span(s) using {} edit(s) on {} page(s)",
modifiedSpans,
compiledEdits.size(),
pageFilter == null
? document.getPages() == null ? 0 : document.getPages().size()
: pageFilter.size());
String docName = buildOutputFilename(inputFile);
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
try (OutputStream os = Files.newOutputStream(tempOut.getPath())) {
pdfJsonConversionService.convertJsonToPdf(document, os);
} catch (Exception e) {
tempOut.close();
throw e;
}
return WebResponseUtils.pdfFileToWebResponse(tempOut, docName);
}
private List<CompiledEdit> compileEdits(
List<EditTextOperation> edits, boolean wholeWordSearch) {
return edits.stream().map(edit -> compileEdit(edit, wholeWordSearch)).toList();
}
private CompiledEdit compileEdit(EditTextOperation edit, boolean wholeWordSearch) {
// Always treat the user-supplied find string as a literal: Pattern.quote escapes any
// regex metacharacters, so the constructed pattern can only ever do a literal match
// (optionally bounded by our own word-boundary anchors). This rules out catastrophic
// backtracking from a malicious find string.
String findRaw = edit.getFind();
String replacement = Objects.toString(edit.getReplace(), "");
String regex = Pattern.quote(findRaw);
if (wholeWordSearch) {
// Use lookarounds rather than \b so the bound works even when the find string starts
// or ends with a non-word character (e.g. "-foo" or "$id"). \b only fires at a
// word/non-word transition, which would never trigger for a find that starts with a
// non-word char and was preceded by another non-word char in the source text.
regex = "(?<!\\w)(?:" + regex + ")(?!\\w)";
}
Pattern pattern = Pattern.compile(regex);
String safeReplacement = Matcher.quoteReplacement(replacement);
return new CompiledEdit(pattern, safeReplacement);
}
private Set<Integer> resolvePageFilter(EditTextRequest request, PdfJsonDocument document) {
String pageNumbers = request.getPageNumbers();
int totalPages = document.getPages() == null ? 0 : document.getPages().size();
if (totalPages == 0) {
return Collections.emptySet();
}
if (pageNumbers == null || pageNumbers.isBlank() || "all".equalsIgnoreCase(pageNumbers)) {
return null;
}
List<Integer> pages = GeneralUtils.parsePageList(pageNumbers, totalPages, true);
return new HashSet<>(pages);
}
private int applyEdits(
PdfJsonDocument document, List<CompiledEdit> edits, Set<Integer> pageFilter) {
List<PdfJsonPage> pages = document.getPages();
if (pages == null) {
return 0;
}
int modifiedSpans = 0;
// The page filter is built from parsePageList against pages.size(), so it returns 1-based
// positional page numbers. Match against the same positional numbering here rather than
// the per-page pageNumber field, which can become non-sequential after split/merge round
// trips and would misalign the filter.
for (int i = 0; i < pages.size(); i++) {
int positionalPageNumber = i + 1;
if (pageFilter != null && !pageFilter.contains(positionalPageNumber)) {
continue;
}
modifiedSpans += applyEditsToPage(pages.get(i), edits);
}
return modifiedSpans;
}
private int applyEditsToPage(PdfJsonPage page, List<CompiledEdit> edits) {
List<PdfJsonTextElement> elements = page.getTextElements();
if (elements == null || elements.isEmpty()) {
return 0;
}
Set<Integer> modifiedIndices = new HashSet<>();
for (CompiledEdit edit : edits) {
applyEditToPage(elements, edit, modifiedIndices);
}
for (Integer index : modifiedIndices) {
// Char codes were captured for the original glyph sequence; clear them so the rebuild
// re-encodes from the new text via the font.
elements.get(index).setCharCodes(null);
}
return modifiedIndices.size();
}
/**
* Apply a single edit across the page by matching against the concatenation of all element
* texts, then writing the replacement back into the originating element(s).
*/
private void applyEditToPage(
List<PdfJsonTextElement> elements, CompiledEdit edit, Set<Integer> modifiedIndices) {
StringBuilder joined = new StringBuilder();
int[] starts = new int[elements.size()];
int[] ends = new int[elements.size()];
for (int i = 0; i < elements.size(); i++) {
starts[i] = joined.length();
String text = elements.get(i).getText();
if (text != null) {
joined.append(text);
}
ends[i] = joined.length();
}
Matcher matcher = edit.pattern().matcher(joined);
List<MatchSpan> spans = new ArrayList<>();
StringBuffer interpolation = new StringBuffer();
int previousAppendPosition = 0;
while (matcher.find()) {
if (matcher.start() == matcher.end()) {
// Skip zero-length matches (e.g. /a*/ on empty input) — they cannot be applied.
continue;
}
int sizeBefore = interpolation.length();
matcher.appendReplacement(interpolation, edit.replacement());
int prefixLength = matcher.start() - previousAppendPosition;
String actualReplacement =
interpolation.substring(sizeBefore + prefixLength, interpolation.length());
spans.add(new MatchSpan(matcher.start(), matcher.end(), actualReplacement));
previousAppendPosition = matcher.end();
}
// Apply right-to-left so earlier match positions stay valid as we mutate elements.
for (int i = spans.size() - 1; i >= 0; i--) {
MatchSpan span = spans.get(i);
int firstElement = findElementForCharIndex(starts, ends, span.start());
int lastElement = findElementForCharIndex(starts, ends, span.end() - 1);
if (firstElement < 0 || lastElement < 0) {
continue;
}
applyMatchToElements(
elements, starts, span, firstElement, lastElement, modifiedIndices);
}
}
/**
* Find the element whose text covers the character at {@code charIndex} in the joined string.
* Returns -1 if no element covers that index (which should not happen for valid match spans).
*/
private static int findElementForCharIndex(int[] starts, int[] ends, int charIndex) {
for (int i = 0; i < starts.length; i++) {
if (starts[i] <= charIndex && charIndex < ends[i]) {
return i;
}
}
return -1;
}
private static void applyMatchToElements(
List<PdfJsonTextElement> elements,
int[] starts,
MatchSpan span,
int firstElement,
int lastElement,
Set<Integer> modifiedIndices) {
if (firstElement == lastElement) {
PdfJsonTextElement element = elements.get(firstElement);
String text = nullToEmpty(element.getText());
int matchStartInElement = span.start() - starts[firstElement];
int matchEndInElement = span.end() - starts[firstElement];
element.setText(
text.substring(0, matchStartInElement)
+ span.replacement()
+ text.substring(matchEndInElement));
modifiedIndices.add(firstElement);
return;
}
// Cross-element match: write the whole replacement into the first matched element, empty
// any intermediate elements, and keep only the suffix of the last matched element. The
// JSON->PDF rebuild concatenates per-token text, so the font lays out the replacement as
// one continuous run anchored at the first element's X position.
String firstText = nullToEmpty(elements.get(firstElement).getText());
int firstSplit = span.start() - starts[firstElement];
elements.get(firstElement).setText(firstText.substring(0, firstSplit) + span.replacement());
modifiedIndices.add(firstElement);
for (int mid = firstElement + 1; mid < lastElement; mid++) {
elements.get(mid).setText("");
modifiedIndices.add(mid);
}
String lastText = nullToEmpty(elements.get(lastElement).getText());
int lastSplit = span.end() - starts[lastElement];
elements.get(lastElement).setText(lastText.substring(lastSplit));
modifiedIndices.add(lastElement);
}
private static String nullToEmpty(String value) {
return value != null ? value : "";
}
private String buildOutputFilename(MultipartFile inputFile) {
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
: "document";
return baseName + "_edited.pdf";
}
private record CompiledEdit(Pattern pattern, String replacement) {}
private record MatchSpan(int start, int end, String replacement) {}
}
@@ -96,7 +96,9 @@ public class RedactController {
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(
List.class, "redactions", new StringToArrayListPropertyEditor());
List.class,
"redactions",
new StringToArrayListPropertyEditor<>(RedactionArea.class));
}
@AutoJobPostMapping(value = "/redact", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -0,0 +1,32 @@
package stirling.software.SPDF.model.api.general;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.model.api.general.EditTextOperation;
@Data
@EqualsAndHashCode(callSuper = true)
public class EditTextRequest extends PDFWithPageNums {
@Schema(
description =
"Ordered list of find/replace operations. Each replaces every occurrence on"
+ " the selected pages, in order; later operations see the result of"
+ " earlier ones (so 'foo'->'foos' then 'foos'->'bars' turns 'foo'"
+ " into 'bars').",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<EditTextOperation> edits;
@Schema(
description =
"Whether matches must be whole words (boundaries determined by non-word"
+ " characters)",
defaultValue = "false")
private Boolean wholeWordSearch;
}
@@ -252,6 +252,17 @@ public class PdfJsonConversionService {
convertPdfToJson(file, null, false, out);
}
/**
* Converts a PDF to the editable {@link PdfJsonDocument} model. Convenience wrapper around
* {@link #convertPdfToJson(MultipartFile, OutputStream)} for callers that need to inspect or
* mutate the document in memory before round-tripping it back to PDF.
*/
public PdfJsonDocument convertPdfToJsonDocument(MultipartFile file) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
convertPdfToJson(file, null, false, buffer);
return objectMapper.readValue(buffer.toByteArray(), PdfJsonDocument.class);
}
public void convertPdfToJson(MultipartFile file, boolean lightweight, OutputStream out)
throws IOException {
convertPdfToJson(file, null, lightweight, out);
@@ -620,6 +631,13 @@ public class PdfJsonConversionService {
}
byte[] jsonBytes = file.getBytes();
PdfJsonDocument pdfJson = objectMapper.readValue(jsonBytes, PdfJsonDocument.class);
convertJsonToPdf(pdfJson, out);
}
public void convertJsonToPdf(PdfJsonDocument pdfJson, OutputStream out) throws IOException {
if (pdfJson == null) {
throw ExceptionUtils.createNullArgumentException("document");
}
List<PdfJsonFont> fontModels = pdfJson.getFonts();
if (fontModels == null) {
@@ -2076,14 +2094,29 @@ public class PdfJsonConversionService {
byte[] toUnicodeBytes = Base64.getDecoder().decode(toUnicodeBase64);
String toUnicodeStr = new String(toUnicodeBytes, StandardCharsets.UTF_8);
// Parse ToUnicode CMap for bfchar and bfrange
// Parse ToUnicode CMap for bfchar and bfrange. Both sides go through
// parseToUnicodeCodepoint so an 8-hex-char surrogate-pair value on either side
// (e.g. "D837DF0E") is decoded as a single supplementary codepoint instead of
// overflowing Integer.parseInt and aborting the whole mapping build.
java.util.regex.Pattern bfcharPattern =
java.util.regex.Pattern.compile("<([0-9A-Fa-f]+)>\\s*<([0-9A-Fa-f]+)>");
java.util.regex.Matcher matcher = bfcharPattern.matcher(toUnicodeStr);
while (matcher.find()) {
int charCode = Integer.parseInt(matcher.group(1), 16);
int unicode = Integer.parseInt(matcher.group(2), 16);
charCodeToUnicode.put(charCode, unicode);
try {
int charCode = parseToUnicodeCodepoint(matcher.group(1));
int unicode = parseToUnicodeCodepoint(matcher.group(2));
charCodeToUnicode.put(charCode, unicode);
} catch (NumberFormatException entryEx) {
// Tolerate a single malformed entry: log and skip rather than aborting the
// entire ToUnicode CMap (which would force the whole font onto the slow
// raw-bytes fallback path).
log.debug(
"Skipping malformed ToUnicode entry <{}> <{}> in font {}: {}",
matcher.group(1),
matcher.group(2),
font.getName(),
entryEx.getMessage());
}
}
// Build JSON mapping: CharCode CID GID Unicode
@@ -2136,6 +2169,42 @@ public class PdfJsonConversionService {
}
}
/**
* Parse a hex string from a PDF ToUnicode CMap into a single Unicode codepoint. Handles three
* cases: a single BMP code unit (4 hex chars), a UTF-16 surrogate pair encoding a supplementary
* codepoint above U+FFFF (8 hex chars, e.g. {@code D837DF0E} for U+1F40E), and multi-codepoint
* mappings (longer; returns the first codepoint as a best-effort representative).
*
* <p>Without this, {@code Integer.parseInt("D837DF0E", 16)} overflows because the value is ~3.6
* billion, throwing {@link NumberFormatException} and forcing the conversion to fall back to a
* raw ToUnicode payload that the JSON&rarr;PDF rebuild then fails to use efficiently.
*/
static int parseToUnicodeCodepoint(String hex) {
if (hex == null || hex.isEmpty()) {
throw new NumberFormatException("Empty ToUnicode hex value");
}
if (hex.length() <= 4) {
return Integer.parseInt(hex, 16);
}
// Treat the hex string as UTF-16BE: pairs of hex digits form bytes, four hex digits form
// one UTF-16 code unit. The PDF ToUnicode CMap convention requires an even number of bytes
// (i.e. a multiple of four hex characters) for multi-unit values.
if (hex.length() % 4 != 0) {
throw new NumberFormatException(
"ToUnicode hex value not a multiple of 4 chars: " + hex);
}
int unitCount = hex.length() / 4;
char[] units = new char[unitCount];
for (int i = 0; i < unitCount; i++) {
units[i] = (char) Integer.parseInt(hex.substring(i * 4, i * 4 + 4), 16);
}
// codePointAt assembles a surrogate pair into a supplementary codepoint when the
// high/low surrogates appear in sequence; for any other multi-unit sequence it returns
// the first BMP codepoint, which is the right best-effort fallback for ligature
// decompositions (one charCode -> several Unicode chars).
return new String(units).codePointAt(0);
}
private PdfJsonFontCidSystemInfo extractCidSystemInfo(COSDictionary fontDictionary) {
if (fontDictionary == null) {
return null;
@@ -4256,12 +4325,8 @@ public class PdfJsonConversionService {
return 0;
}
if (font != null) {
try (InputStream inputStream = new ByteArrayInputStream(value.getBytes())) {
int count = 0;
int code;
while ((code = font.readCode(inputStream)) != -1) {
count++;
}
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(value.getBytes())) {
int count = countCodesProtected(inputStream, font::readCode);
if (count > 0) {
return count;
}
@@ -4273,6 +4338,49 @@ public class PdfJsonConversionService {
return Math.max(1, bytes.length);
}
/**
* Functional accessor for {@link PDFont#readCode(InputStream)} so the bounded counting loop can
* be exercised in isolation without instantiating a {@link PDFont}.
*/
@FunctionalInterface
interface CodeReader {
int readCode(InputStream stream) throws IOException;
}
/**
* Count how many codes the supplied {@code reader} can extract from {@code inputStream}, with
* two safety nets that PDFBox's raw {@link PDFont#readCode(InputStream)} loop lacks:
*
* <ol>
* <li>Stop when the stream is empty (a corrupt CMap can otherwise loop forever returning
* successfully-matched zero-bytes from an exhausted {@link ByteArrayInputStream}).
* <li>Stop when a {@code readCode} call did not consume any bytes, even if it returned a
* non-{@code -1} value.
* </ol>
*
* <p>Both conditions were observed in the wild on round-tripped fallback fonts where the
* embedded ToUnicode CMap matched 0x00 sequences, hanging the JSON&rarr;PDF rebuild.
*/
static int countCodesProtected(ByteArrayInputStream inputStream, CodeReader reader)
throws IOException {
int count = 0;
int previousAvailable = inputStream.available();
while (previousAvailable > 0) {
int code = reader.readCode(inputStream);
if (code == -1) {
break;
}
int currentAvailable = inputStream.available();
if (currentAvailable >= previousAvailable) {
// No progress made; break to avoid infinite loop on corrupt CMaps.
break;
}
count++;
previousAvailable = currentAvailable;
}
return count;
}
private MergedText mergeText(List<PdfJsonTextElement> elements) {
StringBuilder builder = new StringBuilder();
List<Integer> combinedCodes = new ArrayList<>();