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<DmsFilePreviewTicket> 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<DmsFilePreviewTicket> 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<List<Integer>> pagesByCopy = new ArrayList<>(copies.size());
|
for (DmsFilePreviewTicket copy : copies) {
|
List<Integer> 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<DmsWatermarkSnapshot> 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<EncodeHintType, Object> 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<Integer> parsePages(String value, int pageCount) {
|
if (value == null || value.trim().isEmpty()) {
|
List<Integer> pages = new ArrayList<>(pageCount);
|
for (int page = 1; page <= pageCount; page++) pages.add(page);
|
return pages;
|
}
|
Set<Integer> 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<Point> 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<Point> 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<DmsWatermarkPositionSnapshot> 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;
|
}
|
}
|
}
|