diff --git a/backend/ruoyi-modules/ruoyi-aihr/pom.xml b/backend/ruoyi-modules/ruoyi-aihr/pom.xml index a5cf11e1..81e0155a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/pom.xml +++ b/backend/ruoyi-modules/ruoyi-aihr/pom.xml @@ -54,6 +54,12 @@ 3.2.2 + + org.apache.pdfbox + pdfbox + 3.0.5 + + org.springframework.boot spring-boot-starter-test diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPdfPageRenderer.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPdfPageRenderer.java new file mode 100644 index 00000000..27f8a23f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPdfPageRenderer.java @@ -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 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 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 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; + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPdfPageRendererTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPdfPageRendererTest.java new file mode 100644 index 00000000..28187399 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPdfPageRendererTest.java @@ -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 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(); + } + } +}