package com.ruoyi.inspect.util; import com.aspose.words.Document; import com.aspose.words.Cell; import com.aspose.words.CompositeNode; import com.aspose.words.LayoutCollector; import com.aspose.words.LayoutEnumerator; import com.aspose.words.License; import com.aspose.words.Node; import com.aspose.words.NodeCollection; import com.aspose.words.NodeType; import com.aspose.words.PageSetup; import com.aspose.words.Paragraph; import com.aspose.words.RelativeHorizontalPosition; import com.aspose.words.RelativeVerticalPosition; import com.aspose.words.Run; import com.aspose.words.SaveFormat; import com.aspose.words.Section; import com.aspose.words.Shape; import com.aspose.words.ShapeType; import com.aspose.words.WrapType; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.awt.geom.Rectangle2D; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.List; /** * 将报告中的印章改为浮动图片,避免图片参与正文行高计算而改变原报告分页。 */ public final class ReportSealLayoutUtil { private static final double MA_LEFT = 30D; private static final double MA_TOP = 30D; private static final double MA_MAX_WIDTH = 90D; private static final double MA_MAX_HEIGHT = 80D; private static final double REPORT_MAX_WIDTH = 105D; private static final double REPORT_MAX_HEIGHT = 105D; private static final double LAST_PAGE_MAX_WIDTH = 110D; private static final double LAST_PAGE_MAX_HEIGHT = 110D; private static final double POSITION_GAP = 14D; private ReportSealLayoutUtil() { } public static void apply(String wordPath, String reportSealPath, String maSealPath) throws Exception { applyAsposeLicense(); Document document = new Document(wordPath); byte[] reportSealBytes = readImage(reportSealPath); byte[] maSealBytes = readImage(maSealPath); if (reportSealBytes != null) { removeMatchingImages(document, reportSealBytes); document.updatePageLayout(); addCoverReportSeal(document, reportSealPath); addConclusionReportSeal(document, reportSealPath); addLastPageReportSeal(document, reportSealPath); } if (maSealBytes != null) { removeMatchingImages(document, maSealBytes); document.updatePageLayout(); // reportSeal 与 maSeal 可能选择同一张图片,移除后需要恢复三个报告章位置。 if (reportSealBytes != null && Arrays.equals(reportSealBytes, maSealBytes)) { addCoverReportSeal(document, reportSealPath); addConclusionReportSeal(document, reportSealPath); addLastPageReportSeal(document, reportSealPath); } addFirstPageMaSeal(document, maSealPath); } document.updatePageLayout(); document.save(wordPath, SaveFormat.DOCX); } /** 从历史批准稿中清除批准内容,恢复重新批准所需的占位符。 */ public static void restoreForReview(String wordPath) throws Exception { applyAsposeLicense(); Document document = new Document(wordPath); List shapes = imageShapes(document); Shape signatureShape = null; List dateShapes = new ArrayList<>(); for (Shape shape : shapes) { if (hasPlaceholderMarker(shape, "ratifyUrl")) { signatureShape = shape; continue; } if (hasPlaceholderMarker(shape, "ratifyDateUrl")) { dateShapes.add(shape); continue; } // 批准时添加的报告章和 MA 章按页面浮动定位,按位置清理而非比较图片字节。 if (isApprovalSeal(shape)) { shape.remove(); } } if (signatureShape != null) { replaceWithPlaceholder(document, signatureShape, "{{@ratifyUrl}}"); } else if (!restoreApprovalCellPlaceholder(document)) { throw new IllegalStateException("未找到批准栏,无法恢复批准签名占位符"); } // 部分报告模板没有批准日期图片,批准时间仅存在数据库中。 for (Shape dateShape : dateShapes) { replaceWithPlaceholder(document, dateShape, "{{@ratifyDateUrl}}"); } document.save(wordPath, SaveFormat.DOCX); } private static boolean isApprovalSeal(Shape shape) throws Exception { return shape.getWrapType() != WrapType.INLINE && shape.getRelativeHorizontalPosition() == RelativeHorizontalPosition.PAGE && shape.getRelativeVerticalPosition() == RelativeVerticalPosition.PAGE && shape.getWidth() >= 50D && shape.getWidth() <= 140D && shape.getHeight() >= 50D && shape.getHeight() <= 140D; } private static boolean restoreApprovalCellPlaceholder(Document document) throws Exception { NodeCollection cells = document.getChildNodes(NodeType.CELL, true); for (int i = 0; i < cells.getCount(); i++) { Cell label = (Cell) cells.get(i); if (!"批准".equals(label.getText().replace("\r", "").replace("\u0007", "").trim())) { continue; } Node sibling = label.getNextSibling(); if (!(sibling instanceof Cell)) { continue; } Cell signatureCell = (Cell) sibling; for (Shape shape : imageShapesIn(signatureCell)) { shape.remove(); } Paragraph paragraph = signatureCell.getFirstParagraph(); if (paragraph == null) { throw new IllegalStateException("批准签名单元格缺少段落"); } if (!signatureCell.getText().contains("{{@ratifyUrl}}")) { paragraph.appendChild(new Run(document, "{{@ratifyUrl}}")); } return true; } return false; } private static List imageShapesIn(Cell cell) throws Exception { List result = new ArrayList<>(); NodeCollection nodes = cell.getChildNodes(NodeType.SHAPE, true); for (int i = 0; i < nodes.getCount(); i++) { Shape shape = (Shape) nodes.get(i); if (shape.hasImage()) { result.add(shape); } } return result; } private static boolean hasPlaceholderMarker(Shape shape, String marker) throws Exception { String alternativeText = shape.getAlternativeText(); return alternativeText != null && alternativeText.contains(marker); } @SuppressWarnings({"rawtypes", "unchecked"}) private static void replaceWithPlaceholder(Document document, Shape shape, String placeholder) { CompositeNode parent = (CompositeNode) shape.getParentNode(); parent.insertBefore(new Run(document, placeholder), shape); shape.remove(); } private static void applyAsposeLicense() throws Exception { try (InputStream inputStream = ReportSealLayoutUtil.class.getResourceAsStream("/lib/license.xml")) { if (inputStream == null) { throw new IllegalStateException("未找到 Aspose 授权文件"); } License license = new License(); license.setLicense(inputStream); if (!license.getIsLicensed()) { throw new IllegalStateException("Aspose 授权验证失败"); } } } private static byte[] readImage(String imagePath) throws Exception { if (imagePath == null || imagePath.trim().isEmpty()) { return null; } Path path = java.nio.file.Paths.get(imagePath); return Files.readAllBytes(path); } private static void removeMatchingImages(Document document, byte[] imageBytes) throws Exception { for (Shape shape : imageShapes(document)) { if (sameImage(shape, imageBytes)) { shape.remove(); } } } private static void addFirstPageMaSeal(Document document, String imagePath) throws Exception { Paragraph anchor = firstBodyParagraph(document); double[] size = fitImage(imagePath, MA_MAX_WIDTH, MA_MAX_HEIGHT); Shape shape = createFloatingImage(document, imagePath, size[0], size[1]); shape.setLeft(MA_LEFT); shape.setTop(MA_TOP); anchor.appendChild(shape); } private static void addCoverReportSeal(Document document, String imagePath) throws Exception { Paragraph anchor = firstBodyParagraph(document); LayoutCollector collector = new LayoutCollector(document); Paragraph company = findPageCompanyParagraph(document, collector, 1); Rectangle2D rectangle = company == null ? null : getLayoutRectangle(document, collector, company); PageSetup pageSetup = document.getFirstSection().getPageSetup(); double[] size = fitImage(imagePath, REPORT_MAX_WIDTH, REPORT_MAX_HEIGHT); Shape shape = createFloatingImage(document, imagePath, size[0], size[1]); shape.setLeft((pageSetup.getPageWidth() - size[0]) / 2D); // 封面章与底部机构名称重叠,随名称的实际排版位置移动。 double preferredTop = rectangle == null ? pageSetup.getPageHeight() - pageSetup.getBottomMargin() - size[1] - POSITION_GAP : rectangle.getCenterY() - size[1] / 2D; shape.setTop(clamp(preferredTop, 0D, pageSetup.getPageHeight() - size[1])); shape.setBehindText(true); anchor.appendChild(shape); } private static void addConclusionReportSeal(Document document, String imagePath) throws Exception { document.updatePageLayout(); LayoutCollector collector = new LayoutCollector(document); Paragraph anchor = findBodyParagraphContaining(document, "盖章", collector, null); if (anchor == null) { return; } Rectangle2D rectangle = getLayoutRectangle(document, collector, anchor); Section section = (Section) anchor.getAncestor(NodeType.SECTION); PageSetup pageSetup = section.getPageSetup(); int page = collector.getStartPageIndex(anchor); Paragraph placementAnchor = findPageBodyAnchor(document, collector, page); if (placementAnchor == null) { placementAnchor = anchor; } double[] size = fitImage(imagePath, REPORT_MAX_WIDTH, REPORT_MAX_HEIGHT); Shape shape = createFloatingImage(document, imagePath, size[0], size[1]); shape.setLeft((pageSetup.getPageWidth() - size[0]) / 2D); double preferredTop = rectangle == null ? pageSetup.getPageHeight() - pageSetup.getBottomMargin() - size[1] - 120D : rectangle.getY() - size[1] + rectangle.getHeight() / 2D; shape.setTop(clamp(preferredTop, pageSetup.getTopMargin(), pageSetup.getPageHeight() - pageSetup.getBottomMargin() - size[1])); // 结论章位于表格单元格内,必须置于文字上方,否则会被单元格底纹遮住。 shape.setBehindText(false); placementAnchor.appendChild(shape); } private static void addLastPageReportSeal(Document document, String imagePath) throws Exception { int lastPage = document.getPageCount(); LayoutCollector collector = new LayoutCollector(document); Paragraph title = findResultTitle(document); // 结果表可能跨页,章应跟随结果标题,不能跟随最后一页的“报告结束”。 Paragraph anchor = title == null ? findLastPageAnchor(document, collector, lastPage) : title; if (anchor == null) { return; } Section section = (Section) anchor.getAncestor(NodeType.SECTION); PageSetup pageSetup = section.getPageSetup(); double[] size = fitImage(imagePath, LAST_PAGE_MAX_WIDTH, LAST_PAGE_MAX_HEIGHT); Shape shape = createFloatingImage(document, imagePath, size[0], size[1]); shape.setLeft((pageSetup.getPageWidth() - size[0]) / 2D); Rectangle2D rectangle = getLayoutRectangle(document, collector, anchor); double preferredTop; if (title != null) { Paragraph company = findPageCompanyParagraph(document, collector, collector.getStartPageIndex(title)); Rectangle2D companyRectangle = company == null ? null : getLayoutRectangle(document, collector, company); // 章的中心对准机构名称与结果标题组成的区域,允许伸入上页边距。 double headingTop = companyRectangle == null || rectangle == null ? (rectangle == null ? pageSetup.getTopMargin() : rectangle.getY()) : Math.min(companyRectangle.getY(), rectangle.getY()); double headingBottom = rectangle == null ? headingTop : rectangle.getMaxY(); preferredTop = (headingTop + headingBottom - size[1]) / 2D; } else { preferredTop = rectangle == null ? pageSetup.getPageHeight() - pageSetup.getBottomMargin() - size[1] - POSITION_GAP : rectangle.getY() + rectangle.getHeight() + POSITION_GAP; } shape.setTop(clamp(preferredTop, title == null ? pageSetup.getTopMargin() : 0D, pageSetup.getPageHeight() - pageSetup.getBottomMargin() - size[1])); shape.setBehindText(true); anchor.appendChild(shape); } private static Shape createFloatingImage(Document document, String imagePath, double width, double height) throws Exception { Shape shape = new Shape(document, ShapeType.IMAGE); // Aspose 15 会把 Linux 的 / 开头路径当作相对路径,可能生成空图片且不报错。 // 通过流嵌入真实图片内容,避免路径被按文档的资源地址再次解析。 try (InputStream imageStream = Files.newInputStream(java.nio.file.Paths.get(imagePath))) { shape.getImageData().setImage(imageStream); } if (!shape.hasImage()) { throw new IllegalStateException("报告印章图片嵌入失败"); } shape.setWidth(width); shape.setHeight(height); shape.setWrapType(WrapType.NONE); shape.setRelativeHorizontalPosition(RelativeHorizontalPosition.PAGE); shape.setRelativeVerticalPosition(RelativeVerticalPosition.PAGE); shape.setAllowOverlap(true); return shape; } private static Paragraph firstBodyParagraph(Document document) { Paragraph paragraph = document.getFirstSection().getBody().getFirstParagraph(); if (paragraph != null) { return paragraph; } return document.getFirstSection().getBody().appendParagraph(""); } private static Paragraph findLastPageAnchor(Document document, LayoutCollector collector, int lastPage) throws Exception { Paragraph fallback = null; NodeCollection paragraphs = document.getChildNodes(NodeType.PARAGRAPH, true); for (int i = paragraphs.getCount() - 1; i >= 0; i--) { Paragraph paragraph = (Paragraph) paragraphs.get(i); if (paragraph.getAncestor(NodeType.BODY) == null) { continue; } if (collector.getStartPageIndex(paragraph) != lastPage) { continue; } if (fallback == null) { fallback = paragraph; } if (paragraph.getText().contains("报告结束")) { return paragraph; } } return fallback; } private static Paragraph findPageCompanyParagraph(Document document, LayoutCollector collector, int page) throws Exception { Paragraph company = null; NodeCollection paragraphs = document.getChildNodes(NodeType.PARAGRAPH, true); for (int i = 0; i < paragraphs.getCount(); i++) { Paragraph paragraph = (Paragraph) paragraphs.get(i); if (paragraph.getAncestor(NodeType.BODY) != null && paragraph.getAncestor(NodeType.CELL) == null && collector.getStartPageIndex(paragraph) == page && normalizedText(paragraph).endsWith("公司")) { company = paragraph; } } return company; } private static Paragraph findResultTitle(Document document) throws Exception { NodeCollection paragraphs = document.getChildNodes(NodeType.PARAGRAPH, true); for (int i = 0; i < paragraphs.getCount(); i++) { Paragraph paragraph = (Paragraph) paragraphs.get(i); if (paragraph.getAncestor(NodeType.BODY) == null || paragraph.getAncestor(NodeType.CELL) != null) { continue; } String text = normalizedText(paragraph); if ("检验结果".equals(text) || "检测结果".equals(text)) { return paragraph; } } return null; } private static String normalizedText(Paragraph paragraph) { return paragraph.getText().replaceAll("[\\s\\u3000]+", ""); } private static Paragraph findBodyParagraphContaining(Document document, String text, LayoutCollector collector, Integer page) throws Exception { NodeCollection paragraphs = document.getChildNodes(NodeType.PARAGRAPH, true); for (int i = 0; i < paragraphs.getCount(); i++) { Paragraph paragraph = (Paragraph) paragraphs.get(i); if (paragraph.getAncestor(NodeType.BODY) == null || !paragraph.getText().contains(text)) { continue; } if (page == null || collector.getStartPageIndex(paragraph) == page) { return paragraph; } } return null; } private static Paragraph findPageBodyAnchor(Document document, LayoutCollector collector, int page) throws Exception { NodeCollection paragraphs = document.getChildNodes(NodeType.PARAGRAPH, true); for (int i = 0; i < paragraphs.getCount(); i++) { Paragraph paragraph = (Paragraph) paragraphs.get(i); if (paragraph.getAncestor(NodeType.BODY) == null || paragraph.getAncestor(NodeType.CELL) != null) { continue; } if (collector.getStartPageIndex(paragraph) == page) { return paragraph; } } return null; } private static Rectangle2D getLayoutRectangle(Document document, LayoutCollector collector, Paragraph paragraph) throws Exception { Object entity = collector.getEntity(paragraph); if (entity == null) { NodeCollection runs = paragraph.getChildNodes(NodeType.RUN, true); for (int i = 0; i < runs.getCount() && entity == null; i++) { entity = collector.getEntity(runs.get(i)); } } if (entity == null) { return null; } LayoutEnumerator enumerator = new LayoutEnumerator(document); enumerator.setCurrent(entity); return enumerator.getRectangle(); } private static double clamp(double value, double min, double max) { return Math.max(min, Math.min(value, max)); } private static List imageShapes(Document document) throws Exception { List result = new ArrayList<>(); NodeCollection nodes = document.getChildNodes(NodeType.SHAPE, true); for (int i = 0; i < nodes.getCount(); i++) { Shape shape = (Shape) nodes.get(i); if (shape.hasImage()) { result.add(shape); } } return result; } private static boolean sameImage(Shape shape, byte[] imageBytes) throws Exception { return Arrays.equals(shape.getImageData().toByteArray(), imageBytes); } private static double[] fitImage(String imagePath, double maxWidth, double maxHeight) throws Exception { BufferedImage image = ImageIO.read(new ByteArrayInputStream(Files.readAllBytes( java.nio.file.Paths.get(imagePath)))); if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) { return new double[]{maxWidth, maxHeight}; } double scale = Math.min(maxWidth / image.getWidth(), maxHeight / image.getHeight()); return new double[]{image.getWidth() * scale, image.getHeight() * scale}; } }