1.基于PhantomJs实现html输出图片

一灰灰blogQuickMediaphantom-plugin约 2777 字大约 9 分钟

Java & PhantomJs 实现 html 输出图片

借助 phantomJs 来实现将 html 网页输出为图片

在 quick-media 的体系里,html 转图片其实有两套方案:一套是 markdown-plugin 里基于 xhtmlrenderer 的纯 Java 渲染,另一套就是本文要说的,把 html 丢给无头浏览器 phantomjs 渲染,然后截屏输出图片。

1. 为什么需要 phantomjs

先聊一下问题背景:我们希望实现的效果是「给一个网页地址,输出这个网页渲染之后的截图」。

纯 Java 的方案(xhtmlrenderer,也就是 markdown-plugin 中 Html2ImageWrapper 的实现思路)是自己解析 html、自己排版、自己绘制,好处是不依赖任何外部程序,坏处也很明显:

  • 它本质上是一个简化的排版引擎,css 支持有限,复杂的样式(如 flex、部分 js 动态渲染的内容)基本玩不转
  • html 里的 js 不会执行,页面上靠 ajax/js 拉取的内容拿不到
  • 适合场景是「我们自己生成的、结构可控的 html」(比如 markdown 转出来的),而不是任意的线上网页

而 phantomjs 是一个基于 WebKit 内核的无界面浏览器,它和真正的浏览器一样加载页面、执行 js、应用 css 样式,然后我们可以让它对当前页面截屏。也就是说,凡是浏览器能正常显示的页面,phantomjs 都能把它截成图,这就把渲染的复杂度从 Java 侧转嫁给了浏览器内核。

两个方案的简单对比:

对比项xhtmlrenderer(markdown-plugin)phantomjs(本文)
依赖纯 Java,无外部依赖需要本机安装 phantomjs
js 支持不执行 js完整执行 js
css 支持简单样式WebKit 内核支持
使用场景结构可控的 html(如 markdown 生成的)任意线上 url
输出控制宽高自适应、输出格式等都可配置截图结果即浏览器显示效果,参数较少

所以如果你的需求是「markdown 转 html 再转图片」,用 markdown-plugin 那套就够了;如果是「把一个线上网页存成图片」,phantomjs 方案更合适。

2. 前提准备:安装 phantomjs

phantom-plugin 本身只是「调用方」,真正干活的是本机的 phantomjs 程序,所以第一步得把它装好。这里以 2.1.1 版本为例。

2.1 mac 系统

# 下载
wget https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-macosx.zip

# 解压后,将可执行文件放到 PATH 目录下
sudo cp phantomjs-2.1.1-macosx/bin/phantomjs /usr/local/bin

# 验证是否 ok
phantomjs --version
# 输出版本号,则表示 ok

2.2 linux 系统

# 下载
wget https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2

# 解压
sudo su
tar -jxvf phantomjs-2.1.1-linux-x86_64.tar.bz2

# 如果解压报错,则先安装下面的
# yum -y install bzip2

# 简单点,移动到 bin 目录下
cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin

# 验证
phantomjs --version

2.3 windows 系统

老文档里写的是「就不要玩了,没啥意思」😂,不过 Windows 下其实也能跑:下载 phantomjs-2.1.1-windows.zip 解压,记住 bin\phantomjs.exe 的绝对路径(比如 D:/soft/phantomjs/bin/phantomjs.exe),后面通过 JVM 系统属性告诉 Java 进程即可(见下文 3.2 节),不强求配置环境变量。

如果希望通过 phantomjs --version 直接验证,则可以把 bin 目录加到系统的 Path 环境变量里。

2.4 linux 下的中文字体

如果是在 linux 服务器上用,且页面包含中文,请确保系统安装了中文字体,否则截图里的中文会变成一个个方框:

# centos
yum install -y fontconfig
yum install -y wqy-microhei-fonts wqy-zenhei-fonts

这是 phantomjs 系方案(包括后面会聊的 selenium 无头浏览器)的经典坑,渲染乱码十有八九是机器上没有字体。

3. 依赖引入

项目已发布到 maven 中央仓库,直接依赖即可

3.1 maven

<!-- https://mvnrepository.com/artifact/com.github.liuyueyi.media/phantom-plugin -->
<dependency>
    <groupId>com.github.liuyueyi.media</groupId>
    <artifactId>phantom-plugin</artifactId>
    <!-- 以中央仓库最新版本为准 -->
    <version>3.1.0</version>
</dependency>

