package jnpf.bizcommon.onlyoffice.service.impl; import com.google.zxing.BarcodeFormat; import com.google.zxing.EncodeHintType; import com.google.zxing.MultiFormatWriter; import com.google.zxing.common.BitMatrix; import jnpf.bizcommon.onlyoffice.entity.OnlyOfficeProperties; import jnpf.dmsEntity.preview.model.DmsFilePreviewTicket; import jnpf.dmsEntity.preview.model.DmsWatermarkPositionSnapshot; import jnpf.dmsEntity.preview.model.DmsWatermarkSnapshot; import jnpf.dms.fonts.DmsWatermarkFonts; import jnpf.exception.DataException; import lombok.RequiredArgsConstructor; 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.graphics.image.LosslessFactory; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.apache.pdfbox.util.Matrix; import org.springframework.stereotype.Component; import java.awt.AlphaComposite; import java.awt.Color; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** Builds only the physical pages that will actually be printed and stamps copy-specific barcodes. */ @Component @RequiredArgsConstructor public class DmsOnlyOfficePrintPdfComposer { private static final double POINTS_PER_MM = 72D / 25.4D; private static final double RENDER_DPI = 144D; private static final int BARCODE_TEXT_FONT_SIZE = 10; private static final int BARCODE_TEXT_PADDING_PX = 2; private static final int MAX_TILED_MARKS = 300; private final OnlyOfficeProperties properties; public byte[] compose(byte[] basePdf, List copies) { return compose(basePdf, copies, true); } public byte[] composePreview(byte[] basePdf, DmsFilePreviewTicket ticket) { return compose(basePdf, Collections.singletonList(ticket), false); } private byte[] compose(byte[] basePdf, List copies, boolean enforceBatchSize) { if (basePdf == null || basePdf.length == 0 || copies == null || copies.isEmpty()) { throw new DataException("打印 PDF 或打印副本为空"); } OnlyOfficeProperties.DmsPrint limits = properties.getDmsPrint(); if (copies.size() > limits.getMaxBatchItems()) { throw new DataException("单批打印份数超过系统限制"); } try (PDDocument source = Loader.loadPDF(basePdf); PDDocument output = new PDDocument(); ByteArrayOutputStream bytes = new ByteArrayOutputStream()) { if (source.getNumberOfPages() < 1) throw new DataException("基础打印 PDF 没有页面"); int expectedPages = 0; List> pagesByCopy = new ArrayList<>(copies.size()); for (DmsFilePreviewTicket copy : copies) { List pages = parsePages(copy.getPrintPages(), source.getNumberOfPages()); if (pages.isEmpty()) { throw new DataException("打印页码未匹配到文档页面"); } expectedPages = Math.addExact(expectedPages, pages.size()); pagesByCopy.add(pages); } if (copies.size() > 1 && expectedPages > limits.getMaxBatchPages()) { throw new DataException("单批打印页数超过系统限制"); } if (expectedPages > limits.getMaxPages()) { throw new DataException("打印 PDF 页数超过系统限制"); } for (int copyIndex = 0; copyIndex < copies.size(); copyIndex++) { DmsFilePreviewTicket ticket = copies.get(copyIndex); for (Integer pageNo : pagesByCopy.get(copyIndex)) { PDPage page = output.importPage(source.getPage(pageNo - 1)); stamp(output, page, pageNo, ticket.getWatermarks()); } } output.save(bytes); long sizeLimit = enforceBatchSize ? Math.min(limits.getMaxPdfBytes(), limits.getMaxBatchPdfBytes()) : limits.getMaxPdfBytes(); if (bytes.size() > sizeLimit) throw new DataException("单批打印 PDF 超过系统大小限制"); return bytes.toByteArray(); } catch (DataException exception) { throw exception; } catch (ArithmeticException exception) { throw new DataException("打印页数超过系统限制"); } catch (Exception exception) { throw new DataException("生成按页条码打印 PDF 失败"); } } private void stamp(PDDocument document, PDPage page, int sourcePageNo, List watermarks) throws Exception { if (watermarks == null || watermarks.isEmpty()) return; PDRectangle box = page.getCropBox(); if (box == null || box.getWidth() <= 0 || box.getHeight() <= 0) { throw new DataException("打印 PDF 页面尺寸无效"); } int rotation = Math.floorMod(page.getRotation(), 360); boolean quarterTurn = rotation == 90 || rotation == 270; float visualWidthPt = quarterTurn ? box.getHeight() : box.getWidth(); float visualHeightPt = quarterTurn ? box.getWidth() : box.getHeight(); PageSize size = new PageSize(visualWidthPt / POINTS_PER_MM, visualHeightPt / POINTS_PER_MM); try (PDPageContentStream stream = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { stream.transform(pageTransform(box, rotation)); for (DmsWatermarkSnapshot mark : watermarks) { if (mark == null || !Boolean.TRUE.equals(mark.getCustomBarcode()) || mark.getRenderPages() != null && !mark.getRenderPages().contains(sourcePageNo)) { continue; } double markWidth = positive(mark.getMarkWidthMm(), 80D); double markHeight = positive(mark.getMarkHeightMm(), 20D); BufferedImage barcode = barcode(mark, px(markWidth), px(markHeight)); for (Point point : points(size, mark, markWidth, markHeight)) { PositionedImage positioned = rotate(barcode, markWidth, markHeight, value(mark.getRotationAngle(), 0), value(mark.getOpacity(), 100), point, size); PDImageXObject image = LosslessFactory.createFromImage(document, positioned.image); float x = (float) (positioned.x * POINTS_PER_MM); float y = (float) (visualHeightPt - (positioned.y + positioned.height) * POINTS_PER_MM); stream.drawImage(image, x, y, (float) (positioned.width * POINTS_PER_MM), (float) (positioned.height * POINTS_PER_MM)); } } } } private Matrix pageTransform(PDRectangle box, int rotation) { // Undo the page's display rotation so the barcode stays upright in visual coordinates. float x = box.getLowerLeftX(); float y = box.getLowerLeftY(); float width = box.getWidth(); float height = box.getHeight(); switch (rotation) { case 0: return new Matrix(1, 0, 0, 1, x, y); case 90: return new Matrix(0, 1, -1, 0, x + width, y); case 180: return new Matrix(-1, 0, 0, -1, x + width, y + height); case 270: return new Matrix(0, -1, 1, 0, x, y + height); default: throw new DataException("PDF 页面旋转角度无效"); } } private PositionedImage rotate(BufferedImage source, double width, double height, int angle, int opacity, Point point, PageSize page) { double radians = Math.toRadians(Math.floorMod(angle, 360)); double drawingWidth = Math.abs(width * Math.cos(radians)) + Math.abs(height * Math.sin(radians)); double drawingHeight = Math.abs(width * Math.sin(radians)) + Math.abs(height * Math.cos(radians)); BufferedImage image = new BufferedImage(Math.max(1, px(drawingWidth)), Math.max(1, px(drawingHeight)), BufferedImage.TYPE_INT_ARGB); Graphics2D graphics = image.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Math.max(0, Math.min(100, opacity)) / 100F)); graphics.translate(image.getWidth() / 2D, image.getHeight() / 2D); graphics.rotate(radians); graphics.drawImage(source, -px(width) / 2, -px(height) / 2, px(width), px(height), null); } finally { graphics.dispose(); } double x = clamp(point.x - (drawingWidth - width) / 2D, 0D, Math.max(0D, page.width - drawingWidth)); double y = clamp(point.y - (drawingHeight - height) / 2D, 0D, Math.max(0D, page.height - drawingHeight)); return new PositionedImage(image, drawingWidth, drawingHeight, x, y); } private BufferedImage barcode(DmsWatermarkSnapshot mark, int width, int height) throws Exception { String configured = mark.getBarcodeFormat(); if (!"QR_CODE".equalsIgnoreCase(configured) && !"CODE_128".equalsIgnoreCase(configured)) { throw new DataException("不支持的打印条码格式"); } BarcodeFormat format = BarcodeFormat.valueOf(configured.toUpperCase(Locale.ROOT)); String content = text(mark.getContent()); if (content.isEmpty()) throw new DataException("打印条码内容不能为空"); if (!Boolean.TRUE.equals(mark.getShowBarcodeText())) { return barcodeSymbol(content, format, width, height); } String label = mark.getBarcodeText() == null ? content : mark.getBarcodeText(); Font font = chooseFont(mark.getFontFamily(), label, Math.max(1, (int) Math.round(BARCODE_TEXT_FONT_SIZE * RENDER_DPI / 72D))); BufferedImage metricsImage = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); Graphics2D metricsGraphics = metricsImage.createGraphics(); int textHeight; try { metricsGraphics.setFont(font); textHeight = metricsGraphics.getFontMetrics().getHeight() + BARCODE_TEXT_PADDING_PX * 2; } finally { metricsGraphics.dispose(); } BufferedImage symbol = barcodeSymbol(content, format, width, Math.max(1, height - textHeight)); BufferedImage image = new BufferedImage(Math.max(width, symbol.getWidth()), Math.max(height, symbol.getHeight() + textHeight), BufferedImage.TYPE_INT_ARGB); Graphics2D graphics = image.createGraphics(); try { graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); graphics.drawImage(symbol, (image.getWidth() - symbol.getWidth()) / 2, 0, null); graphics.setFont(font); graphics.setColor(Color.BLACK); FontMetrics metrics = graphics.getFontMetrics(); graphics.drawString(label, Math.max(BARCODE_TEXT_PADDING_PX, (image.getWidth() - metrics.stringWidth(label)) / 2), image.getHeight() - BARCODE_TEXT_PADDING_PX - metrics.getDescent()); } finally { graphics.dispose(); } return image; } private BufferedImage barcodeSymbol(String content, BarcodeFormat format, int width, int height) throws Exception { Map hints = new LinkedHashMap<>(); hints.put(EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name()); BitMatrix matrix = new MultiFormatWriter().encode(content, format, width, height, hints); BufferedImage image = new BufferedImage(matrix.getWidth(), matrix.getHeight(), BufferedImage.TYPE_INT_ARGB); for (int y = 0; y < matrix.getHeight(); y++) { for (int x = 0; x < matrix.getWidth(); x++) { if (matrix.get(x, y)) image.setRGB(x, y, Color.BLACK.getRGB()); } } return image; } private Font chooseFont(String requested, String content, int pixels) { return DmsWatermarkFonts.configured().choose(requested, content, pixels); } private List parsePages(String value, int pageCount) { if (value == null || value.trim().isEmpty()) { List pages = new ArrayList<>(pageCount); for (int page = 1; page <= pageCount; page++) pages.add(page); return pages; } Set pages = new LinkedHashSet<>(); for (String raw : value.split(",", -1)) { String token = raw.trim(); if (token.isEmpty() || !token.matches("\\d+(?:\\s*-\\s*\\d+)?")) { throw new DataException("打印页码格式无效"); } String[] bounds = token.split("\\s*-\\s*"); int start = Integer.parseInt(bounds[0]); int end = bounds.length == 1 ? start : Integer.parseInt(bounds[1]); if (start < 1 || end < start) throw new DataException("打印页码格式无效"); for (int page = start; page <= Math.min(end, pageCount); page++) pages.add(page); } return new ArrayList<>(pages); } private List points(PageSize page, DmsWatermarkSnapshot mark, double width, double height) { DmsWatermarkPositionSnapshot position = closestPosition(page, mark.getPositions()); double x = coordinate(page, position, true); double y = coordinate(page, position, false); if (!"TILED".equalsIgnoreCase(mark.getLayoutMode())) { return Collections.singletonList(new Point(clamp(x, 0D, Math.max(0D, page.width - width)), clamp(y, 0D, Math.max(0D, page.height - height)))); } double stepX = Math.max(1D, width + nonNegative(mark.getRepeatSpacingXMm(), 20D)); double stepY = Math.max(1D, height + nonNegative(mark.getRepeatSpacingYMm(), 20D)); List result = new ArrayList<>(); for (double yy = y; yy < page.height && result.size() < MAX_TILED_MARKS; yy += stepY) { for (double xx = x; xx < page.width && result.size() < MAX_TILED_MARKS; xx += stepX) { result.add(new Point(xx, yy)); } } return result; } private DmsWatermarkPositionSnapshot closestPosition(PageSize page, List positions) { if (positions == null || positions.isEmpty()) throw new DataException("条码水印缺少页面位置"); DmsWatermarkPositionSnapshot best = null; double distance = Double.MAX_VALUE; for (DmsWatermarkPositionSnapshot candidate : positions) { double width = positive(candidate.getPaperWidthMm(), 210D); double height = positive(candidate.getPaperHeightMm(), 297D); double current = Math.min(Math.abs(width - page.width) + Math.abs(height - page.height), Math.abs(height - page.width) + Math.abs(width - page.height)); if (current < distance) { distance = current; best = candidate; } } return best; } private double coordinate(PageSize page, DmsWatermarkPositionSnapshot position, boolean x) { BigDecimal coordinate = page.width > page.height ? (x ? position.getLandscapeXMm() : position.getLandscapeYMm()) : (x ? position.getPortraitXMm() : position.getPortraitYMm()); return coordinate == null ? 0D : coordinate.doubleValue(); } private int px(double mm) { return Math.max(1, (int) Math.round(mm * RENDER_DPI / 25.4D)); } private double positive(BigDecimal value, double fallback) { return value == null || value.doubleValue() <= 0 ? fallback : value.doubleValue(); } private double positive(Integer value, double fallback) { return value == null || value <= 0 ? fallback : value.doubleValue(); } private double nonNegative(BigDecimal value, double fallback) { return value == null || value.doubleValue() < 0 ? fallback : value.doubleValue(); } private double clamp(double value, double min, double max) { return Math.max(min, Math.min(max, value)); } private int value(Integer value, int fallback) { return value == null ? fallback : value; } private String text(String value) { return value == null ? "" : value; } private static final class PageSize { private final double width; private final double height; private PageSize(double width, double height) { this.width = width; this.height = height; } } private static final class Point { private final double x; private final double y; private Point(double x, double y) { this.x = x; this.y = y; } } private static final class PositionedImage { private final BufferedImage image; private final double width; private final double height; private final double x; private final double y; private PositionedImage(BufferedImage image, double width, double height, double x, double y) { this.image = image; this.width = width; this.height = height; this.x = x; this.y = y; } } }