feat(personal): render bounded PDF OCR pages
This commit is contained in:
@@ -54,6 +54,12 @@
|
||||
<version>3.2.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.pdfbox</groupId>
|
||||
<artifactId>pdfbox</artifactId>
|
||||
<version>3.0.5</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package org.dromara.aihr.personal.service;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class PersonalPdfPageRenderer {
|
||||
|
||||
public static final int MAX_PAGES = 200;
|
||||
public static final int BATCH_SIZE = 20;
|
||||
public static final int MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||
private static final int MAX_IMAGE_EDGE = 2200;
|
||||
private static final float RENDER_DPI = 144F;
|
||||
private static final float JPEG_QUALITY = 0.82F;
|
||||
|
||||
public int pageCount(byte[] pdfBytes) {
|
||||
try (PDDocument document = load(pdfBytes)) {
|
||||
return requireSupportedPageCount(document.getNumberOfPages());
|
||||
} catch (PdfPageLimitException exception) {
|
||||
throw exception;
|
||||
} catch (IOException exception) {
|
||||
throw new PdfRenderException("PERSONAL_PDF_INVALID", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public int requireSupportedPageCount(int pageCount) {
|
||||
if (pageCount < 1) {
|
||||
throw new PdfRenderException("PERSONAL_PDF_EMPTY");
|
||||
}
|
||||
if (pageCount > MAX_PAGES) {
|
||||
throw new PdfPageLimitException(pageCount);
|
||||
}
|
||||
return pageCount;
|
||||
}
|
||||
|
||||
public List<RenderedPage> render(byte[] pdfBytes, int startPageIndex, int batchSize) {
|
||||
if (startPageIndex < 0 || batchSize < 1 || batchSize > BATCH_SIZE) {
|
||||
throw new IllegalArgumentException("invalid PDF OCR batch bounds");
|
||||
}
|
||||
try (PDDocument document = load(pdfBytes)) {
|
||||
int pageCount = requireSupportedPageCount(document.getNumberOfPages());
|
||||
if (startPageIndex >= pageCount) {
|
||||
return List.of();
|
||||
}
|
||||
PDFRenderer renderer = new PDFRenderer(document);
|
||||
int end = Math.min(pageCount, startPageIndex + batchSize);
|
||||
List<RenderedPage> pages = new ArrayList<>(end - startPageIndex);
|
||||
for (int pageIndex = startPageIndex; pageIndex < end; pageIndex++) {
|
||||
BufferedImage image = renderer.renderImageWithDPI(pageIndex, RENDER_DPI, ImageType.RGB);
|
||||
pages.add(new RenderedPage(pageIndex, encodeBoundedJpeg(scaleToLimit(image)), "image/jpeg"));
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
} catch (PdfPageLimitException exception) {
|
||||
throw exception;
|
||||
} catch (IOException exception) {
|
||||
throw new PdfRenderException("PERSONAL_PDF_RENDER_FAILED", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PDDocument load(byte[] pdfBytes) throws IOException {
|
||||
if (pdfBytes == null || pdfBytes.length == 0) {
|
||||
throw new PdfRenderException("PERSONAL_PDF_EMPTY");
|
||||
}
|
||||
return Loader.loadPDF(pdfBytes);
|
||||
}
|
||||
|
||||
private BufferedImage scaleToLimit(BufferedImage source) {
|
||||
int longest = Math.max(source.getWidth(), source.getHeight());
|
||||
if (longest <= MAX_IMAGE_EDGE) {
|
||||
return source;
|
||||
}
|
||||
double ratio = (double) MAX_IMAGE_EDGE / longest;
|
||||
return scale(source, Math.max(1, (int) Math.round(source.getWidth() * ratio)),
|
||||
Math.max(1, (int) Math.round(source.getHeight() * ratio)));
|
||||
}
|
||||
|
||||
private byte[] encodeBoundedJpeg(BufferedImage source) throws IOException {
|
||||
BufferedImage current = source;
|
||||
for (int attempt = 0; attempt < 4; attempt++) {
|
||||
byte[] encoded = encodeJpeg(current, Math.max(0.55F, JPEG_QUALITY - attempt * 0.08F));
|
||||
if (encoded.length <= MAX_IMAGE_BYTES) {
|
||||
return encoded;
|
||||
}
|
||||
current = scale(current, Math.max(1, current.getWidth() * 3 / 4),
|
||||
Math.max(1, current.getHeight() * 3 / 4));
|
||||
}
|
||||
throw new PdfRenderException("PERSONAL_PDF_PAGE_TOO_LARGE");
|
||||
}
|
||||
|
||||
private byte[] encodeJpeg(BufferedImage image, float quality) throws IOException {
|
||||
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
|
||||
if (!writers.hasNext()) {
|
||||
throw new PdfRenderException("PERSONAL_PDF_JPEG_UNAVAILABLE");
|
||||
}
|
||||
ImageWriter writer = writers.next();
|
||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
ImageOutputStream imageOutput = ImageIO.createImageOutputStream(output)) {
|
||||
writer.setOutput(imageOutput);
|
||||
ImageWriteParam params = writer.getDefaultWriteParam();
|
||||
params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||
params.setCompressionQuality(quality);
|
||||
writer.write(null, new IIOImage(image, null, null), params);
|
||||
return output.toByteArray();
|
||||
} finally {
|
||||
writer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage scale(BufferedImage source, int width, int height) {
|
||||
BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = target.createGraphics();
|
||||
try {
|
||||
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
graphics.drawImage(source, 0, 0, width, height, null);
|
||||
} finally {
|
||||
graphics.dispose();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
public record RenderedPage(int pageIndex, byte[] bytes, String mimeType) {
|
||||
}
|
||||
|
||||
public static class PdfRenderException extends RuntimeException {
|
||||
private final String code;
|
||||
|
||||
public PdfRenderException(String code) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public PdfRenderException(String code, Throwable cause) {
|
||||
super(code, cause);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class PdfPageLimitException extends PdfRenderException {
|
||||
private final int actualPages;
|
||||
|
||||
public PdfPageLimitException(int actualPages) {
|
||||
super("PERSONAL_PDF_PAGE_LIMIT");
|
||||
this.actualPages = actualPages;
|
||||
}
|
||||
|
||||
public int actualPages() {
|
||||
return actualPages;
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package org.dromara.aihr.personal;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.dromara.aihr.personal.service.PersonalPdfPageRenderer;
|
||||
import org.dromara.aihr.personal.service.PersonalPdfPageRenderer.PdfPageLimitException;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@Tag("dev")
|
||||
class PersonalPdfPageRendererTest {
|
||||
|
||||
private final PersonalPdfPageRenderer renderer = new PersonalPdfPageRenderer();
|
||||
|
||||
@Test
|
||||
void countsPagesAndRendersOnlyRequestedBatch() throws Exception {
|
||||
byte[] pdf = pdfWithPages(8);
|
||||
|
||||
assertEquals(8, renderer.pageCount(pdf));
|
||||
List<PersonalPdfPageRenderer.RenderedPage> pages = renderer.render(pdf, 0, 2);
|
||||
|
||||
assertEquals(List.of(0, 1), pages.stream().map(PersonalPdfPageRenderer.RenderedPage::pageIndex).toList());
|
||||
assertTrue(pages.stream().allMatch(page -> "image/jpeg".equals(page.mimeType())));
|
||||
assertTrue(pages.stream().allMatch(page -> page.bytes().length > 0));
|
||||
assertTrue(pages.stream().allMatch(page -> page.bytes().length <= PersonalPdfPageRenderer.MAX_IMAGE_BYTES));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPdfAboveHardPageLimit() {
|
||||
assertThrows(PdfPageLimitException.class, () -> renderer.requireSupportedPageCount(201));
|
||||
assertEquals(200, renderer.requireSupportedPageCount(200));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidBatchBounds() throws Exception {
|
||||
byte[] pdf = pdfWithPages(2);
|
||||
assertThrows(IllegalArgumentException.class, () -> renderer.render(pdf, -1, 1));
|
||||
assertThrows(IllegalArgumentException.class, () -> renderer.render(pdf, 0, 21));
|
||||
}
|
||||
|
||||
private byte[] pdfWithPages(int count) throws Exception {
|
||||
try (PDDocument document = new PDDocument(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
for (int index = 0; index < count; index++) {
|
||||
document.addPage(new PDPage());
|
||||
}
|
||||
document.save(output);
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user