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
+174
@@ -0,0 +1,174 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
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.misc.AddCommentsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.model.api.comments.AnnotationLocation;
|
||||
import stirling.software.common.model.api.comments.StickyNoteSpec;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfAnnotationService;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfTextLocator;
|
||||
import stirling.software.common.util.PdfTextLocator.MatchedBox;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Deterministic Java tool: add sticky-note comments to a PDF at caller-supplied positions.
|
||||
* Composable primitive used by AI agents (that generate comment specs) and by any other caller —
|
||||
* Automate workflows, scripts, unit tests — that has comment positions and text in hand.
|
||||
*
|
||||
* <p>Each {@code CommentSpec} element accepts either absolute coordinates ({@code x, y, width,
|
||||
* height}) or an {@code anchorText} hint. When {@code anchorText} is present, the tool scans the
|
||||
* target page, finds the first line whose text contains the needle (tolerant match — case and
|
||||
* punctuation insensitive), and anchors the sticky-note icon at that line's bounding box. Falls
|
||||
* back to the supplied coordinates when no match is found.
|
||||
*
|
||||
* <p>Pairs with {@link PdfAnnotationService} (annotation creation) and {@link PdfTextLocator}
|
||||
* (anchor resolution).
|
||||
*/
|
||||
@Slf4j
|
||||
@MiscApi
|
||||
@RequiredArgsConstructor
|
||||
public class AddCommentsController {
|
||||
|
||||
/** Sticky-note icon size in PDF user-space units. Matches the agents' default. */
|
||||
private static final float ANCHOR_ICON_SIZE = 20f;
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PdfAnnotationService pdfAnnotationService;
|
||||
private final PdfTextLocator pdfTextLocator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@AutoJobPostMapping(value = "/add-comments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Add sticky-note comments to a PDF at specified positions or anchored text",
|
||||
description =
|
||||
"Attaches PDF Text (sticky-note) annotations to the document."
|
||||
+ " Each CommentSpec can either supply absolute coordinates or an"
|
||||
+ " `anchorText` hint; when provided, the tool locates the first matching"
|
||||
+ " line on the target page and anchors the icon there (falling back to"
|
||||
+ " the coordinates if no match). Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<Resource> addComments(@ModelAttribute AddCommentsRequest request)
|
||||
throws IOException {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "fileInput is required");
|
||||
}
|
||||
String commentsJson = request.getComments();
|
||||
if (commentsJson == null || commentsJson.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "comments JSON is required");
|
||||
}
|
||||
|
||||
List<CommentSpecDto> dtos;
|
||||
try {
|
||||
dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {});
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects");
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
List<StickyNoteSpec> specs = resolveSpecs(document, dtos);
|
||||
pdfAnnotationService.addStickyNotes(document, specs);
|
||||
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try {
|
||||
document.save(tempOut.getFile());
|
||||
} catch (IOException e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(
|
||||
tempOut,
|
||||
GeneralUtils.generateFilename(file.getOriginalFilename(), "_commented.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the wire DTOs into {@link StickyNoteSpec}s, resolving any {@code anchorText} hints
|
||||
* against the PDF. Each spec is resolved independently so a miss falls back locally without
|
||||
* affecting other specs.
|
||||
*/
|
||||
private List<StickyNoteSpec> resolveSpecs(PDDocument document, List<CommentSpecDto> dtos) {
|
||||
List<StickyNoteSpec> specs = new ArrayList<>(dtos.size());
|
||||
for (CommentSpecDto dto : dtos) {
|
||||
specs.add(toSpec(document, dto));
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
private StickyNoteSpec toSpec(PDDocument document, CommentSpecDto d) {
|
||||
AnnotationLocation location = resolveLocation(document, d);
|
||||
return new StickyNoteSpec(location, d.text, d.author, d.subject);
|
||||
}
|
||||
|
||||
private AnnotationLocation resolveLocation(PDDocument document, CommentSpecDto d) {
|
||||
if (d.anchorText == null || d.anchorText.isBlank()) {
|
||||
return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
|
||||
}
|
||||
Optional<MatchedBox> match = pdfTextLocator.findOnPage(document, d.pageIndex, d.anchorText);
|
||||
if (match.isEmpty()) {
|
||||
log.debug(
|
||||
"add-comments: no match for anchorText {!r} on page {}; using fallback coords",
|
||||
d.anchorText,
|
||||
d.pageIndex);
|
||||
return new AnnotationLocation(d.pageIndex, d.x, d.y, d.width, d.height);
|
||||
}
|
||||
MatchedBox box = match.get();
|
||||
// Anchor the icon at the top-left of the matched line, matching the convention used by
|
||||
// PdfCommentAgentOrchestrator for its chunk-based placement.
|
||||
float iconX = box.x();
|
||||
float iconY = box.y() + box.height() - ANCHOR_ICON_SIZE;
|
||||
return new AnnotationLocation(
|
||||
d.pageIndex, iconX, iconY, ANCHOR_ICON_SIZE, ANCHOR_ICON_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format DTO for a single element in the {@code comments} JSON array. Flat record-like
|
||||
* shape keeps the JSON simple for humans, AI-engine plan parameters, and Automate steps alike.
|
||||
*
|
||||
* <p>{@code anchorText} is optional. When present, the server locates the first line on {@code
|
||||
* pageIndex} containing that text (tolerant match) and places the icon there; the {@code
|
||||
* x/y/width/height} act as fallback when no match is found.
|
||||
*/
|
||||
private static final class CommentSpecDto {
|
||||
public int pageIndex;
|
||||
public float x;
|
||||
public float y;
|
||||
public float width;
|
||||
public float height;
|
||||
public String text;
|
||||
public String author;
|
||||
public String subject;
|
||||
public String anchorText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/v1/misc/add-comments}.
|
||||
*
|
||||
* <p>The {@code comments} field is a JSON-encoded array of {@code CommentSpec} objects rather than
|
||||
* a nested multipart part so the endpoint stays compatible with {@code InternalApiClient}'s flat
|
||||
* multipart form body (orchestrator plan dispatch). Jackson parses it controller-side.
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AddCommentsRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"JSON array of comment specs. Each element has: {pageIndex, x, y, width,"
|
||||
+ " height, text, author?, subject?}. Coordinates are PDF user-space with"
|
||||
+ " origin at the page's bottom-left.",
|
||||
example =
|
||||
"[{\"pageIndex\":0,\"x\":72,\"y\":720,\"width\":20,\"height\":20,"
|
||||
+ "\"text\":\"Check this paragraph\",\"author\":\"Reviewer\","
|
||||
+ "\"subject\":\"Unclear wording\"}]",
|
||||
requiredMode = RequiredMode.REQUIRED)
|
||||
private String comments;
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
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.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddCommentsRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfAnnotationService;
|
||||
import stirling.software.common.util.PdfTextLocator;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AddCommentsControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
private PdfAnnotationService pdfAnnotationService;
|
||||
private PdfTextLocator pdfTextLocator;
|
||||
private ObjectMapper objectMapper;
|
||||
private AddCommentsController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
pdfAnnotationService = new PdfAnnotationService();
|
||||
pdfTextLocator = new PdfTextLocator();
|
||||
objectMapper = JsonMapper.builder().build();
|
||||
controller =
|
||||
new AddCommentsController(
|
||||
pdfDocumentFactory,
|
||||
tempFileManager,
|
||||
pdfAnnotationService,
|
||||
pdfTextLocator,
|
||||
objectMapper);
|
||||
|
||||
lenient()
|
||||
.when(tempFileManager.createManagedTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
File file =
|
||||
Files.createTempFile(tempDir, "addcomments", ".pdf").toFile();
|
||||
TempFile tf = org.mockito.Mockito.mock(TempFile.class);
|
||||
lenient().when(tf.getPath()).thenReturn(file.toPath());
|
||||
lenient().when(tf.getFile()).thenReturn(file);
|
||||
return tf;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesEachCommentSpecAsStickyNote() throws Exception {
|
||||
MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
request.setComments(
|
||||
"""
|
||||
[{"pageIndex":0,"x":72,"y":700,"width":20,"height":20,"text":"First","author":"me","subject":"S1"},
|
||||
{"pageIndex":1,"x":100,"y":650,"width":20,"height":20,"text":"Second"}]
|
||||
""");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
byte[] result = drainBody(response);
|
||||
try (PDDocument reloaded = Loader.loadPDF(result)) {
|
||||
List<PDAnnotationText> p0 = textAnnotations(reloaded.getPage(0).getAnnotations());
|
||||
List<PDAnnotationText> p1 = textAnnotations(reloaded.getPage(1).getAnnotations());
|
||||
assertThat(p0).hasSize(1);
|
||||
assertThat(p1).hasSize(1);
|
||||
assertThat(p0.get(0).getContents()).isEqualTo("First");
|
||||
assertThat(p1.get(0).getContents()).isEqualTo("Second");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anchorsStickyNoteAtLocatedTextWhenAnchorTextMatches() throws Exception {
|
||||
byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
|
||||
MockMultipartFile file = pdf("doc.pdf", pdfBytes);
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
// Fallback coords deliberately far from the line so we can tell which path ran.
|
||||
request.setComments(
|
||||
"""
|
||||
[{"pageIndex":0,"x":10,"y":10,"width":5,"height":5,
|
||||
"text":"Check this total","author":"tester","subject":"S",
|
||||
"anchorText":"215000"}]
|
||||
""");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
|
||||
List<PDAnnotationText> notes = textAnnotations(reloaded.getPage(0).getAnnotations());
|
||||
assertThat(notes).hasSize(1);
|
||||
PDRectangle rect = notes.get(0).getRectangle();
|
||||
// Line was drawn at user-space y=720 with font size 12; icon should land in that band,
|
||||
// not at the fallback y=10. Width/height fixed to 20 by the anchor path.
|
||||
assertThat(rect.getWidth()).isEqualTo(20f);
|
||||
assertThat(rect.getHeight()).isEqualTo(20f);
|
||||
assertThat(rect.getLowerLeftY()).isBetween(700f, 740f);
|
||||
assertThat(rect.getLowerLeftX()).isGreaterThan(50f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToAbsoluteCoordsWhenAnchorTextMisses() throws Exception {
|
||||
byte[] pdfBytes = singlePagePdfWithLine("Revenue: $215,000");
|
||||
MockMultipartFile file = pdf("doc.pdf", pdfBytes);
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
request.setComments(
|
||||
"""
|
||||
[{"pageIndex":0,"x":55,"y":33,"width":7,"height":9,
|
||||
"text":"No match","anchorText":"not-on-this-page"}]
|
||||
""");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
|
||||
List<PDAnnotationText> notes = textAnnotations(reloaded.getPage(0).getAnnotations());
|
||||
assertThat(notes).hasSize(1);
|
||||
PDRectangle rect = notes.get(0).getRectangle();
|
||||
assertThat(rect.getLowerLeftX()).isEqualTo(55f);
|
||||
assertThat(rect.getLowerLeftY()).isEqualTo(33f);
|
||||
assertThat(rect.getWidth()).isEqualTo(7f);
|
||||
assertThat(rect.getHeight()).isEqualTo(9f);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankCommentsJson() {
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
|
||||
request.setComments("");
|
||||
|
||||
assertThatThrownBy(() -> controller.addComments(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidJson() {
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(pdf("doc.pdf", new byte[] {1, 2, 3}));
|
||||
request.setComments("not-json");
|
||||
|
||||
assertThatThrownBy(() -> controller.addComments(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingFileInput() {
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setComments("[]");
|
||||
|
||||
assertThatThrownBy(() -> controller.addComments(request))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsSuccessForEmptyCommentsArray() throws Exception {
|
||||
// An empty JSON array is a valid payload — nothing to annotate, but the caller
|
||||
// should still get back the input PDF without any error so pipelines that
|
||||
// produce zero comments don't have to special-case the empty result.
|
||||
MockMultipartFile file = pdf("doc.pdf", twoPagePdfBytes());
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF(file.getBytes()));
|
||||
|
||||
AddCommentsRequest request = new AddCommentsRequest();
|
||||
request.setFileInput(file);
|
||||
request.setComments("[]");
|
||||
|
||||
ResponseEntity<Resource> response = controller.addComments(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
try (PDDocument reloaded = Loader.loadPDF(drainBody(response))) {
|
||||
assertThat(textAnnotations(reloaded.getPage(0).getAnnotations())).isEmpty();
|
||||
assertThat(textAnnotations(reloaded.getPage(1).getAnnotations())).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static MockMultipartFile pdf(String name, byte[] bytes) {
|
||||
return new MockMultipartFile("fileInput", name, MediaType.APPLICATION_PDF_VALUE, bytes);
|
||||
}
|
||||
|
||||
private static byte[] twoPagePdfBytes() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] singlePagePdfWithLine(String line) 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);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(72f, 720f);
|
||||
cs.showText(line);
|
||||
cs.endText();
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (java.io.InputStream is = response.getBody().getInputStream()) {
|
||||
is.transferTo(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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user