内部依赖了 selenium-java(selenium 官方提供的 phantomjs driver)与 ghostdriver(phantomjs 的 webdriver 实现,来自 jitpack 仓库),如果你选择自己拉源码编译,需要留意 pom 里有 jitpack 的仓库配置。

3.2 指定 phantomjs 的可执行路径

插件是如何找到 phantomjs 的?看一眼源码:

private static String getPhantomJsPath() {
    String path = System.getProperty("phantomjs.binary.path");
    if (path == null || "".endsWith(path)) {
        return "/usr/local/bin/phantomjs";
    } else {
        return path;
    }
}

逻辑非常直白:

  • 优先读取 JVM 系统属性 phantomjs.binary.path
  • 没有配置时,兜底使用 /usr/local/bin/phantomjs —— 这也是为什么上面推荐把可执行文件拷到 /usr/local/bin 下

所以两种姿势二选一:

  1. 把 phantomjs 放到 /usr/local/bin 下(mac/linux 省事)
  2. 启动时带上系统属性:
# 命令行方式
java -Dphantomjs.binary.path=/Users/yihui/soft/phantomjs/bin/phantomjs -jar xxx.jar

# windows 示例
java -Dphantomjs.binary.path=D:/soft/phantomjs/bin/phantomjs.exe -jar xxx.jar

代码内也可以在创建 driver 之前设置一次(注意插件是类加载时初始化的,最好在应用启动最前面做):

System.setProperty("phantomjs.binary.path", "D:/soft/phantomjs/bin/phantomjs.exe");

4. 使用姿势

这个插件对外暴露的 API 非常克制,整个插件就一个工具类 Html2ImageByJsWrapper,就一个静态方法:

/**
 * 渲染 url 对应的网页,并输出截图
 */
public static BufferedImage renderHtml2Image(String url) throws IOException

