最近遇到开发需求,需要将SpringBoot后端的一个H5网页,转换为图片,并发送到指定的接口上。由于要考虑到要兼容各种CSS样式和AJAX请求的因素,因此用内嵌浏览器的实现方法往往会导致网页样式出不来。
因此思路是操作服务器本地的Chrome,访问网页再“截图”为图片。然而Java本身并没有合适的控制本地Chrome的API,而nodejs的puppeteer提供了可以控制headless chrome的API接口,其中就包括对网页进行截屏的API。因此,决定用Java控制nodejs完成网页转图片文件的操作,再用Java读取转换完的图片文件进行操作。 这里以ubuntu服务器为例说明步骤如下:

  1. 安装Chrome或Chromium。前者可以在Chrome官网下载deb包(Chrome官网),后者直接用apt-get就可以安装(具体步骤)。
  2. 安装nodejs,具体步骤可参照github上的说明。
  3. 将npm的库变更为淘宝的镜像。
  4. 运行以下命令安装puppeteer。如果直接安装puppeteer会自动再安装一个Chromium,为了避免这个问题可以只安装puppeteer-core。详细说明。
cnpm i puppeteer-core
  1. 用Chrome访问需要转图片的H5网页,检查字体是否正常。如果发现字体缺失,可以从windows/fonts里copy字体到服务器上。如果原字体文件的格式为ttc,需要转换成ttf,可以安装fontforge,具体操作为
apt-get install fontforge

然后用fontforge将ttc另存为ttf,并将文件copy到服务器的/usr/share/fonts/truetype/xxx,其中xxx文件夹可以自行创建,然后运行以下命令,系统可以自动检测/usr/share/fonts及子目录下的ttf文件,刷新字体缓存。

sudo mkfontscale
sudo mkfontdir
sudo fc-cache -fv
fc-list
  1. 创建截图脚本screenshot.js,如下:
const puppeteer = require('puppeteer-core');
const os = require('os');

function timeout(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
};
(async () => {
  const browser = await puppeteer.launch({
	  headless: true,
	  executablePath: process.argv[2]
	  });
  const page = await browser.newPage();
  await page.goto(process.argv[3]);
  // 仅指定宽度,高度根据返回结果确定
  await page.setViewport({width:parseInt(process.argv[4]), height:0});
  // 等待页面自动执行的AJAX
  await timeout(10000);
  const pagesize = await page.evaluate(() => {
	  // TODO: 在这里可以动态操作dom元素
	  return {
		  width: document.documentElement.scrollWidth,
		  height: document.documentElement.scrollHeight};
  });
  // 重新调整viewport大小,适配真实的页面
  await page.setViewport({width:pagesize.width, height:pagesize.height});
  const path = process.argv[5].replace('~',os.homedir());
  await page.screenshot({path: path, fullpage: true});
  console.log("file " + path + " is saved, page size:" + pagesize.width + "," + pagesize.height);
  await browser.close();
})();
  1. 在Java里调用node,利用processbuilder构造命令行参数,再创建执行进程,并等待返回结果。
protected String generateScreenShot(Properties prop) throws Exception {
	log.info("generate screenshot");
	ProcessBuilder pb = new ProcessBuilder("node",
						// 不指定这个参数的话,nodejs执行出现unhandled exception时,会block进程
					   "--unhandled-rejections=strict",
					   prop.getProperty("alert_screenshot_script_file"),
					   prop.getProperty("alert_screenshot_browser"),
					   prop.getProperty("alert_detail_url"),
					   prop.getProperty("alert_screenshot_width"),
					   prop.getProperty("alert_screenshot_file"));
	pb.directory(ResourceUtils.getFile("classpath:" + prop.getProperty("alert_screenshot_script_path")));
	// 将正常的output和error分开显示
	pb.redirectErrorStream(false);
	Process p = pb.start();
	// 为了支持分开显示,采用线程的方法,同时防止read()之类的操作阻塞主线程
	Thread t1 = printScreenShotInformation(p.getInputStream(), false);
	Thread t2 = printScreenShotInformation(p.getErrorStream(), true);
	// 等待执行
	int exitcode = p.waitFor();
	Thread.sleep(1000);
	// 如果有线程有read()没返回,可以强制退出
	t1.interrupt();
	t2.interrupt();
	String strFilePath = prop.getProperty("alert_screenshot_file").replaceFirst("^~", System.getProperty("user.home"));
	log.info(String.format("fininsh saving a screen shot to %s with exit code:%d", strFilePath, exitcode));
	return strFilePath;
}

protected Thread printScreenShotInformation(final InputStream in, boolean bIsErr) {
	Thread t = new Thread() {
		@Override
		public void run() {
			try {
				int n;
				StringBuilder sb = new StringBuilder();
				while ((n = in.read()) != -1) {
					char c = (char)n;
					if(c == '\r' || c == '\n') {
						if(sb.length() > 0) {
							if(bIsErr) {
								log.error(sb.toString());
							}
							else {
								log.info(sb.toString());
							}
							sb = new StringBuilder();
						}
					}
					else {
						sb.append(c);
					}
				}
				if(sb.length() > 0) {
					if(bIsErr) {
						log.error(sb.toString());
					}
					else {
						log.info(sb.toString());
					}
				}
			}
			catch(Exception e) {
				e.printStackTrace();
			}
		}
	};
	t.start();
	return t;
}