近期在业务中遇到一个问题,即需要将一个html的文本转换成一个图片,并且需要保留图片的像素大小,上面涉及到的字体样式等等,最终通过了以下方案来实现。在 Java Spring Boot 框架中,结合 Selenium 实现 HTML 富文本转图片的方案,核心思路是利用 Selenium 控制浏览器渲染 HTML 内容,再通过截图功能将其转换为图片。
一、添加依赖
java
<!-- Selenium核心 -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.15.0</version>
</dependency>
<!-- ChromeDriver(需与本地Chrome版本匹配) -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-chrome-driver</artifactId>
<version>4.15.0</version>
</dependency>
二、实现步骤
1、下载符合当前环境的驱动包
一般都是需要本地windows和linux双环境的驱动包,会涉及到本地的调试功能(后续代码中会涉及到)
注:我当前linux环境容器的版本较低(而且没有完整的驱动包,需要在dockerfile中启动的时候就部署,但是会有一个rpm文件,文件我会和其他版本的一些下载链接附着在文章末尾),所以我个人使用的驱动包是比较老的版本但是不影响功能,各位在使用的时候可以按照环境下载比较新的版本的驱动,兼容性会更好一些
2、代码实现
1、先创建驱动实例
java
@Slf4j
@Configuration
public class SeleniumPoolConfig {
// 配置参数(可根据服务器性能调整)
private static final int POOL_SIZE = 2; // 浏览器实例数量
private static final long TIMEOUT_SECONDS = 30; // 获取实例超时时间
private final String driverPath;
private final String chromePath;
private final String osName;
// 维护WebDriver实例及状态(true=空闲,false=占用)
private final ConcurrentHashMap<WebDriver, Boolean> driverPool = new ConcurrentHashMap<>();
// 用于等待空闲实例的信号量
private final Semaphore semaphore = new Semaphore(POOL_SIZE);
// 初始化驱动路径(构造器执行,早于@Bean)
public SeleniumPoolConfig() {
this.osName = System.getProperty("os.name").toLowerCase();
log.info("操作系统: {}", osName);
// 初始化驱动路径
if (osName.contains("windows")) {
String path = Objects.requireNonNull(getClass().getClassLoader().getResource("chromeDriver/chromedriver.exe")).getPath();
this.driverPath = path.replaceFirst("file:/", "").replace("%20", " ");
this.chromePath = null;
} else {
this.driverPath = "/opt/chromedriver/chromedriver";
this.chromePath = "/opt/google/chrome/chrome";
}
System.setProperty("webdriver.chrome.driver", driverPath);
log.info("浏览器驱动路径: {}", driverPath);
log.info("浏览器可执行文件路径: {}", chromePath);
}
/**
* 初始化WebDriver池(应用启动时执行)
*/
@PostConstruct
public void initDriverPool() {
// 先清理残留进程
if (osName.contains("linux")) {
cleanUpLinuxChromeProcesses();
}
// 预热浏览器实例
for (int i = 0; i < POOL_SIZE; i++) {
try {
WebDriver driver = createDriver();
driverPool.put(driver, true); // 标记为空闲
log.info("初始化浏览器池实例 {} 成功", i + 1);
} catch (Exception e) {
log.error("初始化浏览器池实例 {} 失败", i + 1, e);
throw new RuntimeException("浏览器池初始化失败", e);
}
}
}
/**
* 获取空闲的WebDriver实例(核心方法)
*/
public WebDriver borrowDriver() throws Exception {
// 获取信号量许可(超时则抛出异常)
if (!semaphore.tryAcquire(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
throw new RuntimeException("获取浏览器实例超时,当前所有实例均在使用中");
}
try {
// 循环查找空闲实例
while (true) {
for (Map.Entry<WebDriver, Boolean> entry : driverPool.entrySet()) {
// 原子操作:如果是空闲状态,则标记为占用
if (entry.getValue() && driverPool.replace(entry.getKey(), true, false)) {
WebDriver driver = entry.getKey();
// 验证实例是否可用
if (isDriverValid(driver)) {
log.info("获取到空闲浏览器实例: {}", driver);
return driver;
} else {
// 实例无效,销毁并重建
log.info("浏览器实例 {} 已失效,重建中...", driver);
destroyDriver(driver);
WebDriver newDriver = createDriver();
driverPool.put(newDriver, false);
return newDriver;
}
}
}
// 暂时没有空闲实例,等待100ms重试
Thread.sleep(100);
}
} catch (Exception e) {
semaphore.release(); // 释放信号量
throw e;
}
}
/**
* 归还WebDriver实例到池(使用后必须调用)
*/
public void returnDriver(WebDriver driver) {
if (driver == null) return;
try {
// 重置浏览器状态(清除缓存、Cookie等)
driver.manage().deleteAllCookies();
driver.navigate().to("about:blank"); // 跳转到空白页
// 标记为空闲
driverPool.replace(driver, false, true);
log.info("浏览器实例 {} 已归还", driver);
} catch (Exception e) {
log.error("归还浏览器实例失败,将销毁该实例", e);
destroyDriver(driver);
try {
// 重建实例补充到池
WebDriver newDriver = createDriver();
driverPool.put(newDriver, true);
} catch (Exception ex) {
log.error("重建浏览器实例失败", ex);
}
} finally {
semaphore.release(); // 释放信号量
}
}
/**
* 创建新的WebDriver实例
*/
private WebDriver createDriver() throws Exception {
ChromeOptions options = new ChromeOptions();
if (StringUtils.isNotBlank(chromePath)) {
options.setBinary(chromePath);
}
List<String> args = new ArrayList<>();
args.add("--memory-limit=1024000"); // 限制最大内存为 1024MB(单位:KB) 避免无限占用内存导致的内存溢出,影响主服务
args.add("--headless=new");
args.add("--disable-gpu");
args.add("--window-size=1920,1080");
args.add("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36");
if (osName.contains("linux")) {
args.add("--enable-features=VizDisplayCompositor"); // 强制走合成器
args.add("--disable-software-rasterizer"); // 禁止退回 CPU 光栅
args.add("--use-gl=swiftshader-webgl");
args.add("--disable-dev-shm-usage");
args.add("--no-sandbox");
args.add("--no-user-data-dir");
args.add("--single-process");
}
options.addArguments(args);
// 多尝试机制
int maxAttempts = 2;
for (int i = 0; i < maxAttempts; i++) {
try {
return new ChromeDriver(options);
} catch (Exception e) {
log.info("第{}次创建浏览器实例失败,重试中...", i + 1, e);
if (i == maxAttempts - 1) {
throw new RuntimeException("创建浏览器实例失败", e);
}
Thread.sleep(1000);
}
}
throw new RuntimeException("创建浏览器实例失败");
}
/**
* 验证WebDriver实例是否有效
*/
private boolean isDriverValid(WebDriver driver) {
try {
// 执行简单命令验证实例是否存活
driver.getCurrentUrl();
return true;
} catch (Exception e) {
log.error("浏览器实例已失效", e);
return false;
}
}
/**
* 销毁WebDriver实例
*/
private void destroyDriver(WebDriver driver) {
if (driver == null) return;
try {
driver.quit(); // 优雅关闭
log.info("浏览器实例 {} 已销毁", driver);
} catch (Exception e) {
log.info("销毁浏览器实例失败", e);
} finally {
driverPool.remove(driver);
}
}
/**
* 应用关闭时清理资源
*/
@PreDestroy
public void destroyAllDrivers() {
log.info("开始清理所有浏览器实例...");
driverPool.keySet().forEach(this::destroyDriver);
driverPool.clear();
log.info("所有浏览器实例清理完成");
}
private void cleanUpLinuxChromeProcesses() {
try {
log.info("开始清理Linux残留Chrome进程");
Process process = Runtime.getRuntime().exec("pgrep -f 'chrome|chromedriver'");
process.waitFor(2, TimeUnit.SECONDS);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { // 明确字符集
String pidLine;
while ((pidLine = reader.readLine()) != null) {
String pid = pidLine.trim();
if (pid.isEmpty()) {
log.warn("跳过空PID行");
continue;
}
// 1. 校验PID合法性(数字+范围)
if (!isValidPid(pid)) {
log.warn("发现无效PID: {}, 已跳过", pid);
continue;
}
// 2. 安全验证是否为目标进程
if (!isTargetProcess(pid)) {
log.warn("进程PID: {} 非目标进程,已跳过", pid);
continue;
}
// 3. 安全杀死进程
killProcess(pid);
}
} catch (IOException e) {
log.error("读取进程输出失败", e);
}
log.info("Linux进程清理完成");
} catch (Exception e) {
log.info("Linux进程清理异常(非致命)", e);
}
}
// 验证PID是否为纯数字且在合理范围(1到系统最大PID)
private boolean isValidPid(String pid) {
if (!pid.matches("^\\d+$")) {
return false;
}
try {
long pidNum = Long.parseLong(pid);
// Linux系统PID通常从1开始,最大可通过/proc/sys/kernel/pid_max获取(默认32768)
return pidNum >= 1 && pidNum <= 32768;
} catch (NumberFormatException e) {
return false;
}
}
// 执行kill命令时增加异常处理和结果校验
private void killProcess(String pid) {
try {
Process killProcess = new ProcessBuilder("kill", "-9", pid)
.redirectErrorStream(true)
.start();
boolean exitCode = killProcess.waitFor(1, TimeUnit.SECONDS);
if (!exitCode) {
// 读取错误信息
String error = new BufferedReader(
new InputStreamReader(killProcess.getInputStream(), StandardCharsets.UTF_8))
.lines().collect(Collectors.joining("\n"));
log.error("杀死进程PID: {} 失败,退出码: {},错误: {}", pid, exitCode, error);
} else {
log.info("成功杀死进程PID: {}", pid);
}
} catch (Exception e) {
log.error("杀死进程PID: {} 时发生异常", pid, e);
}
}
/**
* 安全验证进程是否为目标进程
* @param pid 已校验为纯数字的进程ID
* @return 是否为目标进程
*/
private boolean isTargetProcess(String pid) {
try {
// 1. 用数组传参避免命令注入,查询进程详情
Process process = new ProcessBuilder("ps", "-p", pid, "-o", "comm=") // 只输出进程名
.redirectErrorStream(true) // 合并错误流到输入流
.start();
// 2. 读取进程名并校验(假设目标进程名为"target-process")
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String comm = reader.readLine();
// 3. 确保进程存在且名称匹配(根据实际场景调整匹配逻辑)
return comm != null && "target-process".equals(comm.trim());
} finally {
process.waitFor(1, TimeUnit.SECONDS); // 等待进程结束,避免资源泄漏
}
} catch (Exception e) {
log.warn("验证进程PID: {} 失败", pid, e);
return false; // 验证失败则视为非目标进程
}
}
/**
* 每天凌晨1点重新初始化所有浏览器实例(清理长期运行的实例)
* 浏览器会持续占用内存,可每天重新初始化实例释放内存
* cron表达式:秒 分 时 日 月 周(0 0 1 * * ? 表示每天1点0分0秒)
*/
@XxlJob("reinitializeDriversAtMidnight")
public void reinitializeDriversAtMidnight() {
log.info("开始执行凌晨1点实例重建任务...");
// 1. 等待所有实例归还(避免中断正在执行的任务)
try {
// 尝试获取所有信号量许可(若有实例在使用,会阻塞直到归还)
// 超时时间设为10分钟(根据业务最大任务耗时调整)
boolean allReleased = semaphore.tryAcquire(POOL_SIZE, 10, TimeUnit.MINUTES);
if (!allReleased) {
log.warn("等待10分钟后仍有实例未归还,强制重建(可能影响部分任务)");
}
} catch (InterruptedException e) {
log.error("等待实例归还时被中断", e);
Thread.currentThread().interrupt();
return;
}
try {
// 2. 销毁所有旧实例
log.info("销毁所有旧实例...");
driverPool.keySet().forEach(this::destroyDriver);
driverPool.clear();
// 3. 重建新实例
log.info("开始重建新实例...");
for (int i = 0; i < POOL_SIZE; i++) {
WebDriver newDriver = createDriver();
driverPool.put(newDriver, true);
log.info("重建实例 {} 成功", i + 1);
}
log.info("凌晨1点实例重建任务完成");
} catch (Exception e) {
log.error("实例重建失败", e);
// 若重建失败,尝试恢复至少1个实例保证基本可用
try {
WebDriver fallbackDriver = createDriver();
driverPool.put(fallbackDriver, true);
log.info("重建失败后,恢复1个备用实例");
} catch (Exception ex) {
log.error("备用实例恢复失败", ex);
}
} finally {
// 4. 释放所有信号量许可(重置为初始状态)
int permitsToRelease = POOL_SIZE - semaphore.availablePermits();
if (permitsToRelease > 0) {
semaphore.release(permitsToRelease);
}
}
}
}
2、转图片方法
java
@Slf4j
public class Test {
@Autowired
private SeleniumPoolConfig driverPool;
/**
* 将HTML富文本转换为图片入口
* @param htmlContent HTML富文本内容
* @return 图片路径
*/
public void convertHtmlToImage(String htmlContent) {
WebDriver webDriver = null;
File tempHtmlFile = null;
// 总耗时起点
long totalStartTime = System.currentTimeMillis();
log.info("===== HTML转图片处理开始 =====");
try {
// 从池里获取浏览器实例
long borrowDriverStartTime = System.currentTimeMillis();
webDriver = driverPool.borrowDriver();
log.info("获取浏览器实例耗时: {}ms", System.currentTimeMillis() - borrowDriverStartTime);
// 1. 创建临时HTML文件并加载
tempHtmlFile = createTempHtmlFile(htmlContent);
webDriver.get("file:///" + tempHtmlFile.getAbsolutePath().replace("\\", "/"));
// 1. 等字体加载完成
long waitFontsReadyStartTime = System.currentTimeMillis();
((JavascriptExecutor) webDriver).executeScript("return document.fonts.ready");
// 2. 等一次合成帧
((JavascriptExecutor) webDriver).executeScript("return new Promise(r => requestAnimationFrame(r))");
// 2. 等待页面完全渲染(针对动态内容,如JS加载的元素)
Thread.sleep(200);
log.info("等字体加载完成耗时: {}ms", System.currentTimeMillis() - waitFontsReadyStartTime);
// 4. 调整浏览器窗口大小以适配内容(加100避免边缘截断)
webDriver.manage().window().setSize(new Dimension(3840, 2160));
// 5. 再次等待窗口调整完成
Thread.sleep(100);
// 6. 截取完整页面截图
File targetFile = ((TakesScreenshot) webDriver).getScreenshotAs(OutputType.FILE);
// 7. 这里可以对文件进行处理,例如保存到文件服务器等等
}catch (Exception e){
log.error("图片生成失败, 异常信息: {}", e.getMessage(), e);
throw new BusinessException("图片生成失败");
}finally {
//清理临时文件
if (tempHtmlFile != null) {
tempHtmlFile.delete();
}
// 归还实例到池(必须执行)
if (webDriver != null) {
driverPool.returnDriver(webDriver);
}
}
}
/**
* 创建临时HTML文件,用于承载富文本内容
*/
private File createTempHtmlFile(String htmlContent) throws IOException {
// 创建临时文件
File tempFile = File.createTempFile("temp_html_", ".html");
// 设置JVM退出时自动删除临时文件
tempFile.deleteOnExit();
// 写入HTML内容
FileUtils.writeStringToFile(tempFile, htmlContent, StandardCharsets.UTF_8);
return tempFile;
}
}
三、部署
在服务部署的时候,可根据实际情况来处理,目前我的环境是采用dockerfile来部署的,所以需要先建立一个dockerfile文件,用于启动前的包部署和需要的字体等等一些的初始化,各位可依据自身环境来处理即可(任何问题可留言讨论。。。。)
四、驱动下载
附(整理了一些版本比较齐全的可以下载驱动包的路径):
1、linux版本下载地址 : https://wojc.cn/archives/1195.html
2、https://pan.quark.cn/s/ccf4fe478b04#/list/share/5da77429824943e4ac1e5507d8858801
3、https://blog.csdn.net/weixin_43873210/article/details/145723166