springboot 之 HTML与图片生成 (3)

背景

之前写了两篇关于HTML转图片的文章,使用过的都应该发现存在问题。

  1. 有些HTML或者css特性不支持,因为使用的依赖库太老了。
  2. 生成后的图片像素差,稍微放大就发现模糊。

鉴于上面的问题,被内外部使用人员多次吐槽,后面因为有了AI,故再次优化项目的此功能,经测试后发现完美将HTML转为PNG图片,这里记录下。

要求

写一个库,以java文件方式或者jar包方式或者maven依赖方式调用,直接对给定HTML内容生成PNG图片

项目介绍:html2png

主要功能

将 HTML 内容渲染成高质量 PNG 图片

实现方式

全部逻辑集中在 HtmlToPng.java,核心机制如下:

1. 真实浏览器渲染(Playwright + Chromium/Edge)

  • 使用 Playwright for Java 启动无头浏览器
  • 优先复用系统已安装的浏览器(Edge > Chrome > Brave > Chromium),支持 Win/macOS/Linux
  • 设备像素比 2.0(Retina 清晰度)

2. 远程图片内联下载

  • 渲染前用正则扫描 <img> 标签,把 http(s):// 远程图片下载到本地临时目录并替换路径,避免跨域/网络失败

3. 智能尺寸测量(核心亮点)

  • 先用宽视口(1280×800)加载 HTML,让内容按设计宽度自然展开
  • 通过浏览器内核 getBoundingClientRect() + getComputedStyle() 测量目标元素(.receipt-container 或 body 第一个子元素)的真实渲染尺寸
  • 按实际宽度重设视口,让元素贴左对齐,消除右侧空白
  • 再次测量后精确截图,避免因宽视口导致的空白

4. 精确裁剪

  • 截图区域自动加 16px padding
  • 找不到目标元素时兜底:全页截图 + Java AWT 白边裁剪(颜色容差 15)

5. 临时资源清理

  • 带重试(5 次指数退避)确保临时 HTML 和图片资源被清理

技术栈

组件 技术
语言 Java 17
渲染引擎 Playwright(无头浏览器)
图片裁剪 Java AWT (BufferedImage)
HTTP 下载 Java 11+ HttpClient
打包 Maven Shade Plugin(可执行 fat-jar)

一句话总结:用真实浏览器引擎渲染 HTML,让浏览器自己测量内容尺寸,再精确截图成 PNG,确保 CSS、字体、图片都准确呈现,且无多余空白。

以上介绍内容由 AI 生成

相关代码

