Java 渲染 LaTex 公式并生成 PDF 文件

这是我作为 C# 开发在工作中遇到的第一个 JAVA 项目,项目不是从头参与开发的,是开发项目的人员离职了我才接手的(只有我一个后端开发),以下内容均是我接手后独立开发的。

需求很简单,但是开发很复杂,涉及到了 LaTex 公式渲染、 HTML 转 PDF、文件下载、 Docker 和 Nginx 及 Rancher 配置。可以说从开发到 IT 到运维都体验了一下,DevOps 对后端开发来说,真的是一个趋势啊。

一、项目背景#

教育项目,需要将试题集(包含数学公式)生成电子版并打印出来。

大致涉及到两个方面的问题:

  • 如何渲染公式
  • 电子版文件格式,换句话说:Word or PDF?

经过技术调研、实现难易度以及最终效果,最终结果如下:

  • 采用 Selenium 实现公式渲染
  • 采用 itextpdf.html2pdf 实现 PDF 生成

二、环境搭建#

1. 搭建 Selenium 环境#

1.1 通过 Docker 安装#

执行以下命令即可快速搭建出 Selenium + Chrome 的环境

$ docker run -d -p 4444:4444 -v /dev/shm:/dev/shm selenium/standalone-chrome

该命令会获取最新的 Selenium 镜像,目前版本是 3.141.59。

需要注意的是,官方文档上会带有版本号,即:

$ docker run -d -p 4444:4444 -v /dev/shm:/dev/shm selenium/standalone-chrome:3.141.59-20210128

如果带版本号,镜像下载的会非常慢,不带版本号很快就下载下来了。

1.2 通过 Rancher 安装#

在 Rancher 上添加服务也很简单,以下是精简的 YAML:

apiVersion: apps/v1
kind: Deployment
metadata:

  name: selenium-chrome
  namespace: edu-selenium
  
spec:
  
  template:
    spec:
      containers:
      - env:
        - name: LANG
          value: C.UTF-8
        - name: TZ
          value: Asia/Shanghai
        image: selenium/standalone-chrome
        imagePullPolicy: Always
        
        name: selenium-chrome
        ports:
        - containerPort: 4444
          name: http
          protocol: TCP
        readinessProbe:
          failureThreshold: 3
          initialDelaySeconds: 10
          periodSeconds: 20
          successThreshold: 2
          tcpSocket:
            port: 4444
          timeoutSeconds: 2
          
        volumeMounts:
        - mountPath: /dev/shm
          name: shm-volume
        workingDir: /home/seleuser

      volumes:
      - hostPath:
          path: /dev/shm
          type: ""
        name: shm-volume

1.3 检测服务是否可用#

访问以下地址,即可测试服务是否可用:

[服务器IP]:4444/status

1.4 查看 Chrome 版本#

访问以下地址,并创建一个 chrome session ,鼠标悬停在 Capabilities 上,即可在弹出的窗口看到 browserVersion

[服务器IP]:4444/wd/hub/static/resource/hub.html

2. 添加包依赖#

build.gradlepom.xml 添加包依赖,这里以 Gradle 配置为例:

// import WebDriverWait
implementation 'org.seleniumhq.selenium:selenium-support:3.141.59'
implementation 'org.seleniumhq.selenium:selenium-chrome-driver:3.141.59'
implementation 'com.itextpdf:html2pdf:3.0.3'

注意 selenium-chrome-driver 版本与安装的 Selenium 版本一致。

3. 下载 chrome-driver 二进制文件#

官网 华为镜像 淘宝镜像

下载对应浏览器版本的驱动,并放到 resources/webdriver 目录下(可放在任意目录)

三、使用示例#

1. 配置 webdriver 路径#

