Pdf comment agent (#6196)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-05-01 10:19:38 +01:00
committed by GitHub
co-authored by James Brunton
parent 2dc5276e8b
commit 86774d556e
78 changed files with 5091 additions and 112 deletions
@@ -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) {}
@@ -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<>();
@@ -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();
}
}
}