依赖pom

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>html2png</artifactId>
    <version>5.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>html2png</name>
    <description>HTML-to-PNG converter (Playwright real browser engine)</description>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.microsoft.playwright</groupId>
            <artifactId>playwright</artifactId>
            <version>1.48.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
                <configuration>
                    <release>17</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.6.0</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>com.example.HtmlToPng</mainClass>
                                </transformer>
                            </transformers>
                            <filters>
                                <filter>
                                    <artifact>*:*</artifact>
                                    <excludes>
                                        <exclude>META-INF/*.SF</exclude>
                                        <exclude>META-INF/*.DSA</exclude>
                                        <exclude>META-INF/*.RSA</exclude>
                                    </excludes>
                                </filter>
                            </filters>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

函数代码

java 复制代码
package com.example;

import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.options.ScreenshotType;
import com.microsoft.playwright.options.WaitUntilState;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class HtmlToPng {

    private static final double DEVICE_SCALE_FACTOR = 2.0;
    private static final int PADDING_PX = 16;

    /**
     * Convert HTML content string to a PNG image file using real browser engine.
     *
     * @param htmlContent   the HTML source to render
     * @param outputPngPath the destination PNG file path
     * @return true if the PNG was generated successfully, false otherwise
     */
    public static boolean htmlToPng(String htmlContent, String outputPngPath) {
        if (htmlContent == null || htmlContent.isEmpty()
                || outputPngPath == null || outputPngPath.isEmpty()) {
            System.err.println("Invalid input");
            return false;
        }

        Path outputPng;
        try {
            outputPng = Paths.get(outputPngPath).toAbsolutePath().normalize();
            Path parent = outputPng.getParent();
            if (parent != null)
                Files.createDirectories(parent);
        } catch (Exception e) {
            System.err.println("Invalid output path: " + e.getMessage());
            return false;
        }

        Path assetsDir = null;
        Path tempHtml = null;
        boolean success = false;

        try {
            assetsDir = Files.createTempDirectory("html2png_assets_");

            htmlContent = inlineRemoteImages(htmlContent, assetsDir);

            tempHtml = Files.createTempFile("html2png_", ".html");
            Files.writeString(tempHtml, htmlContent);
            String fileUrl = tempHtml.toUri().toString();

            try (Playwright playwright = Playwright.create()) {
                Browser browser = launchBrowser(playwright);
                try {
                    // 先用足够宽的视口加载,确保内容按设计宽度自然展开
                    Page page = browser.newPage(new Browser.NewPageOptions()
                            .setViewportSize(1280, 800)
                            .setDeviceScaleFactor(DEVICE_SCALE_FACTOR));

                    page.navigate(fileUrl, new Page.NavigateOptions()
                            .setWaitUntil(WaitUntilState.NETWORKIDLE)
                            .setTimeout(30000));

                    captureScreenshot(page, outputPng);
                    page.close();
                } finally {
                    browser.close();
                }
            }

            System.out.println("PNG saved: " + outputPng);
            success = Files.exists(outputPng) && Files.size(outputPng) > 0;
            return success;
        } catch (Exception e) {
            System.err.println("Error converting HTML to PNG: " + e.getMessage());
            e.printStackTrace();
            return false;
        } finally {
            cleanupTempFiles(tempHtml, assetsDir);
        }
    }

    private static Browser launchBrowser(Playwright playwright) {
        BrowserType.LaunchOptions opts = new BrowserType.LaunchOptions()
                .setHeadless(true)
                .setArgs(Arrays.asList(
                        "--headless=new",
                        "--disable-gpu",
                        "--no-sandbox",
                        "--disable-setuid-sandbox",
                        "--disable-dev-shm-usage",
                        "--allow-file-access-from-files"));

        Path browserPath = detectBrowserPath();
        if (browserPath != null) {
            opts.setExecutablePath(browserPath);
            System.out.println("Using local browser: " + browserPath);
        } else {
            System.out.println("No local browser found, using Playwright bundled Chromium.");
        }

        return playwright.chromium().launch(opts);
    }

    private static Path detectBrowserPath() {
        String override = System.getProperty("html2png.browser.path");
        if (override == null || override.isEmpty()) {
            override = System.getenv("HTML2PNG_BROWSER_PATH");
        }
        if (override != null && !override.isEmpty()) {
            Path p = Paths.get(override);
            if (Files.exists(p))
                return p;
            System.err.println("Configured browser path does not exist: " + override);
        }

        String os = System.getProperty("os.name", "").toLowerCase();
        String pf = System.getenv("ProgramFiles");
        String pf86 = System.getenv("ProgramFiles(X86)");
        String localAppData = System.getenv("LOCALAPPDATA");
        String[] candidates;

        if (os.contains("win")) {
            candidates = new String[] {
                    pf86 + "\\Microsoft\\Edge\\Application\\msedge.exe",
                    pf + "\\Microsoft\\Edge\\Application\\msedge.exe",
                    localAppData + "\\Microsoft\\Edge\\Application\\msedge.exe",
                    pf + "\\Google\\Chrome\\Application\\chrome.exe",
                    pf86 + "\\Google\\Chrome\\Application\\chrome.exe",
                    localAppData + "\\Google\\Chrome\\Application\\chrome.exe",
                    pf + "\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
                    pf + "\\Chromium\\Application\\chromium.exe"
            };
        } else if (os.contains("mac")) {
            candidates = new String[] {
                    "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
                    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
                    "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
                    "/Applications/Chromium.app/Contents/MacOS/Chromium"
            };
        } else {
            candidates = new String[] {
                    getLinuxCandidate("microsoft-edge"),
                    getLinuxCandidate("microsoft-edge-stable"),
                    getLinuxCandidate("google-chrome"),
                    getLinuxCandidate("google-chrome-stable"),
                    getLinuxCandidate("chromium"),
                    getLinuxCandidate("chromium-browser"),
                    getLinuxCandidate("brave-browser"),
                    "/opt/google/chrome/chrome",
                    "/opt/chromium/chromium"
            };
        }

        for (String path : candidates) {
            if (path == null || path.isEmpty())
                continue;
            try {
                Path p = Paths.get(path);
                if (Files.exists(p))
                    return p;
            } catch (Exception ignored) {
            }
        }
        return null;
    }

    private static String getLinuxCandidate(String name) {
        for (String dir : new String[] { "/usr/bin", "/usr/local/bin", "/snap/bin" }) {
            Path p = Paths.get(dir, name);
            if (Files.exists(p))
                return p.toString();
        }
        return null;
    }

    private static void captureScreenshot(Page page, Path outputPng) throws Exception {
        // 第一次测量:用足够宽的视口让内容自然展开
        page.waitForLoadState();
        try {
            page.waitForFunction("document.fonts && document.fonts.ready", null,
                    new Page.WaitForFunctionOptions().setTimeout(5000));
        } catch (Exception ignored) {
        }

        // 通过 JS 让浏览器测量目标元素的实际渲染尺寸
        String measureJs = "(function() {"
                + "  var target = document.querySelector('.receipt-container')"
                + "    || (document.body.firstElementChild || document.body);"
                + "  if (!target) return null;"
                + "  var r = target.getBoundingClientRect();"
                + "  var cs = getComputedStyle(target);"
                + "  return {"
                + "    x: r.left,"
                + "    y: r.top,"
                + "    width: Math.ceil(r.width),"
                + "    height: Math.ceil(r.height"
                + "      + parseFloat(cs.marginTop || '0') + parseFloat(cs.marginBottom || '0'))"
                + "  };"
                + "})()";

        var result = page.evaluate(measureJs);
        Double x = null, y = null, width = null, height = null;
        if (result instanceof Map) {
            Map<?, ?> m = (Map<?, ?>) result;
            x = toDouble(m.get("x"));
            y = toDouble(m.get("y"));
            width = toDouble(m.get("width"));
            height = toDouble(m.get("height"));
        }

        if (width != null && height != null && width > 0 && height > 0) {
            // 关键:按元素实际宽度重设视口(含 padding),重新渲染
            // 这样元素会贴左对齐,避免因宽视口导致的右侧大片空白
            int targetWidth = (int) Math.ceil(width) + 2 * PADDING_PX;
            int targetHeight = (int) Math.ceil(height) + 2 * PADDING_PX;
            page.setViewportSize(targetWidth, targetHeight);

            // 等待重排稳定后重新测量(重设视口后位置会变化)
            page.waitForLoadState();
            try {
                Thread.sleep(200);
            } catch (InterruptedException ignored) {
            }

            var result2 = page.evaluate(measureJs);
            if (result2 instanceof Map) {
                Map<?, ?> m2 = (Map<?, ?>) result2;
                x = toDouble(m2.get("x"));
                y = toDouble(m2.get("y"));
                width = toDouble(m2.get("width"));
                height = toDouble(m2.get("height"));
            }

            double px = x != null ? x : 0;
            double py = y != null ? y : 0;
            // 截图区域:从元素左上角开始,包含 padding
            double clipX = Math.max(0, px - PADDING_PX);
            double clipY = Math.max(0, py - PADDING_PX);
            double clipW = width + 2 * PADDING_PX;
            double clipH = height + 2 * PADDING_PX;

            System.out.println("Measured target: " + width + "x" + height
                    + " at (" + px + "," + py + "), clip=" + clipW + "x" + clipH);

            page.screenshot(new Page.ScreenshotOptions()
                    .setPath(outputPng)
                    .setClip(clipX, clipY, clipW, clipH)
                    .setType(ScreenshotType.PNG));
            return;
        }

        // 兜底:整页截图 + 裁白边
        page.screenshot(new Page.ScreenshotOptions()
                .setPath(outputPng)
                .setFullPage(true)
                .setType(ScreenshotType.PNG));
        trimWhitespace(outputPng, PADDING_PX);
    }

    private static Double toDouble(Object o) {
        if (o instanceof Number)
            return ((Number) o).doubleValue();
        return null;
    }

    private static final Pattern IMG_TAG_PATTERN = Pattern.compile(
            "<img\\s+([^>]*?)src\\s*=\\s*[\"']([^\"']+)[\"'][^>]*?>",
            Pattern.CASE_INSENSITIVE);

    private static String inlineRemoteImages(String html, Path assetsDir) {
        Map<String, String> cache = new HashMap<>();
        Matcher m = IMG_TAG_PATTERN.matcher(html);
        StringBuffer sb = new StringBuffer();

        while (m.find()) {
            String fullTag = m.group(0);
            String src = m.group(2);

            if (src.startsWith("http://") || src.startsWith("https://")) {
                String localPath = cache.computeIfAbsent(src, u -> downloadImage(u, assetsDir));
                String replacement = fullTag.replace(src, localPath);
                m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
            } else {
                m.appendReplacement(sb, Matcher.quoteReplacement(fullTag));
            }
        }
        m.appendTail(sb);
        return sb.toString();
    }

    private static String downloadImage(String url, Path assetsDir) {
        try {
            URI uri = URI.create(url);
            String path = uri.getPath();
            String fileName = (path == null || path.isEmpty()) ? "img.bin"
                    : path.substring(path.lastIndexOf('/') + 1);
            if (fileName.isEmpty())
                fileName = "img.bin";
            Path target = assetsDir.resolve(System.currentTimeMillis() + "_" + fileName);

            HttpClient client = HttpClient.newHttpClient();
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(uri)
                    .timeout(java.time.Duration.ofSeconds(15))
                    .GET()
                    .build();
            HttpResponse<InputStream> response = client.send(request,
                    HttpResponse.BodyHandlers.ofInputStream());
            try (InputStream is = response.body()) {
                Files.copy(is, target, StandardCopyOption.REPLACE_EXISTING);
            }
            return target.toUri().toString();
        } catch (Exception e) {
            System.err.println("Failed to download " + url + ": " + e.getMessage());
            return url;
        }
    }

    private static void trimWhitespace(Path imagePath, int padding) throws Exception {
        BufferedImage img = ImageIO.read(imagePath.toFile());
        if (img == null)
            return;
        int w = img.getWidth(), h = img.getHeight();
        int bg = img.getRGB(0, 0), tol = 15;

        int top = 0;
        outer: for (int y = 0; y < h; y++)
            for (int x = 0; x < w; x++)
                if (!colorClose(img.getRGB(x, y), bg, tol)) {
                    top = y;
                    break outer;
                }
        int bottom = h - 1;
        outer: for (int y = h - 1; y >= 0; y--)
            for (int x = 0; x < w; x++)
                if (!colorClose(img.getRGB(x, y), bg, tol)) {
                    bottom = y;
                    break outer;
                }
        int left = 0;
        outer: for (int x = 0; x < w; x++)
            for (int y = 0; y < h; y++)
                if (!colorClose(img.getRGB(x, y), bg, tol)) {
                    left = x;
                    break outer;
                }
        int right = w - 1;
        outer: for (int x = w - 1; x >= 0; x--)
            for (int y = 0; y < h; y++)
                if (!colorClose(img.getRGB(x, y), bg, tol)) {
                    right = x;
                    break outer;
                }

        top = Math.max(0, top - padding);
        left = Math.max(0, left - padding);
        bottom = Math.min(h - 1, bottom + padding);
        right = Math.min(w - 1, right + padding);

        int nw = right - left + 1, nh = bottom - top + 1;
        if (nw > 0 && nh > 0 && nw < w && nh < h) {
            BufferedImage cropped = img.getSubimage(left, top, nw, nh);
            ImageIO.write(cropped, "png", imagePath.toFile());
        }
    }

    private static boolean colorClose(int a, int b, int t) {
        int r1 = (a >> 16) & 0xFF, g1 = (a >> 8) & 0xFF, b1 = a & 0xFF;
        int r2 = (b >> 16) & 0xFF, g2 = (b >> 8) & 0xFF, b2 = b & 0xFF;
        return Math.abs(r1 - r2) <= t && Math.abs(g1 - g2) <= t && Math.abs(b1 - b2) <= t;
    }

    private static void cleanupTempFiles(Path tempHtml, Path assetsDir) {
        int maxRetries = 5;
        long initialDelayMs = 300;

        deleteWithRetry(tempHtml, maxRetries, initialDelayMs);
        deleteWithRetry(assetsDir, maxRetries, initialDelayMs);
    }

    private static void deleteWithRetry(Path path, int maxRetries, long initialDelayMs) {
        if (path == null || !Files.exists(path))
            return;

        long delay = initialDelayMs;
        for (int attempt = 1; attempt <= maxRetries; attempt++) {
            try {
                if (attempt > 1) {
                    Thread.sleep(delay);
                    delay *= 2;
                }

                deleteRecursively(path);

                if (!Files.exists(path)) {
                    System.out.println("Cleanup: deleted " + path + " (attempt " + attempt + ")");
                    return;
                }
                System.out.println("Cleanup: still exists " + path + ", retrying...");
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                System.err.println("Cleanup: interrupted while deleting " + path);
                return;
            } catch (Exception e) {
                System.err.println("Cleanup: failed on attempt " + attempt + " for " + path + ": " + e.getMessage());
            }
        }

        if (Files.exists(path)) {
            System.err.println("Cleanup: WARNING - unable to delete " + path + " after " + maxRetries + " attempts");
            System.err.println("Cleanup: Please manually delete: " + path);
        }
    }

    private static void deleteRecursively(Path p) throws Exception {
        if (!Files.exists(p))
            return;

        if (Files.isDirectory(p)) {
            try (var walk = Files.walk(p)) {
                walk.sorted(java.util.Comparator.reverseOrder())
                        .forEach(x -> {
                            try {
                                Files.deleteIfExists(x);
                            } catch (Exception e) {
                                System.err.println("Cleanup: cannot delete " + x + ": " + e.getMessage());
                            }
                        });
            }
        } else {
            Files.deleteIfExists(p);
        }
    }

    public static void main(String[] args) {
        if (args.length > 0 && "installBrowsers".equalsIgnoreCase(args[0])) {
            System.out.println("To install Playwright bundled Chromium:");
            System.out.println("  Option 1: npx playwright install chromium");
            System.out.println("  Option 2: Use system-installed Chrome/Edge (auto-detected)");
            System.out.println("  Option 3: java -Dhtml2png.browser.path=/path/to/chrome -jar ...");
            return;
        }

        Path inputHtml = Paths.get("F:\\test", "bri-test-phone.html");
        // Path inputHtml = Paths.get("F:\\test", "bri-test-email.html");
        String html = readFully(inputHtml);
        String output = "F:\\test\\bri-test-phone.png";
        // String output = "F:\\test\\bri-test-email.png";

        long start = System.currentTimeMillis();
        boolean ok = htmlToPng(html, output);
        long elapsed = System.currentTimeMillis() - start;
        System.out.println("Result: " + (ok ? "SUCCESS" : "FAILED") + " (" + elapsed + " ms)");
    }

    private static String readFully(Path p) {
        try {
            return Files.readString(p);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

测试新旧版对比

旧版生成的图片:

新版生成的图片:

对比发现,第二种方式和浏览器打开html一样。

使用方法

具体使用方法查看下面不同环境下的文档内容,一下内容同样由AI根据项目生成的。

====================================================================

Windows 开发测试环境部署与使用指南

环境要求

软件 版本要求 下载地址
JDK 17+ https://adoptium.net/
Maven 3.8+ https://maven.apache.org/download.cgi
Chrome 或 Edge 90+ 系统通常已默认安装

步骤 1:安装 JDK 17

powershell 复制代码
# 方式 1:winget 安装(推荐)
winget install EclipseAdoptium.Temurin.17.JDK

# 方式 2:手动下载
# 访问 https://adoptium.net/ 下载 Windows x64 MSI 安装包

# 验证
java -version
# 输出:openjdk version "17.0.x" ...

步骤 2:配置环境变量

powershell 复制代码
# 新增系统变量
JAVA_HOME = C:\Program Files\Eclipse Adoptium\jdk-17.x.x

# Path 变量添加
%JAVA_HOME%\bin

步骤 3:安装 Maven

powershell 复制代码
# 方式 1:winget 安装
winget install Apache.Maven

# 方式 2:手动下载 https://maven.apache.org/download.cgi
# 解压到 C:\Dev\apache-maven-3.9.x

# 配置环境变量
MAVEN_HOME = C:\Dev\apache-maven-3.9.x
Path 添加:%MAVEN_HOME%\bin

# 验证
mvn -version

步骤 4:确认浏览器可用

Windows 通常预装了 Edge:

powershell 复制代码
# 检查 Edge
Test-Path "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"

# 检查 Chrome
Test-Path "C:\Program Files\Google\Chrome\Application\chrome.exe"

# 如需安装
winget install Microsoft.Edge
winget install Google.Chrome

Windows 默认已安装中文字体(Microsoft YaHei、SimSun),通常无需额外装字体。


API 使用方法

核心 API

java 复制代码
/**
 * 将 HTML 内容渲染为 PNG 图片(使用真实浏览器引擎)
 *
 * @param htmlContent   HTML 源码字符串
 * @param outputPngPath 输出 PNG 文件的路径
 * @return true=成功,false=失败
 */
public static boolean htmlToPng(String htmlContent, String outputPngPath)

方式 1:作为独立工具运行(命令行测试)

jar 包内置了 main 方法,可直接运行测试:

powershell 复制代码
# 使用默认配置(自动检测 Edge/Chrome)
java -jar target\html2png-5.0-SNAPSHOT.jar

# 指定浏览器路径
java -Dhtml2png.browser.path="C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" `
     -jar target\html2png-5.0-SNAPSHOT.jar

# 通过环境变量指定浏览器
$env:HTML2PNG_BROWSER_PATH = "C:\Program Files\Google\Chrome\Application\chrome.exe"
java -jar target\html2png-5.0-SNAPSHOT.jar

# 查看 Playwright 浏览器安装帮助
java -jar target\html2png-5.0-SNAPSHOT.jar installBrowsers

运行成功后会输出:

复制代码
Using local browser: C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe
Measured target: 542.0x731.0 at (16.0,8.0), clip=574.0x763.0
PNG saved: F:\test\bri-test-phone.png
Result: SUCCESS (xxxx ms)

方式 2:在你的项目中集成(推荐)

步骤 1:构建并安装到本地 Maven 仓库

powershell 复制代码
cd D:\projects\html2png
mvn clean install -DskipTests

install 会把 jar 放入本地 Maven 仓库(~/.m2/repository),供其它项目作为依赖引用。

步骤 2:在你的项目 pom.xml 中添加依赖

xml 复制代码
<dependency>
    <groupId>com.example</groupId>
    <artifactId>html2png</artifactId>
    <version>5.0-SNAPSHOT</version>
</dependency>

步骤 3:调用 API

java 复制代码
import com.example.HtmlToPng;

public class MyReceiptGenerator {
    public static void main(String[] args) {
        // 方式 A:直接传入 HTML 字符串
        String html = "<html><body><h1>电子收据</h1><p>金额:¥100.00</p></body></html>";
        boolean ok = HtmlToPng.htmlToPng(html, "D:\\output\\receipt.png");
        System.out.println("结果: " + (ok ? "成功" : "失败"));

        // 方式 B:从文件读取 HTML 再转换
        String htmlFromFile = java.nio.file.Files.readString(
            java.nio.file.Paths.get("D:\\template\\receipt.html"));
        HtmlToPng.htmlToPng(htmlFromFile, "D:\\output\\receipt.png");

        // 方式 C:在 Spring Boot 服务中调用
        // @PostMapping("/receipt/generate")
        // public byte[] generateReceipt(@RequestBody ReceiptData data) {
        //     String html = renderTemplate(data);  // 你的模板渲染逻辑
        //     String tmpPath = System.getProperty("java.io.tmpdir") + "\\" + UUID.randomUUID() + ".png";
        //     HtmlToPng.htmlToPng(html, tmpPath);
        //     return Files.readAllBytes(Paths.get(tmpPath));
        // }
    }
}

工作原理(问题排查必读)

程序按以下步骤工作,理解后便于排查问题:

  1. 下载远程图片 :扫描 HTML 中的 <img src="http...">,下载到本地临时目录并替换路径
  2. 宽视口加载:先用 1280×800 视口加载 HTML,让内容按设计宽度自然展开
  3. 等待字体加载 :等待 document.fonts.ready(最多 5 秒),确保字体加载完成
  4. 浏览器测量尺寸 :通过 getBoundingClientRect() 获取目标元素(.receipt-container 或 body 第一个子元素)的真实渲染尺寸
  5. 重设视口:按测量到的实际宽度重设视口,让元素贴左对齐,消除右侧空白
  6. 精确截图:按元素真实尺寸 + 16px padding 截图

目标元素选择优先级

  1. .receipt-container(业务约定容器)
  2. body 的第一个子元素
  3. body 自身

提示:如果你的 HTML 容器 class 不是 receipt-container,截图会取 body 第一个子元素的尺寸,通常也能正确工作。


构建项目

powershell 复制代码
cd D:\projects\html2png

# 编译打包(生成 target\html2png-5.0-SNAPSHOT.jar)
mvn clean package -DskipTests

# 安装到本地 Maven 仓库(供其它项目引用)
mvn clean install -DskipTests

# 产物位置
dir target\html2png-5.0-SNAPSHOT.jar

常见问题

Q1:浏览器找不到?

powershell 复制代码
# 方式 1:设置 JVM 参数
java -Dhtml2png.browser.path="C:\Path\To\chrome.exe" -jar your-app.jar

# 方式 2:设置环境变量
$env:HTML2PNG_BROWSER_PATH = "C:\Path\To\chrome.exe"
java -jar your-app.jar

Q2:中文显示方框?

Windows 默认已安装中文字体。如确实缺少,访问 https://fonts.google.com/noto/specimen/Noto+Sans+SC 下载安装。

Q3:Maven 依赖下载慢?

~/.m2/settings.xml 中配置阿里云镜像:

xml 复制代码
<mirrors>
    <mirror>
        <id>aliyun</id>
        <mirrorOf>central</mirrorOf>
        <url>https://maven.aliyun.com/repository/central</url>
    </mirror>
</mirrors>

Q4:截图右侧有大片空白?

程序已通过"重设视口"机制自动解决。如仍出现:

  • 确认 HTML 中目标容器(.receipt-container 或 body 第一个子元素)有明确的宽度设置
  • 查看控制台输出的 Measured target: WxH at (x,y) 日志,确认测量值是否合理

Q5:文件被占用 / Access denied?

  • 关闭占用文件的进程(如图片查看器)
  • 更换输出路径
  • 使用管理员权限运行

快捷命令汇总

powershell 复制代码
# 一键安装环境
winget install EclipseAdoptium.Temurin.17.JDK
winget install Apache.Maven

# 一键验证环境
java -version
mvn -version
Test-Path "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"

# 一键构建并测试
cd D:\projects\html2png
mvn clean package -DskipTests
java -jar target\html2png-5.0-SNAPSHOT.jar

====================================================================

Linux (Red Hat) 生产环境部署与使用指南

适用 Red Hat Enterprise Linux 7/8/9、CentOS 7/8、Rocky Linux 8/9、AlmaLinux 8/9。


环境要求

软件 版本要求
操作系统 RHEL 7+ / CentOS 7+ / Rocky 8+ / AlmaLinux 8+
JDK 17+
内存 ≥ 1GB(推荐 2GB)
磁盘 ≥ 500MB 可用空间

步骤 1:安装 JDK 17

bash 复制代码
# RHEL 8/9、Rocky、AlmaLinux
sudo dnf install -y java-17-openjdk java-17-openjdk-devel

# RHEL 7、CentOS 7
sudo yum install -y java-17-openjdk java-17-openjdk-devel

# 验证
java -version
# 输出:openjdk version "17.0.x" ...

如果默认仓库没有 JDK 17,使用 Adoptium 仓库:

bash 复制代码
sudo curl -o /etc/yum.repos.d/adoptium.repo \
  https://raw.githubusercontent.com/adoptium/temurin-repo/master/adoptium.repo
sudo dnf install -y temurin-17-jdk

步骤 2:安装浏览器

方式 A:安装 Google Chrome(推荐)

bash 复制代码
# RHEL 8/9
sudo dnf install -y https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm

# CentOS 7
sudo yum install -y https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm

# 验证
which google-chrome
google-chrome --version

方式 B:安装 Chromium

bash 复制代码
# RHEL 9
sudo dnf install -y epel-release
sudo dnf install -y chromium

# RHEL 8
sudo dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-8.noarch.rpm
sudo dnf install -y chromium

# CentOS 7
sudo yum install -y epel-release
sudo yum install -y chromium

# 验证
which chromium
chromium --version

步骤 3:安装多语言字体(关键)

为什么重要 :程序会等待 document.fonts.ready 后才测量元素尺寸。字体缺失会导致:

  • 中日韩等字符显示为方框(豆腐块)
  • 文字宽高计算错误,导致截图尺寸偏差、内容被裁剪
bash 复制代码
# 添加 EPEL 仓库
sudo dnf install -y epel-release   # RHEL 8+
sudo yum install -y epel-release   # CentOS 7

# 安装 CJK 中日韩字体
sudo dnf install -y google-noto-sans-cjk-fonts google-noto-sans-fonts

# 安装其他语言字体(按需)
sudo dnf install -y google-noto-sans-arabic-fonts       # 阿拉伯语
sudo dnf install -y google-noto-sans-devanagari-fonts   # 印地语
sudo dnf install -y google-noto-sans-thai-fonts        # 泰语
sudo dnf install -y google-noto-sans-vietnamese-fonts  # 越南语

# CentOS 7 可能包名不同
sudo yum install -y google-noto-sans-cjk-ttf google-noto-sans-ttf

# 刷新字体缓存
sudo fc-cache -fv

# 验证字体(建议 ≥ 200 个)
fc-list | grep -i noto | head -10
fc-list | wc -l

步骤 4:安装浏览器运行时依赖

bash 复制代码
sudo dnf install -y \
    nss-lib libxkbcommon libXcomposite libXdamage libXrandr \
    libgbm libXss libXtst libX11-xcb libxcb-util libxcb-keysyms \
    libxcb-image libasound libatspi libcups libdrm

# 如果报缺少某个库,用 provides 查找
sudo dnf provides "*/libxxx.so"

步骤 5:上传 jar 包

bash 复制代码
# 在 Windows 本地编译好 jar 后上传
scp target/html2png-5.0-SNAPSHOT.jar user@server:/opt/app/

# 服务器上创建目录
sudo mkdir -p /opt/app
sudo chown -R user:user /opt/app
cd /opt/app

API 使用方法

核心 API

java 复制代码
/**
 * 将 HTML 内容渲染为 PNG 图片(使用真实浏览器引擎)
 *
 * @param htmlContent   HTML 源码字符串
 * @param outputPngPath 输出 PNG 文件的路径
 * @return true=成功,false=失败
 */
public static boolean htmlToPng(String htmlContent, String outputPngPath)

方式 1:作为独立工具运行(命令行)

jar 包内置了 main 方法,可直接运行测试:

bash 复制代码
# 使用默认配置(自动检测系统浏览器)
java -jar html2png-5.0-SNAPSHOT.jar

# 指定浏览器路径
java -Dhtml2png.browser.path=/usr/bin/google-chrome \
     -jar html2png-5.0-SNAPSHOT.jar

# 通过环境变量指定浏览器
export HTML2PNG_BROWSER_PATH=/usr/bin/google-chrome
java -jar html2png-5.0-SNAPSHOT.jar

# 查看 Playwright 浏览器安装帮助
java -jar html2png-5.0-SNAPSHOT.jar installBrowsers

方式 2:在你的项目中集成(推荐)

步骤 1:安装到本地 Maven 仓库

在 html2png 项目目录执行:

bash 复制代码
mvn clean install -DskipTests

步骤 2:在你的项目 pom.xml 中添加依赖

xml 复制代码
<dependency>
    <groupId>com.example</groupId>
    <artifactId>html2png</artifactId>
    <version>5.0-SNAPSHOT</version>
</dependency>

步骤 3:调用 API

java 复制代码
import com.example.HtmlToPng;

public class MyReceiptGenerator {
    public static void main(String[] args) {
        // 方式 A:直接传入 HTML 字符串
        String html = "<html><body><h1>电子收据</h1><p>金额:¥100.00</p></body></html>";
        boolean ok = HtmlToPng.htmlToPng(html, "/tmp/receipt.png");
        System.out.println("结果: " + (ok ? "成功" : "失败"));

        // 方式 B:从文件读取 HTML 再转换
        String htmlFromFile = java.nio.file.Files.readString(
            java.nio.file.Paths.get("/data/template/receipt.html"));
        HtmlToPng.htmlToPng(htmlFromFile, "/data/output/receipt.png");

        // 方式 C:在 Spring Boot 服务中调用
        // public byte[] generateReceipt(ReceiptData data) {
        //     String html = renderTemplate(data);  // 你的模板渲染逻辑
        //     String tmpPath = "/tmp/" + UUID.randomUUID() + ".png";
        //     HtmlToPng.htmlToPng(html, tmpPath);
        //     return Files.readAllBytes(Paths.get(tmpPath));
        // }
    }
}

工作原理(问题排查必读)

程序按以下步骤工作,理解后便于排查问题:

  1. 下载远程图片 :扫描 HTML 中的 <img src="http...">,下载到本地临时目录并替换路径
  2. 宽视口加载:先用 1280×800 视口加载 HTML,让内容按设计宽度自然展开
  3. 等待字体加载 :等待 document.fonts.ready(最多 5 秒),确保字体加载完成
  4. 浏览器测量尺寸 :通过 getBoundingClientRect() 获取目标元素(.receipt-container 或 body 第一个子元素)的真实渲染尺寸
  5. 重设视口:按测量到的实际宽度重设视口,让元素贴左对齐,消除右侧空白
  6. 精确截图:按元素真实尺寸 + 16px padding 截图

目标元素选择优先级

  1. .receipt-container(业务约定容器)
  2. body 的第一个子元素
  3. body 自身

提示:如果你的 HTML 容器 class 不是 receipt-container,截图会取 body 第一个子元素的尺寸,通常也能正确工作。


配置 systemd 服务(生产推荐)

bash 复制代码
sudo cat > /etc/systemd/system/html2png.service << 'EOF'
[Unit]
Description=HTML to PNG Converter Service
After=network.target

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/app
ExecStart=/usr/bin/java \
    -Xms256m -Xmx1024m \
    -Dhtml2png.browser.path=/usr/bin/google-chrome \
    -Dfile.encoding=UTF-8 \
    -jar /opt/app/html2png-5.0-SNAPSHOT.jar
Restart=on-failure
RestartSec=10
MemoryMax=2G

NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/opt/app /tmp
ProtectHome=yes

[Install]
WantedBy=multi-user.target
EOF

# 创建运行用户并启动
sudo useradd -r -s /bin/false appuser
sudo chown -R appuser:appuser /opt/app
sudo systemctl daemon-reload
sudo systemctl start html2png
sudo systemctl enable html2png

# 检查状态与日志
sudo systemctl status html2png
sudo journalctl -u html2png -f

验证清单

部署完成后逐项验证:

bash 复制代码
# 1. JDK 版本应为 17.x
java -version

# 2. 浏览器可用
/usr/bin/google-chrome --version

# 3. 字体已安装(建议 ≥ 200)
fc-list | grep -i noto | wc -l

# 4. 运行时库完整(应无输出)
ldd /usr/bin/google-chrome 2>/dev/null | grep "not found"

# 5. 程序正常运行,应看到 "Result: SUCCESS"
java -jar /opt/app/html2png-5.0-SNAPSHOT.jar

常见问题排查

Q1:Chrome 启动报 "missing shared library"

bash 复制代码
ldd /usr/bin/google-chrome | grep "not found"
sudo dnf install -y nss-lib libXss libXtst ...
sudo dnf provides "*/libxxx.so"

Q2:root 用户启动报 "Running as root without --no-sandbox"

代码已自动添加 --no-sandbox 参数,无需额外处理。

Q3:中文字体显示方框 / 截图尺寸异常

bash 复制代码
fc-list | grep -i "noto.*cjk"
sudo dnf install -y google-noto-sans-cjk-fonts
sudo fc-cache -fv
sudo systemctl restart html2png

Q4:截图右侧有大片空白

这是视口未贴合内容导致。程序已通过"重设视口"机制自动解决。如仍出现:

  • 确认 HTML 中目标容器(.receipt-container 或 body 第一个子元素)有明确的宽度设置
  • 检查 document.fonts.ready 是否超时(日志会输出测量结果)

Q5:Chrome 崩溃或内存不足

bash 复制代码
# 在 systemd 服务文件中增加 JVM 内存
-Xms512m -Xmx2048m
# 代码已内置 --disable-dev-shm-usage --disable-gpu 参数

Q6:浏览器无法启动 "permission denied"

bash 复制代码
ls -la /usr/bin/google-chrome
sudo chmod 755 /usr/bin/google-chrome

# SELinux 问题
getenforce
sudo setenforce 0   # 临时关闭测试

Q7:离线环境部署

bash 复制代码
# 在联网机器下载 RPM
yumdownloader --resolve google-chrome-stable google-noto-sans-cjk-fonts nss-lib
tar czvf html2png-rpms.tar.gz *.rpm
scp html2png-rpms.tar.gz user@server:/tmp/

# 离线服务器安装
sudo rpm -ivh --force /tmp/*.rpm

Q8:版本兼容性

RHEL 版本 浏览器支持 字体支持 建议
RHEL 9 Chrome 120+ 完整 推荐生产版本
RHEL 8 Chrome 120+ 完整 推荐
RHEL 7 Chrome 109 部分 建议升级 RHEL 8+

一键部署脚本

保存为 deploy.sh,在服务器上执行:

bash 复制代码
#!/bin/bash
set -e

APP_DIR="/opt/app"
JAR_FILE="html2png-5.0-SNAPSHOT.jar"

echo "=== Step 1: Install JDK ==="
if ! command -v java &>/dev/null; then
    if command -v dnf &>/dev/null; then
        dnf install -y java-17-openjdk java-17-openjdk-devel
    else
        yum install -y java-17-openjdk java-17-openjdk-devel
    fi
fi

echo "=== Step 2: Install Chrome ==="
if ! command -v google-chrome &>/dev/null && ! command -v chromium &>/dev/null; then
    if command -v dnf &>/dev/null; then
        dnf install -y https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm || \
            dnf install -y chromium
    else
        yum install -y https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm || \
            yum install -y chromium
    fi
fi

echo "=== Step 3: Install Fonts ==="
if command -v dnf &>/dev/null; then
    dnf install -y google-noto-sans-cjk-fonts google-noto-sans-fonts 2>/dev/null || true
else
    yum install -y google-noto-sans-cjk-fonts google-noto-sans-fonts 2>/dev/null || true
fi
fc-cache -fv

echo "=== Step 4: Install Runtime Dependencies ==="
if command -v dnf &>/dev/null; then
    dnf install -y nss-lib libXcomposite libXdamage libXrandr libgbm libasound libdrm 2>/dev/null || true
else
    yum install -y nss-lib libXcomposite libXdamage libXrandr libgbm libasound libdrm 2>/dev/null || true
fi

echo "=== Step 5: Deploy Application ==="
mkdir -p $APP_DIR
cp $JAR_FILE $APP_DIR/ 2>/dev/null || { echo "请上传 jar 文件到 $APP_DIR/"; exit 1; }

echo "=== Step 6: Start Service ==="
java -jar $APP_DIR/$JAR_FILE &
echo $! > $APP_DIR/app.pid

echo ""
echo "=== Deployment Complete ==="
echo "App running in background, PID: $(cat $APP_DIR/app.pid)"

使用:

bash 复制代码
chmod +x deploy.sh
sudo ./deploy.sh