新增打印模块功能,支持图片分析生成原生模板JSON,查询可用打印机,服务端直打功能,优化打印设计器界面,添加打印机选择和快速打印选项,同时更新依赖项以支持PDF处理。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user