以项目自带的测试 case 为例(Html2ImgTest#testRender):

@Test
public void testRender() {
    BufferedImage img = null;
    for (int i = 0; i < 20; ++i) {
        String url = "https://my.oschina.net/u/566591/blog/1580020";
        long start = System.currentTimeMillis();
        img = Html2ImageByJsWrapper.renderHtml2Image(url);
        long end = System.currentTimeMillis();
        System.out.println("cost:  " + (end - start));
    }
}

传一个线上 url 进去,返回的就是渲染之后的 BufferedImage,拿到之后该怎么用就怎么用,比如直接落盘:

BufferedImage img = Html2ImageByJsWrapper.renderHtml2Image("https://blog.hhui.top");
ImageIO.write(img, "png", new File("/tmp/render.png"));

如果希望在前端网页中直接展示,可以配合 base-plugin 里的工具类,把图片转成 base64 的 data-uri 格式:

import com.github.hui.quick.plugin.base.Base64Util;
import com.github.hui.quick.plugin.base.DomUtil;
import com.github.hui.quick.plugin.base.constants.MediaType;

// 输出可以直接塞进 <img src="..."> 的 data-uri 字符串
System.out.println(DomUtil.toDomSrc(Base64Util.encode(img, "png"), MediaType.ImagePng));

5. 内部是怎么干的

虽然它只是个薄薄的封装,但把核心源码贴出来过一遍,有助于理解后面的一些坑:

public class Html2ImageByJsWrapper {

    private static PhantomJSDriver webDriver;

    static {
        try {
            webDriver = getPhantomJs();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static PhantomJSDriver getPhantomJs() {
        //设置必要参数
        DesiredCapabilities dcaps = new DesiredCapabilities();
        //ssl证书支持
        dcaps.setCapability("acceptSslCerts", true);
        //截屏支持
        dcaps.setCapability("takesScreenshot", true);
        //css搜索支持
        dcaps.setCapability("cssSelectorsEnabled", true);
        //js支持
        dcaps.setJavascriptEnabled(true);
        //驱动支持(第二参数表明的是你的phantomjs引擎所在的路径,which/whereis phantomjs可以查看)
        dcaps.setCapability(PhantomJSDriverService.PHANTOMJS_EXECUTABLE_PATH_PROPERTY, getPhantomJsPath());
        //创建无界面浏览器对象
        return new PhantomJSDriver(dcaps);
    }

    public static BufferedImage renderHtml2Image(String url) throws IOException {
        if (webDriver != null) {
            webDriver.get(url);
            File file = webDriver.getScreenshotAs(OutputType.FILE);
            return ImageIO.read(file);
        } else {
            return null;
        }
    }
}

几处值得注意的点:

  • 静态块初始化:PhantomJSDriver 在类加载时就创建好了,phantomjs 进程随之启动,后续所有的渲染都复用这一个浏览器实例,避免每次截图都拉起进程的开销(测试 case 里循环 20 次渲染并计时,就是为了观察复用之后的耗时表现)
  • 参数说明:DesiredCapabilities 里开启了 ssl 证书、截屏、css 选择器、js 支持,都是截网页图的必要开关
  • 渲染流程:webDriver.get(url) 会等页面加载完成,然后 getScreenshotAs 对当前窗口截屏,得到一个临时文件,再读成 BufferedImage 返回
  • phantomjs 路径:通过 PHANTOMJS_EXECUTABLE_PATH_PROPERTY 这个 capability 传给了 driver,取值规则见上文 3.2

6. 常见坑

6.1 找不到 phantomjs 可执行文件

报错一般是 phantomjs driver 初始化失败,栈里会有找不到 executable 之类的提示。排查顺序:

  1. 确认机器上真的装了 phantomjs:which phantomjs(win 上是 where phantomjs)
  2. 确认路径和插件期望的对得上:没有配置 phantomjs.binary.path 时,兜底路径是 /usr/local/bin/phantomjs,只适用于 mac/linux
  3. windows 用户必须显式配置 -Dphantomjs.binary.path=...,且注意路径分隔符用 / 或 \\

6.2 中文乱码 / 中文变方框

表现是截图里的中文全是「豆腐块」。原因在前面提过:机器上没有中文字体,phantomjs 渲染时找不到字形。装上字体即可(见 2.4 节),linux 服务器上部署时尤其容易踩。

另外注意插件默认以 UTF-8 读取文件,如果你的 html 文件本身是 GBK 编码,也可能出现乱码,这类情况属于 html 源文件的问题,改文件编码即可。

6.3 截图只有一小块 / 长网页被截断

phantomjs 截屏截的是「当前视口」,默认窗口尺寸比较小(400x300),所以页面超长时会只截到第一屏。

这是方案本身的限制:Html2ImageByJsWrapper 没有暴露设置视口大小的参数。如果遇到这种场景,可以下载源码自己改造——PhantomJSDriver 实现了标准 WebDriver 接口,在 renderHtml2Image 里调用 webDriver.manage().window().setSize(new Dimension(w, h)) 即可放大视口;或者干脆按自己的需求 fork 一份 wrapper(整个类也就七十来行,改造成本很低)。

6.4 页面加载超时

webDriver.get(url) 是同步阻塞的,页面一直加载不完时,调用就会一直挂着。如果渲染的 url 来自外部输入,建议在外面套一层自己的超时控制(如异步线程 + 超时取消),不要让一次异常的页面加载拖垮整个服务。

网络不佳时,页面里引用的远程图片、css 加载失败,也会导致截图内容不完整,这一点在弱网环境下要有心理预期。

6.5 并发支持有限

插件内部只有一个静态的 webDriver 实例,renderHtml2Image 串行使用它是安全的,但多线程并发调用同一个无头浏览器实例并没有得到保证。如果业务上是高并发场景,稳妥的做法是自己基于 selenium + ghostdriver 搭建一个 phantomjs 实例池(或者每个线程一个实例),不要直接并发怼这个工具类。

6.6 时代局限

phantomjs 项目官方已经停止维护,其 WebKit 内核相对老旧,对新的 js 语法、新的 css 特性支持有限,遇到新潮的网页可能会渲染异常或直接加载失败。这是选型时需要心里有数的:phantomjs 方案胜在简单、有历史沉淀,但如果要求对新页面有良好的兼容性,可以考虑换成基于 chrome 的 headless 方案,思路完全一致(同样是 selenium 驱动 + 截屏),只是驱动换成了 ChromeDriver。

7. 小结

phantom-plugin 提供的是一个非常轻量的封装:Html2ImageByJsWrapper.renderHtml2Image(url) 一行调用,把「网页」变成「图片」。它和 markdown-plugin 的 xhtmlrenderer 方案互补——后者擅长渲染我们自己生成的 html,前者擅长截真实的线上网页。

使用它的三要素:

  1. 本机装好 phantomjs 2.1.1
  2. 保证 Java 进程能找到可执行文件(环境变量 或 phantomjs.binary.path)
  3. 注意视口大小、字体、超时这几个经典坑

相关文档:

项目地址: https://github.com/liuyueyi/quick-mediaopen in new window

Loading...