mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
co-authored by
EthanHealy01
parent
294b616a63
commit
575684ee4b
@@ -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) {}
|
||||
}
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
+118
-10
@@ -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→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→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<>();
|
||||
|
||||
+664
@@ -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"));
|
||||
}
|
||||
}
|
||||
+161
@@ -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→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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user