package cn.iocoder.yudao.module.mes.service.wm.document; import org.springframework.core.io.ClassPathResource; import org.w3c.dom.Node; import javax.imageio.ImageIO; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import java.awt.*; import java.awt.geom.Path2D; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; /** * MES 仓储单据 Word 导出:基于 templates/wm/*.docx 模板填写槽位, * 并按公司名称实时绘制红色电子章(不依赖静态章图片)。 */ public final class MesWmDocumentRenderer { private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; private static final String SALES_DELIVERY_TEMPLATE = "templates/wm/sales-delivery.docx"; private static final String PRODUCT_OUTBOUND_TEMPLATE = "templates/wm/product-outbound.docx"; private static final String PROJECT_TRACKING_TEMPLATE = "templates/pd/project-tracking.docx"; private static final String SEAL_ENTRY = "word/media/seal.png"; private MesWmDocumentRenderer() { } public static byte[] renderSalesDelivery(Map fields, List> items, String companyName) throws IOException { return render(SALES_DELIVERY_TEMPLATE, fields, items, companyName); } public static byte[] renderProductOutbound(Map fields, List> items, String companyName) throws IOException { return render(PRODUCT_OUTBOUND_TEMPLATE, fields, items, companyName); } /** * 项目跟踪表:表头字段自动填充,跟踪记录为空白行,供打印后手写或下载后补填。 */ public static byte[] renderProjectTracking(Map fields, String companyName) throws IOException { byte[] seal = renderSeal(companyName, "项目专用章"); try (var input = new ClassPathResource(PROJECT_TRACKING_TEMPLATE).getInputStream(); var zip = new ZipInputStream(input); var output = new ByteArrayOutputStream(); var result = new ZipOutputStream(output)) { ZipEntry entry; while ((entry = zip.getNextEntry()) != null) { byte[] content = zip.readAllBytes(); if ("word/document.xml".equals(entry.getName())) { content = fillDocumentFieldsOnly(content, fields, companyName); } else if (SEAL_ENTRY.equals(entry.getName())) { content = seal; } result.putNextEntry(new ZipEntry(entry.getName())); result.write(content); result.closeEntry(); } result.finish(); return output.toByteArray(); } } private static byte[] render(String template, Map fields, List> items, String companyName) throws IOException { if (items == null || items.isEmpty()) { throw new IllegalArgumentException("单据没有货物明细,无法生成 Word 文件"); } byte[] seal = renderSeal(companyName, "发货专用章"); try (var input = new ClassPathResource(template).getInputStream(); var zip = new ZipInputStream(input); var output = new ByteArrayOutputStream(); var result = new ZipOutputStream(output)) { ZipEntry entry; while ((entry = zip.getNextEntry()) != null) { byte[] content = zip.readAllBytes(); if ("word/document.xml".equals(entry.getName())) { content = fillDocument(content, fields, items, companyName); } else if (SEAL_ENTRY.equals(entry.getName())) { content = seal; } result.putNextEntry(new ZipEntry(entry.getName())); result.write(content); result.closeEntry(); } result.finish(); return output.toByteArray(); } } private static byte[] fillDocument(byte[] xml, Map fields, List> items, String companyName) throws IOException { try { var factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); var document = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml)); // 1. 明细行 Node rowTemplate = null; var rows = document.getElementsByTagNameNS(W, "tr"); for (int i = 0; i < rows.getLength(); i++) { if (rows.item(i).getTextContent().contains("${itemName}")) { rowTemplate = rows.item(i); break; } } if (rowTemplate == null) { throw new IOException("单据模板缺少货物明细槽位"); } int index = 0; for (Map item : items) { Node row = rowTemplate.cloneNode(true); var values = new java.util.HashMap(); item.forEach((key, value) -> values.put(key, text(value))); values.put("index", String.valueOf(++index)); replace(row, values); rowTemplate.getParentNode().insertBefore(row, rowTemplate); } rowTemplate.getParentNode().removeChild(rowTemplate); // 2. 表头字段 var values = new java.util.HashMap(); fields.forEach((key, value) -> values.put(key, text(value))); values.put("companyName", text(companyName)); replace(document, values); if (document.getDocumentElement().getTextContent().contains("${")) { throw new IOException("单据模板存在未填写槽位"); } var transformerFactory = TransformerFactory.newInstance(); transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); var output = new ByteArrayOutputStream(); transformerFactory.newTransformer().transform(new DOMSource(document), new StreamResult(output)); return output.toByteArray(); } catch (IOException exception) { throw exception; } catch (Exception exception) { throw new IOException("生成单据 Word 文件失败", exception); } } private static byte[] fillDocumentFieldsOnly(byte[] xml, Map fields, String companyName) throws IOException { try { var factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); var document = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml)); var values = new java.util.HashMap(); fields.forEach((key, value) -> values.put(key, text(value))); values.put("companyName", text(companyName)); replace(document, values); if (document.getDocumentElement().getTextContent().contains("${")) { throw new IOException("单据模板存在未填写槽位"); } var transformerFactory = TransformerFactory.newInstance(); transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); var output = new ByteArrayOutputStream(); transformerFactory.newTransformer().transform(new DOMSource(document), new StreamResult(output)); return output.toByteArray(); } catch (IOException exception) { throw exception; } catch (Exception exception) { throw new IOException("生成单据 Word 文件失败", exception); } } // ==================== 电子章绘制 ==================== private static byte[] renderSeal(String companyName, String sealText) throws IOException { int size = 400; var image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); Graphics2D g = image.createGraphics(); try { g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); var red = new Color(200, 16, 46); g.setColor(red); g.setStroke(new BasicStroke(9f)); g.drawOval(12, 12, size - 24, size - 24); g.setStroke(new BasicStroke(3f)); g.drawOval(38, 38, size - 76, size - 76); String name = text(companyName).isBlank() ? "公司" : companyName.trim(); int fontSize = Math.max(16, Math.min(30, 560 / Math.max(1, name.length()))); g.setFont(new Font("SimSun", Font.BOLD, fontSize)); drawArcText(g, name, size / 2, size / 2, size / 2 - 62, 195, 150); drawStar(g, size / 2, size / 2 - 8, 34, 14); g.setFont(new Font("SimSun", Font.BOLD, 27)); drawCentered(g, sealText, size / 2, (int) (size * 0.68)); } finally { g.dispose(); } var output = new ByteArrayOutputStream(); ImageIO.write(image, "png", output); return output.toByteArray(); } private static void drawArcText(Graphics2D g, String text, int cx, int cy, int radius, double startDeg, double sweepDeg) { FontMetrics fm = g.getFontMetrics(); int[] widths = new int[text.length()]; double totalWidth = 0; for (int i = 0; i < text.length(); i++) { widths[i] = Math.max(1, fm.charWidth(text.charAt(i))); totalWidth += widths[i]; } double angle = startDeg; for (int i = 0; i < text.length(); i++) { double span = widths[i] / totalWidth * sweepDeg; double center = angle + span / 2; double rad = Math.toRadians(center); int x = (int) (cx + radius * Math.cos(rad)); int y = (int) (cy + radius * Math.sin(rad)); var transform = g.getTransform(); g.rotate(Math.toRadians(center + 90), x, y); g.drawString(String.valueOf(text.charAt(i)), x - widths[i] / 2, y + fm.getAscent() / 2 - 3); g.setTransform(transform); angle += span; } } private static void drawStar(Graphics2D g, int cx, int cy, int outer, int inner) { var path = new Path2D.Double(); for (int i = 0; i < 10; i++) { double r = i % 2 == 0 ? outer : inner; double angle = Math.toRadians(-90 + i * 36); double x = cx + r * Math.cos(angle); double y = cy + r * Math.sin(angle); if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } path.closePath(); g.fill(path); } private static void drawCentered(Graphics2D g, String text, int cx, int baseline) { FontMetrics fm = g.getFontMetrics(); g.drawString(text, cx - fm.stringWidth(text) / 2, baseline); } private static void replace(Node node, Map values) { if (node.getNodeType() == Node.ELEMENT_NODE && "t".equals(node.getLocalName()) && W.equals(node.getNamespaceURI())) { String value = node.getTextContent(); for (var entry : values.entrySet()) { // 明细字段可能为 null(如未填批次号),replace 不接受 null,统一回退为占位符 String replacement = entry.getValue() == null ? "-" : entry.getValue(); value = value.replace("${" + entry.getKey() + "}", replacement); } node.setTextContent(value); return; } for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) { replace(child, values); } } private static String text(String value) { return Objects.requireNonNullElse(value, "-").isBlank() ? "-" : value; } }