// 设置驱动路径,调用一次即可
static final String webDriverKey = "webdriver.chrome.driver";
static {
    try {
        ClassPathResource webdriver = new ClassPathResource("webdriver");
        if (OS.isFamilyWindows()) {
            System.setProperty(webDriverKey, webdriver.getFile().getAbsolutePath() + "/chromedriver.exe");
        } else {
            System.setProperty(webDriverKey, webdriver.getFile().getAbsolutePath() + "/chromedriver");
        }
    } catch (IOException e) {
        log.error("初始化chromedriver失败", e);
    }
}

2. 渲染 HTML#

// 渲染指定url的html文件,并返回选然后的html
public static String render(String url) throws MalformedURLException {
    
    ChromeOptions options = new ChromeOptions();
    options.addArguments("--headless");
    options.addArguments("--lang=zh-CN");
    
    RemoteWebDriver chromeDriver;
    // seleniumUrl为配置值,指定Selenium服务的地址
    // 示例:http://ip:4444/wd/hub
    // 此判断用于本地调试和真实环境的切换
    if (StringUtils.isBlank(seleniumUrl) || "localhost".equals(seleniumUrl)) {
        chromeDriver = new ChromeDriver(options);
    } else {
        chromeDriver = new RemoteWebDriver(new URL(seleniumUrl), options);
    }
    
    String html = "";
    try {
        chromeDriver.navigate().to(url);
        // 等待echart渲染完成
        synchronized (chromeDriver) {
            chromeDriver.wait(1000);
        }
        // 得到渲染后的html
        html = (String) chromeDriver.executeScript("return document.documentElement.innerHTML;");
    } finally {
        // 退出chromeDriver进程
        // chromeDriver.close()用于关闭当前窗口,不会退出chromeDriver进程,多次调用会导致chrome进程增多
        chromeDriver.quit();
    }
    
    return html;
}

3. itextpdf.html2pdf 中文字体支持#

3.1 添加中文字体#

注意:如果服务器支持中文字体,则此步骤可以跳过

推荐使用开源的中文字体,比如:思源黑体

根据需要,将字体复制到 resources/fonts 目录下(可以是任意目录)

3.2 配置 ConverterProperties#

static final ConverterProperties converterProperties = new ConverterProperties();

static {
    // 添加字体支持
    FontProvider fontProvider = new FontProvider();
    fontProvider.addStandardPdfFonts();
    fontProvider.addSystemFonts();
    
    // 添加自定义字体
    // 对于ttc格式字体,需要在路径后加',1',所以无法调用fontProvider.addDirectory方法
    // fontProvider.addDirectory方法如果添加字体失败,会忽略异常,不会抛出异常
    try {
        ClassPathResource msyhFont = new ClassPathResource("fonts/msyh.ttc");
        FontProgram font = FontProgramFactory.createFont(msyhFont.getFile().getAbsolutePath()+",1");
        fontProvider.addFont(font);
    } catch (IOException e) {
        log.error("初始化字体失败", e);
    }
    
    converterProperties.setFontProvider(fontProvider);
    converterProperties.setCharset(StandardCharsets.UTF_8.name());
}

4. 生成 PDF#

// 传入渲染后的html和生成pdf保存的路径
public static void convert(String html, String pdfFileName) throws FileNotFoundException {
    PdfWriter pdfWriter = new PdfWriter(pdfFileName);
    HTMLConverter.convertToPdf(html, pdfWriter, converterProperties);
}

四、总结#

以上主要技术问题就介绍完了,剩余的问题是:

  • HTML 模板
  • PDF 文件下载

模板是需要不断优化不断改进的,是日后主要的修改点。

文件下载服务就简单了,使用 Nginx 服务器就可以了。

实际项目是容器化部署,涉及到主机目录到容器目录的映射。docker -v 命令就可以解决。

docker -v /data/template:/data/template -v /download:/data/download ... your app
# 用于渲染,应用生成的html通过nginx暴露出url用于渲染
docker -v /data/template:/nginx/root ... nginx
# 用于pdf下载
docker -v /download:/nginx/root ... nginx

❤️ 如果这篇文章对你有帮助,欢迎赞助支持我继续维护 ❤️

☕ Support me ⚡ 爱发电赞助