新增打印模块功能,支持图片分析生成原生模板JSON,查询可用打印机,服务端直打功能,优化打印设计器界面,添加打印机选择和快速打印选项,同时更新依赖项以支持PDF处理。

This commit is contained in:
geht
2026-04-14 17:18:50 +08:00
parent 0024c071ff
commit e04169a694
55 changed files with 30188 additions and 1595 deletions

View File

@@ -6,8 +6,36 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import javax.print.Doc;
import javax.print.DocFlavor;
import javax.print.DocPrintJob;
import javax.print.PrintException;
import javax.print.PrintService;
import javax.print.PrintServiceLookup;
import javax.print.SimpleDoc;
import javax.print.attribute.HashPrintRequestAttributeSet;
import javax.print.attribute.standard.JobName;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.print.PageFormat;
import java.awt.print.Printable;
import java.awt.print.PrinterAbortException;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.io.ByteArrayInputStream;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
@@ -16,7 +44,10 @@ import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.print.entity.PrintTemplate;
import org.jeecg.modules.print.service.IPrintTemplateService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.jeecg.modules.print.ai.INativePrintTemplateImageAnalyzeService;
/**
* 打印模板维护Hiprint
@@ -26,6 +57,11 @@ import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/print/template")
public class PrintTemplateController extends JeecgController<PrintTemplate, IPrintTemplateService> {
@Value("${print.network-printers:}")
private String networkPrinters;
@Autowired
private INativePrintTemplateImageAnalyzeService nativePrintTemplateImageAnalyzeService;
@Operation(summary = "打印模板-分页列表")
@GetMapping(value = "/list")
@@ -128,6 +164,35 @@ public class PrintTemplateController extends JeecgController<PrintTemplate, IPri
return Result.OK(service.getById(id));
}
@AutoLog(value = "打印模板-图片分析生成原生JSON")
@Operation(summary = "打印模板-上传图片分析为原生模板JSON前端传 imageBase64可接 OpenAI 兼容视觉模型)")
@PostMapping(value = "/analyzeImageForNative")
@RequiresPermissions("print:template:edit")
public Result<Map<String, Object>> analyzeImageForNative(@RequestBody Map<String, String> body) {
try {
String imageBase64 = body == null ? null : body.get("imageBase64");
if (StringUtils.isBlank(imageBase64)) {
return Result.error("imageBase64 不能为空");
}
String filename = body.get("filename");
String mime = body.get("mime");
byte[] bytes = decodeImageBase64(imageBase64);
return Result.OK(nativePrintTemplateImageAnalyzeService.analyzeBytes(bytes, mime, filename));
} catch (Exception e) {
log.error("图片分析失败", e);
return Result.error("图片分析失败:" + e.getMessage());
}
}
private static byte[] decodeImageBase64(String imageBase64) {
String s = StringUtils.trimToEmpty(imageBase64);
int comma = s.indexOf(',');
if (s.startsWith("data:") && comma > 0) {
s = s.substring(comma + 1);
}
return Base64.getDecoder().decode(s.replaceAll("\\s", ""));
}
@Operation(summary = "打印模板-通过编码查询")
@GetMapping(value = "/queryByCode")
@RequiresPermissions("print:template:list")
@@ -138,4 +203,270 @@ public class PrintTemplateController extends JeecgController<PrintTemplate, IPri
}
return Result.OK(t);
}
@Operation(summary = "打印模板-查询可用打印机")
@GetMapping(value = "/queryPrinters")
@RequiresPermissions("print:template:list")
public Result<Map<String, Object>> queryPrinters() {
Map<String, Object> res = new HashMap<>(8);
List<String> serverPrinters = new ArrayList<>();
String serverDefaultPrinter = "";
try {
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
if (services != null) {
for (PrintService service : services) {
if (service != null && StringUtils.isNotBlank(service.getName())) {
serverPrinters.add(service.getName().trim());
}
}
}
PrintService defaultService = PrintServiceLookup.lookupDefaultPrintService();
if (defaultService != null && StringUtils.isNotBlank(defaultService.getName())) {
serverDefaultPrinter = defaultService.getName().trim();
}
} catch (Exception e) {
log.warn("查询服务器打印机失败: {}", e.getMessage());
}
List<String> networkPrinterList =
StringUtils.isBlank(networkPrinters)
? new ArrayList<>()
: java.util.Arrays.stream(networkPrinters.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.toList());
Map<String, Object> capability = new LinkedHashMap<>(4);
capability.put("localSupported", false);
capability.put("localReason", "浏览器环境无法直接枚举客户端本地打印机,需要本地组件或客户端程序配合。");
capability.put("serverSupported", true);
capability.put("networkSupported", true);
res.put("capability", capability);
res.put("serverPrinters", serverPrinters);
res.put("serverDefaultPrinter", serverDefaultPrinter);
res.put("networkPrinters", networkPrinterList);
return Result.OK(res);
}
@AutoLog(value = "打印模板-服务端直打")
@Operation(summary = "打印模板-服务端直打")
@PostMapping(value = "/directPrint")
@RequiresPermissions("print:template:list")
public Result<String> directPrint(@RequestBody Map<String, Object> body) {
String templateCode = String.valueOf(body.getOrDefault("templateCode", "")).trim();
String printerName = String.valueOf(body.getOrDefault("printerName", "")).trim();
Object dataJsonObj = body.get("dataJson");
String dataJsonText = dataJsonObj == null ? "" : String.valueOf(dataJsonObj);
if (StringUtils.isBlank(templateCode)) {
return Result.error("templateCode 不能为空");
}
if (StringUtils.isBlank(dataJsonText)) {
return Result.error("dataJson 不能为空");
}
PrintTemplate tpl = service.getByCode(templateCode);
if (tpl == null) {
return Result.error("模板不存在: " + templateCode);
}
try {
PrintService target = null;
if (StringUtils.isNotBlank(printerName) && !"__system_default__".equals(printerName)) {
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
if (services != null) {
for (PrintService serviceItem : services) {
if (serviceItem != null && printerName.equalsIgnoreCase(String.valueOf(serviceItem.getName()).trim())) {
target = serviceItem;
break;
}
}
}
if (target == null) {
return Result.error("未找到指定打印机: " + printerName);
}
}
if (target == null) {
target = PrintServiceLookup.lookupDefaultPrintService();
}
if (target == null) {
return Result.error("未找到可用打印机,请检查服务器打印机配置");
}
// 说明:当前接口实现的是服务端直打(纯文本)。若需按 hiprint 模板渲染版式,建议接入独立渲染服务。
String content =
"QH-MES 快速打印\n模板编号: "
+ templateCode
+ "\n模板名称: "
+ String.valueOf(tpl.getTemplateName())
+ "\n\n数据JSON:\n"
+ dataJsonText
+ "\n";
final String[] lines = content.replace("\r\n", "\n").split("\n", -1);
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(target);
job.setJobName("QH-MES-" + templateCode);
job.setPrintable(
new Printable() {
@Override
public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) throws PrinterException {
Graphics2D g2 = (Graphics2D) graphics;
g2.translate(pageFormat.getImageableX(), pageFormat.getImageableY());
g2.setFont(new Font("Microsoft YaHei", Font.PLAIN, 10));
int lineHeight = g2.getFontMetrics().getHeight() + 2;
int maxLinesPerPage = Math.max(1, (int) (pageFormat.getImageableHeight() / lineHeight));
int start = pageIndex * maxLinesPerPage;
if (start >= lines.length) {
return Printable.NO_SUCH_PAGE;
}
int end = Math.min(lines.length, start + maxLinesPerPage);
int y = g2.getFontMetrics().getAscent();
for (int i = start; i < end; i += 1) {
g2.drawString(lines[i], 0, y);
y += lineHeight;
}
return Printable.PAGE_EXISTS;
}
});
job.print();
return Result.OK("已提交到服务器打印机: " + target.getName());
} catch (Exception e) {
log.error("服务端直打失败", e);
return Result.error("服务端直打失败: " + e.getMessage());
}
}
@AutoLog(value = "打印模板-PDF后端打印")
@Operation(summary = "打印模板-PDF后端打印")
@PostMapping(value = "/directPrintPdf")
@RequiresPermissions("print:template:list")
public Result<String> directPrintPdf(@RequestBody Map<String, Object> body) {
String templateCode = String.valueOf(body.getOrDefault("templateCode", "")).trim();
String printerName = String.valueOf(body.getOrDefault("printerName", "")).trim();
String pdfBase64 = String.valueOf(body.getOrDefault("pdfBase64", "")).trim();
String fileName = String.valueOf(body.getOrDefault("fileName", "")).trim();
if (StringUtils.isBlank(templateCode)) {
return Result.error("templateCode 不能为空");
}
if (StringUtils.isBlank(pdfBase64)) {
return Result.error("pdfBase64 不能为空");
}
String lastResolvedPrinterLabel = null;
try {
PrintService target = resolvePrintService(printerName);
if (target == null) {
return Result.error("未找到可用打印机,请检查服务器打印机配置");
}
final String resolvedPrinterLabel = target.getName();
lastResolvedPrinterLabel = resolvedPrinterLabel;
String base64Body = pdfBase64;
int commaIdx = pdfBase64.indexOf(",");
if (pdfBase64.startsWith("data:") && commaIdx > 0) {
base64Body = pdfBase64.substring(commaIdx + 1);
}
byte[] pdfBytes = Base64.getDecoder().decode(base64Body);
String printJobName = StringUtils.isNotBlank(fileName) ? fileName : ("QH-MES-" + templateCode + ".pdf");
// 优先直送 PDF 字节,避免走 RasterPrinterJob虚拟打印机/无界面会话下易触发 PrinterAbortException
if (tryPrintPdfBytesWithDocFlavor(target, pdfBytes, printJobName)) {
return Result.OK("已提交PDF到服务器打印机: " + resolvedPrinterLabel);
}
try (PDDocument document = PDDocument.load(new ByteArrayInputStream(pdfBytes))) {
PDFRenderer renderer = new PDFRenderer(document);
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(target);
job.setJobName(printJobName);
job.setPrintable(
(graphics, pageFormat, pageIndex) -> {
if (pageIndex >= document.getNumberOfPages()) {
return Printable.NO_SUCH_PAGE;
}
BufferedImage image;
try {
image = renderer.renderImageWithDPI(pageIndex, 150);
} catch (Exception ex) {
throw new PrinterException("PDF页面渲染失败: " + ex.getMessage());
}
Graphics2D g2 = (Graphics2D) graphics;
double imageableX = pageFormat.getImageableX();
double imageableY = pageFormat.getImageableY();
double imageableWidth = pageFormat.getImageableWidth();
double imageableHeight = pageFormat.getImageableHeight();
double scale =
Math.min(imageableWidth / image.getWidth(), imageableHeight / image.getHeight());
int drawWidth = (int) Math.round(image.getWidth() * scale);
int drawHeight = (int) Math.round(image.getHeight() * scale);
int drawX = (int) Math.round(imageableX + (imageableWidth - drawWidth) / 2);
int drawY = (int) Math.round(imageableY + (imageableHeight - drawHeight) / 2);
g2.drawImage(image, drawX, drawY, drawWidth, drawHeight, null);
return Printable.PAGE_EXISTS;
});
HashPrintRequestAttributeSet patts = new HashPrintRequestAttributeSet();
patts.add(new JobName(printJobName, Locale.getDefault()));
job.print(patts);
}
return Result.OK("已提交PDF到服务器打印机: " + resolvedPrinterLabel);
} catch (PrinterAbortException e) {
log.error("PDF后端打印失败(PrinterAbortException)", e);
return Result.error(buildPdfPrinterAbortHint(printerName, lastResolvedPrinterLabel));
} catch (Exception e) {
log.error("PDF后端打印失败", e);
return Result.error("PDF后端打印失败: " + e.getMessage());
}
}
/**
* 若打印机声明支持 application/pdf则通过 DocPrintJob 提交,通常比 AWT 栅格化更稳定。
*/
private boolean tryPrintPdfBytesWithDocFlavor(PrintService printService, byte[] pdfBytes, String jobName) {
DocFlavor flavor = new DocFlavor.INPUT_STREAM("application/pdf");
if (!printService.isDocFlavorSupported(flavor)) {
return false;
}
try {
DocPrintJob docJob = printService.createPrintJob();
ByteArrayInputStream in = new ByteArrayInputStream(pdfBytes);
Doc doc = new SimpleDoc(in, flavor, null);
HashPrintRequestAttributeSet attrs = new HashPrintRequestAttributeSet();
if (StringUtils.isNotBlank(jobName)) {
attrs.add(new JobName(jobName, Locale.getDefault()));
}
docJob.print(doc, attrs);
return true;
} catch (PrintException e) {
log.warn("PDF DocFlavor 直送失败,将回退为位图渲染: {} - {}", printService.getName(), e.getMessage());
return false;
}
}
private static String buildPdfPrinterAbortHint(String requestedPrinterName, String resolvedPrintQueueName) {
StringBuilder sb = new StringBuilder();
sb.append("打印任务被系统取消PrinterAbortException。常见原因");
sb.append("1) 默认或所选为「Microsoft Print to PDF」等虚拟打印机在 Tomcat 等服务进程无交互桌面时无法弹出保存对话框,作业会被中止——请安装实体打印机并在前端指定 printerName");
sb.append("2) 打印机离线、队列暂停、缺纸或驱动报错;");
sb.append("3) 运行服务的 Windows 账户无权访问打印队列。");
if (StringUtils.isNotBlank(resolvedPrintQueueName)) {
sb.append(" 当前实际使用的打印队列: ").append(resolvedPrintQueueName.trim()).append("");
}
if (StringUtils.isNotBlank(requestedPrinterName) && !"__system_default__".equalsIgnoreCase(requestedPrinterName.trim())) {
sb.append(" 请求参数 printerName: ").append(requestedPrinterName.trim()).append("");
}
return sb.toString();
}
private PrintService resolvePrintService(String printerName) {
PrintService target = null;
if (StringUtils.isNotBlank(printerName) && !"__system_default__".equals(printerName)) {
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
if (services != null) {
for (PrintService serviceItem : services) {
if (serviceItem != null
&& printerName.equalsIgnoreCase(String.valueOf(serviceItem.getName()).trim())) {
target = serviceItem;
break;
}
}
}
}
if (target == null) {
target = PrintServiceLookup.lookupDefaultPrintService();
}
return target;
}
}