mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Pdf comment agent (#6196)
Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
2dc5276e8b
commit
86774d556e
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.common.model.api.comments;
|
||||
|
||||
/**
|
||||
* Absolute position of a PDF annotation in the document.
|
||||
*
|
||||
* <p>Coordinates are in PDF user-space with the origin at the page's bottom-left, consistent with
|
||||
* PDFBox's {@code PDRectangle} convention.
|
||||
*
|
||||
* @param pageIndex 0-indexed page number the annotation lives on.
|
||||
* @param x bottom-left x coordinate of the annotation rectangle.
|
||||
* @param y bottom-left y coordinate of the annotation rectangle.
|
||||
* @param width width of the annotation rectangle, in user-space units.
|
||||
* @param height height of the annotation rectangle, in user-space units.
|
||||
*/
|
||||
public record AnnotationLocation(int pageIndex, float x, float y, float width, float height) {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.common.model.api.comments;
|
||||
|
||||
/**
|
||||
* Description of a single sticky-note (PDF Text) annotation to place on a document.
|
||||
*
|
||||
* <p>{@code author} and {@code subject} are optional — callers that pass {@code null} get a default
|
||||
* author/subject from {@code PdfAnnotationService}.
|
||||
*
|
||||
* @param location where to anchor the annotation icon, in PDF user-space.
|
||||
* @param text the comment body shown in the popup (required, non-blank).
|
||||
* @param author optional author label shown in the popup; {@code null} → service default.
|
||||
* @param subject optional subject line shown in the popup; {@code null} → service default.
|
||||
*/
|
||||
public record StickyNoteSpec(
|
||||
AnnotationLocation location, String text, String author, String subject) {}
|
||||
@@ -34,11 +34,17 @@ import stirling.software.common.util.TempFileManager;
|
||||
@Slf4j
|
||||
public class InternalApiClient {
|
||||
|
||||
// Allowlist for internal dispatch. Matches a fixed namespace prefix,
|
||||
// Allowlist for internal dispatch. Matches fixed namespace prefixes,
|
||||
// but rejects traversal (..), URL-encoding (%), query/fragment, backslashes, and any other
|
||||
// character that could alter the resolved endpoint on the local Spring server.
|
||||
//
|
||||
// The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are
|
||||
// dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is
|
||||
// intentionally NOT permitted to avoid plan steps re-entering the orchestrator.
|
||||
private static final Pattern ALLOWED_ENDPOINT_PATH =
|
||||
Pattern.compile("^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$");
|
||||
Pattern.compile(
|
||||
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
|
||||
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
|
||||
|
||||
private final ServletContext servletContext;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
|
||||
/**
|
||||
* Shared primitive for adding sticky-note (PDF Text) annotations to a document.
|
||||
*
|
||||
* <p>Used by:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code /api/v1/misc/add-comments} — a deterministic, reusable tool.
|
||||
* <li>AI-agent flows that generate comment specs (e.g. PDF review agent, math auditor review
|
||||
* mode) and hand them off to this service for deterministic placement.
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PdfAnnotationService {
|
||||
|
||||
/** Yellow sticky-note fill colour (R, G, B in 0..1 range). */
|
||||
private static final float[] STICKY_NOTE_COLOR_RGB = {1f, 0.95f, 0.4f};
|
||||
|
||||
/** Opacity for the sticky-note icon. */
|
||||
private static final float ANNOTATION_OPACITY = 0.9f;
|
||||
|
||||
/** PDF Text-annotation icon name — {@code "Comment"} is one of the standard icons. */
|
||||
private static final String ANNOTATION_ICON_NAME = "Comment";
|
||||
|
||||
/** Default subject shown in the annotation popup when a spec does not supply one. */
|
||||
private static final String DEFAULT_SUBJECT = "Stirling AI Comment";
|
||||
|
||||
/** Default author label shown in the annotation popup when a spec does not supply one. */
|
||||
private static final String DEFAULT_AUTHOR = "Stirling AI";
|
||||
|
||||
/**
|
||||
* Cap on sticky-note text length. PDF annotation bodies can technically be much longer, but
|
||||
* anything beyond this is almost certainly pathological (accidental document-dump or malicious
|
||||
* payload) and would bloat the output file.
|
||||
*/
|
||||
private static final int MAX_COMMENT_TEXT_LENGTH = 100_000;
|
||||
|
||||
/**
|
||||
* Add a list of sticky notes to {@code doc}. Specs that reference an out-of-range page or
|
||||
* contain blank text are logged and skipped; this method never throws for a single bad spec.
|
||||
*
|
||||
* @return the number of annotations actually applied
|
||||
*/
|
||||
public int addStickyNotes(PDDocument doc, List<StickyNoteSpec> specs) {
|
||||
if (specs == null || specs.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int totalPages = doc.getNumberOfPages();
|
||||
Calendar now = Calendar.getInstance();
|
||||
int applied = 0;
|
||||
for (int i = 0; i < specs.size(); i++) {
|
||||
StickyNoteSpec spec = specs.get(i);
|
||||
if (!isValid(spec, totalPages, i)) {
|
||||
continue;
|
||||
}
|
||||
apply(doc, spec, now);
|
||||
applied++;
|
||||
}
|
||||
if (applied < specs.size()) {
|
||||
log.warn(
|
||||
"Applied {}/{} sticky notes; {} skipped due to invalid specs.",
|
||||
applied,
|
||||
specs.size(),
|
||||
specs.size() - applied);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single sticky note. Convenience wrapper; prefer {@link #addStickyNotes(PDDocument,
|
||||
* List)} when placing multiple annotations so log output is batched.
|
||||
*/
|
||||
public void addStickyNote(PDDocument doc, StickyNoteSpec spec) {
|
||||
addStickyNotes(doc, List.of(spec));
|
||||
}
|
||||
|
||||
private boolean isValid(StickyNoteSpec spec, int totalPages, int index) {
|
||||
if (spec == null || spec.location() == null) {
|
||||
log.warn("Skipping sticky-note[{}]: spec or location is null.", index);
|
||||
return false;
|
||||
}
|
||||
if (spec.text() == null || spec.text().isBlank()) {
|
||||
log.warn("Skipping sticky-note[{}]: text is blank.", index);
|
||||
return false;
|
||||
}
|
||||
if (spec.text().length() > MAX_COMMENT_TEXT_LENGTH) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: text length {} exceeds limit {}.",
|
||||
index,
|
||||
spec.text().length(),
|
||||
MAX_COMMENT_TEXT_LENGTH);
|
||||
return false;
|
||||
}
|
||||
AnnotationLocation loc = spec.location();
|
||||
if (loc.width() <= 0f || loc.height() <= 0f) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: non-positive dimensions width={} height={}.",
|
||||
index,
|
||||
loc.width(),
|
||||
loc.height());
|
||||
return false;
|
||||
}
|
||||
int page = loc.pageIndex();
|
||||
if (page < 0 || page >= totalPages) {
|
||||
log.warn(
|
||||
"Skipping sticky-note[{}]: pageIndex={} out of range [0, {}).",
|
||||
index,
|
||||
page,
|
||||
totalPages);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void apply(PDDocument doc, StickyNoteSpec spec, Calendar now) {
|
||||
AnnotationLocation loc = spec.location();
|
||||
|
||||
PDAnnotationText annot = new PDAnnotationText();
|
||||
annot.setContents(spec.text());
|
||||
annot.setRectangle(new PDRectangle(loc.x(), loc.y(), loc.width(), loc.height()));
|
||||
annot.setSubject(nonBlankOr(spec.subject(), DEFAULT_SUBJECT));
|
||||
annot.setTitlePopup(nonBlankOr(spec.author(), DEFAULT_AUTHOR));
|
||||
annot.setColor(new PDColor(STICKY_NOTE_COLOR_RGB, PDDeviceRGB.INSTANCE));
|
||||
annot.setCreationDate(now);
|
||||
annot.setConstantOpacity(ANNOTATION_OPACITY);
|
||||
annot.getCOSObject().setName(COSName.NAME, ANNOTATION_ICON_NAME);
|
||||
|
||||
try {
|
||||
doc.getPage(loc.pageIndex()).getAnnotations().add(annot);
|
||||
} catch (java.io.IOException e) {
|
||||
log.warn(
|
||||
"Failed to attach sticky note to page {}: {}", loc.pageIndex(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String nonBlankOr(String value, String fallback) {
|
||||
return value != null && !value.isBlank() ? value : fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Locate text on a specific PDF page and return its bounding box in PDF user-space (bottom-left
|
||||
* origin). Used by tools that receive "anchor by text" hints — e.g. {@code
|
||||
* /api/v1/misc/add-comments} when callers supply an {@code anchorText} instead of explicit
|
||||
* coordinates.
|
||||
*
|
||||
* <p>Matching is tolerant: case-insensitive with punctuation/whitespace stripped on both sides, so
|
||||
* a caller-supplied needle of {@code "215000"} matches page text {@code "$215,000"}, and {@code
|
||||
* "Total Revenue"} matches {@code "Total Revenue."}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PdfTextLocator {
|
||||
|
||||
/** One found line of text with its user-space bounding box. */
|
||||
public record MatchedBox(float x, float y, float width, float height) {}
|
||||
|
||||
/**
|
||||
* Find the first line on {@code pageIndex} (0-indexed) whose text contains {@code needle} under
|
||||
* the tolerant match. Returns empty when no match, when the page index is out of range, or when
|
||||
* the needle is blank.
|
||||
*/
|
||||
public Optional<MatchedBox> findOnPage(PDDocument doc, int pageIndex, String needle) {
|
||||
if (doc == null
|
||||
|| needle == null
|
||||
|| needle.isBlank()
|
||||
|| pageIndex < 0
|
||||
|| pageIndex >= doc.getNumberOfPages()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String normalizedNeedle = normalize(needle);
|
||||
if (normalizedNeedle.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<CapturedLine> lines = new ArrayList<>();
|
||||
LineCapturingStripper stripper;
|
||||
try {
|
||||
stripper = new LineCapturingStripper(lines);
|
||||
stripper.setStartPage(pageIndex + 1);
|
||||
stripper.setEndPage(pageIndex + 1);
|
||||
stripper.setSortByPosition(true);
|
||||
// Side effect: populates `lines`. We don't need the concatenated text.
|
||||
stripper.getText(doc);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"PdfTextLocator failed to extract text on page {}: {}",
|
||||
pageIndex,
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
PDRectangle mediaBox = doc.getPage(pageIndex).getMediaBox();
|
||||
float pageHeight = mediaBox.getHeight();
|
||||
|
||||
for (CapturedLine line : lines) {
|
||||
if (normalize(line.text).contains(normalizedNeedle)) {
|
||||
// PDFBox's *DirAdj coords descend from the top of the page; convert to PDF
|
||||
// user-space (origin = bottom-left) so the bbox can feed a PDRectangle directly.
|
||||
float userSpaceY = pageHeight - line.yTopDown - line.height;
|
||||
return Optional.of(new MatchedBox(line.x, userSpaceY, line.width, line.height));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Strip everything non-alphanumeric and lowercase for tolerant matching. */
|
||||
private static String normalize(String s) {
|
||||
return s.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class CapturedLine {
|
||||
String text;
|
||||
float x;
|
||||
float yTopDown;
|
||||
float width;
|
||||
float height;
|
||||
}
|
||||
|
||||
private static final class LineCapturingStripper extends PDFTextStripper {
|
||||
private final List<CapturedLine> lines;
|
||||
|
||||
LineCapturingStripper(List<CapturedLine> sink) throws IOException {
|
||||
super();
|
||||
this.lines = sink;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String text, List<TextPosition> textPositions)
|
||||
throws IOException {
|
||||
if (textPositions != null && !textPositions.isEmpty()) {
|
||||
CapturedLine line = new CapturedLine();
|
||||
line.text = text;
|
||||
|
||||
float minX = Float.MAX_VALUE;
|
||||
float maxRight = 0f;
|
||||
float minY = Float.MAX_VALUE;
|
||||
float maxHeight = 0f;
|
||||
for (TextPosition p : textPositions) {
|
||||
float x = p.getXDirAdj();
|
||||
float y = p.getYDirAdj();
|
||||
float w = p.getWidthDirAdj();
|
||||
float h = p.getHeightDir();
|
||||
if (h == 0f) {
|
||||
// Workaround: some fonts report 0 height via TextPosition; fall back to
|
||||
// the nominal font size so downstream bboxes are never zero-height.
|
||||
h = p.getFontSizeInPt();
|
||||
}
|
||||
if (x < minX) minX = x;
|
||||
if (x + w > maxRight) maxRight = x + w;
|
||||
if (y < minY) minY = y;
|
||||
if (h > maxHeight) maxHeight = h;
|
||||
}
|
||||
line.x = minX;
|
||||
line.width = maxRight - minX;
|
||||
line.yTopDown = minY;
|
||||
line.height = maxHeight;
|
||||
lines.add(line);
|
||||
}
|
||||
super.writeString(text, textPositions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,49 @@ class InternalApiClientTest {
|
||||
assertThrows(SecurityException.class, () -> client.post("/api/v1/admin/settings", body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsAiEndpointsOutsideToolsSubnamespace() {
|
||||
// /api/v1/ai/orchestrate and other non-tool AI endpoints are not internally
|
||||
// dispatchable. Only /api/v1/ai/tools/* and the general/misc/security/convert/filter
|
||||
// namespaces are on the allowlist — letting a plan step re-enter /orchestrate would
|
||||
// introduce recursion risk.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
assertThrows(SecurityException.class, () -> client.post("/api/v1/ai/orchestrate", body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postAcceptsAiToolsSubnamespace() throws Exception {
|
||||
// Agent tool paths like /api/v1/ai/tools/pdf-comment-agent are on the allowlist and
|
||||
// should be dispatchable by the orchestrator's plan executor.
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("fileInput", namedResource("input.pdf", "data"));
|
||||
|
||||
Path tempPath = Files.createTempFile("internal-api-ai-tools-test", ".tmp");
|
||||
TempFile tempFile = mock(TempFile.class);
|
||||
when(tempFile.getPath()).thenReturn(tempPath);
|
||||
when(tempFile.getFile()).thenReturn(tempPath.toFile());
|
||||
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
|
||||
|
||||
try (var ignored =
|
||||
mockConstruction(
|
||||
RestTemplate.class,
|
||||
(rt, ctx) -> {
|
||||
when(rt.httpEntityCallback(any(), eq(Resource.class)))
|
||||
.thenReturn((RequestCallback) req -> {});
|
||||
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
|
||||
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
|
||||
})) {
|
||||
|
||||
ResponseEntity<Resource> response =
|
||||
client.post("/api/v1/ai/tools/pdf-comment-agent", body);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
} finally {
|
||||
Files.deleteIfExists(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postRejectsPathTraversal() {
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
|
||||
class PdfAnnotationServiceTest {
|
||||
|
||||
private PdfAnnotationService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PdfAnnotationService();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addStickyNotesPlacesOneAnnotationPerValidSpec() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "First comment", "alice", null),
|
||||
spec(1, 100f, 650f, "Second comment", null, "Second"));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(2, applied);
|
||||
byte[] saved = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
assertEquals(1, textAnnotations(reloaded.getPage(0).getAnnotations()).size());
|
||||
assertEquals(1, textAnnotations(reloaded.getPage(1).getAnnotations()).size());
|
||||
|
||||
PDAnnotationText first =
|
||||
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
|
||||
assertEquals("First comment", first.getContents());
|
||||
assertEquals("alice", first.getTitlePopup(), "author override propagates");
|
||||
assertNotNull(first.getSubject(), "subject falls back to default when null");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithBlankText() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "Valid", null, null),
|
||||
spec(0, 72f, 680f, " ", null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Blank-text spec must be skipped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithOutOfRangePageIndex() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "OK", null, null),
|
||||
spec(99, 72f, 700f, "Too far", null, null),
|
||||
spec(-1, 72f, 700f, "Negative", null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Only the in-range spec should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesNullAndEmptySpecList() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
assertEquals(0, service.addStickyNotes(doc, null));
|
||||
assertEquals(0, service.addStickyNotes(doc, List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithNonPositiveDimensions() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
StickyNoteSpec zeroWidth =
|
||||
new StickyNoteSpec(
|
||||
new AnnotationLocation(0, 72f, 700f, 0f, 20f),
|
||||
"Zero width",
|
||||
null,
|
||||
null);
|
||||
StickyNoteSpec negativeHeight =
|
||||
new StickyNoteSpec(
|
||||
new AnnotationLocation(0, 72f, 680f, 20f, -5f),
|
||||
"Negative height",
|
||||
null,
|
||||
null);
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(spec(0, 72f, 660f, "OK", null, null), zeroWidth, negativeHeight);
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Only the positively-sized spec should be applied");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsSpecsWithOverlongText() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
String overlong = "x".repeat(100_001);
|
||||
List<StickyNoteSpec> specs =
|
||||
List.of(
|
||||
spec(0, 72f, 700f, "Short", null, null),
|
||||
spec(0, 72f, 680f, overlong, null, null));
|
||||
|
||||
int applied = service.addStickyNotes(doc, specs);
|
||||
|
||||
assertEquals(1, applied, "Overlong-text spec must be skipped");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesDefaultAuthorAndSubjectWhenAbsent() throws IOException {
|
||||
byte[] bytes = twoPagePdfBytes();
|
||||
try (PDDocument doc = Loader.loadPDF(bytes)) {
|
||||
service.addStickyNote(doc, spec(0, 72f, 700f, "No author given", null, null));
|
||||
|
||||
byte[] saved = save(doc);
|
||||
try (PDDocument reloaded = Loader.loadPDF(saved)) {
|
||||
PDAnnotationText annot =
|
||||
textAnnotations(reloaded.getPage(0).getAnnotations()).get(0);
|
||||
assertTrue(
|
||||
annot.getTitlePopup() != null && !annot.getTitlePopup().isBlank(),
|
||||
"Default author should be applied");
|
||||
assertTrue(
|
||||
annot.getSubject() != null && !annot.getSubject().isBlank(),
|
||||
"Default subject should be applied");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static StickyNoteSpec spec(
|
||||
int page, float x, float y, String text, String author, String subject) {
|
||||
return new StickyNoteSpec(
|
||||
new AnnotationLocation(page, x, y, 20f, 20f), text, author, subject);
|
||||
}
|
||||
|
||||
private static byte[] twoPagePdfBytes() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
return save(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] save(PDDocument doc) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private static List<PDAnnotationText> textAnnotations(List<PDAnnotation> annotations) {
|
||||
List<PDAnnotationText> out = new ArrayList<>();
|
||||
for (PDAnnotation a : annotations) {
|
||||
if (a instanceof PDAnnotationText t) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.util.PdfTextLocator.MatchedBox;
|
||||
|
||||
class PdfTextLocatorTest {
|
||||
|
||||
private final PdfTextLocator locator = new PdfTextLocator();
|
||||
|
||||
@Test
|
||||
void findsLineContainingNeedleAndReturnsUserSpaceBox() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Revenue: $215,000", "Expenses: $120,000"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "215000");
|
||||
assertThat(match).isPresent();
|
||||
MatchedBox box = match.get();
|
||||
// Line was drawn at y=720 in user-space (bottom-left origin); locator
|
||||
// should return a bbox close to that height band with non-zero width.
|
||||
assertThat(box.width()).isGreaterThan(0f);
|
||||
assertThat(box.height()).isGreaterThan(0f);
|
||||
assertThat(box.y()).isBetween(700f, 740f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchIsCaseAndPunctuationInsensitive() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Total Revenue.", "Q4 summary"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "total revenue");
|
||||
assertThat(match).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyWhenNeedleNotFound() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Nothing to see here"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
Optional<MatchedBox> match = locator.findOnPage(doc, 0, "not-on-this-page");
|
||||
assertThat(match).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyForBlankNeedle() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Any text"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertThat(locator.findOnPage(doc, 0, "")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 0, " ")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 0, null)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyForOutOfRangePage() throws Exception {
|
||||
byte[] pdf = pdfWithLines(new String[] {"Single page"});
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
assertThat(locator.findOnPage(doc, -1, "single")).isEmpty();
|
||||
assertThat(locator.findOnPage(doc, 99, "single")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pdfWithLines(String[] lines) throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
cs.setNonStrokingColor(Color.BLACK);
|
||||
float y = 720f;
|
||||
for (String line : lines) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, y);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
y -= 20f;
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user