Convert PDF to HTML without losing text or format.
用springboot
把pdf2htmlEX
命令行工具包裝爲web
服務, 使得PDF
轉HTML
更方便。
pdf2htmlEX
命令行工具詳情見:
https://github.com/pdf2htmlEX/pdf2htmlEX
pdf2html-service
源碼見:
https://github.com/iflyendless/pdf2html-service
快速開始
# 拉取鏡像
docker pull iflyendless/pdf2html-service:1.0.1
# 啓動
docker run --name pdf2html -p 8686:8686 -d --rm iflyendless/pdf2html-service:1.0.1
使用:
curl -o html.zip --request POST 'localhost:8686/api/pdf2html' --form 'files=@/pdfs/example.pdf'
提醒一下: /pdfs/example.pdf
指的是pdf文件所在的絕對路徑
在當前目錄解壓html.zip
, 即可看到轉換後的html
文件以及000-task.txt
。
構建鏡像
# 下載代碼
git clone https://github.com/iflyendless/pdf2html-service.git
# 進入項目
cd pdf2html-service
# 跳過單元測試打包
mvn clean package -DskipTests
# build docker image
docker build -t pdf2html-service:1.0.1 .
如果構建鏡像失敗,請檢查 https://enos.itcollege.ee/~jpoial/allalaadimised/jdk8/ 該站點下jdk版本是否與Dockerfile
中的下載版本一致。
啓動
docker run --name pdf2html -p 8686:8686 -d --rm pdf2html-service:1.0.1
如果需要格外設置一些參數的話, 可以啓動docker的時候通過-e
傳進去:
# 同時啓動的最大子進程數, 需要根據系統的資源合理設置(默認15)
-e PDF2HTML_MAX_PROCESS=15
# 執行/usr/local/bin/pdf2htmlEX命令時最大超時時間,單位s表示秒(默認600s)
-e PDF2HTML_COMMAND_TIMEOUT=600s
即:
docker run --name pdf2html -p 8686:8686 -e PDF2HTML_MAX_PROCESS=10 -e PDF2HTML_COMMAND_TIMEOUT=60s -d --rm pdf2html-service:1.0.1
更多配置見: resources
目錄下的application.yml
文件。
Http接口
(1) 查看版本
curl http://localhost:8686/api/version
(2) 查看配置
curl http://localhost:8686/api/config
(3) 上傳多個pdf, 並下載html壓縮包
curl -o html.zip --request POST 'localhost:8686/api/pdf2html' --form 'files=@/pdfs/001.pdf' --form 'files=@/pdfs/002.pdf' --form 'files=@/pdfs/003.pdf'
提醒一下: /pdfs/001.pdf
指的是pdf文件所在的絕對路徑
(4) 查詢程序暴露出來的metric
curl http://localhost:8686/api/metric
問題排查
# 進入容器
docker exec -it pdf2html bash
# 查看日誌目錄
cd /opt/pdf2html-service/logs
# 查看轉換失敗的pdf
cd /tmp/pdf2html-service/failed-pdfs
# 手動調用pdf2htmlEX命令轉換pdf
pdf2htmlEX --help
實現
每次手動調用pdf2htmlEX
命令行工具不太方便,在此基礎上包裝成一個web
服務更加方便使用。完整源碼見:
https://github.com/iflyendless/pdf2html-service
思路
由於pdf2htmlEX
命令行工具的依賴較爲複雜,編譯也比較麻煩,所以可直接在官方提供的Docker Image
中安裝JDK
,然後用springboot
快速編寫一個web
應用,接收用戶http
請求,後臺調用pdf2htmlEX
命令行工具將多個PDF
都轉爲HTML
,然後壓縮生成的HTML
爲zip
包,讓用戶下載。
Dockerfile
如下:
# pdf2htmlex image
FROM pdf2htmlex/pdf2htmlex:0.18.8.rc1-master-20200630-Ubuntu-bionic-x86_64
ENV TZ='CST-8'
ENV LANG C.UTF-8
# apt
RUN sed -i s@/archive.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.list
RUN apt-get clean && apt-get update
RUN apt-get install -y vim curl htop net-tools
# vim
RUN echo "set fileencodings=utf-8,ucs-bom,gb18030,gbk,gb2312,cp936" >> /etc/vim/vimrc
RUN echo "set termencoding=utf-8" >> /etc/vim/vimrc
RUN echo "set encoding=utf-8" >> /etc/vim/vimrc
# jdk
ADD https://enos.itcollege.ee/~jpoial/allalaadimised/jdk8/jdk-8u291-linux-x64.tar.gz /tmp/
RUN tar -zxf /tmp/jdk-*.tar.gz -C /opt/ && rm -f /tmp/jdk-*.tar.gz && mv /opt/jdk* /opt/jdk
ENV JAVA_HOME /opt/jdk
ENV PATH ${JAVA_HOME}/bin:$PATH
# pdf2html-service
COPY target/pdf2html-service-*.tar.gz /tmp/
RUN tar -zxf /tmp/pdf2html-service-*.tar.gz -C /opt/ && rm -f /tmp/pdf2html-service-*.tar.gz
ENTRYPOINT [""]
WORKDIR /opt/pdf2html-service
CMD ["bash","-c","./start.sh && tail -f /dev/null"]
引入依賴
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<java.version>1.8</java.version>
<maven.build.timestamp.format>yyyyMMdd</maven.build.timestamp.format>
<hutool.version>5.6.3</hutool.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.3</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
這是一個springboot
應用:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class Pdf2HtmlService {
public static void main(String[] args) {
SpringApplication.run(Pdf2HtmlService.class);
}
}
程序配置
application.yml
大致如下:
server:
port: ${APP_PORT:8686}
servlet.context-path: /
pdf2html:
# /usr/local/bin/pdf2htmlEX --zoom 1.3
command: ${PDF2HTML_COMMAND:/usr/local/bin/pdf2htmlEX --zoom 1.3 --quiet 1}
command-timeout: ${PDF2HTML_COMMAND_TIMEOUT:600s}
work-dir: ${PDF2HTML_WORK_DIR:/tmp/pdf2html-service}
max-process: ${PDF2HTML_MAX_PROCESS:15}
spring:
application:
name: pdf2html-service
對應的Pdf2HtmlProperties
如下:
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@Data
@ConfigurationProperties(prefix = "pdf2html")
public class Pdf2HtmlProperties {
private String command;
private String workDir;
private Duration commandTimeout;
// 同時啓動的最大子進程數, 需要根據系統的性能合理設置
private int maxProcess;
}
下面簡單解釋一下這幾個配置的含義:
command
:指的是調用pdf2htmlEX
命令行工具的具體command,詳細參數見pdf2htmlEX --help
command-timeout
:使用的apache
的commons-exec
工具包,異步調用命令行,可設置最大超時時間。commons-exec
的使用詳情見:https://commons.apache.org/proper/commons-exec/tutorial.html
work-dir
:該web應用程序的工作目錄,也就是接收到用戶的request
時,先將pdf文件寫入該目錄的一個子目錄下,調用pdf2htmlEX
生成的html默認也是在該目錄下,然後壓縮該目錄下生成的html文件,寫入response
。另外注意的是:轉換失敗的pdf會寫入到該work-dir
下的failed-pdfs
下。方便復現、排查問題。max-process
:由於我的實現中調用命令行工具是全異步操作,必須對同時啓動的命令行個數加以限制,避免短時間內產生大量子進程,不僅嚴重影響程序性能,而且可能導致系統瞬間卡死。所以該配置限制了同時啓動的最大子進程數, 需要根據系統的性能合理設置。這裏是用JDK自帶的java.util.concurrent.Semaphore
來限制子進程數量。
接口實現
接口實現並不複雜,關鍵地方也加了一些註釋。如下:
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.FileTypeUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ZipUtil;
import com.github.iflyendless.config.Pdf2HtmlProperties;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.exec.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileFilter;
import java.net.URLEncoder;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
@Slf4j
@RestController
@RequestMapping("/api")
public class Pdf2HtmlController {
private static final String PDF = "pdf";
private static final String FAILED_PDF_DIR = "failed-pdfs";
private static final String TASK_FILE = "000-task.txt";
@Resource
private Pdf2HtmlProperties pdf2HtmlProperties;
// 爲了限制同時啓動pdf2htmlEX命令行工具的子進程數
private static Semaphore semaphore;
// 轉換html失敗的pdf寫到這個目錄, 方便後面手動轉換排查原因
private static File failedPdfDir;
@PostConstruct
public void init() {
semaphore = new Semaphore(pdf2HtmlProperties.getMaxProcess());
failedPdfDir = FileUtil.mkdir(FileUtil.file(pdf2HtmlProperties.getWorkDir(), FAILED_PDF_DIR));
}
@GetMapping("/version")
public Object version() {
return "1.0.1";
}
@GetMapping("/config")
public Object config() {
return pdf2HtmlProperties;
}
@GetMapping("/metric")
public Object metric() {
Map<String, Object> semaphoreMap = new LinkedHashMap<>();
semaphoreMap.put("availablePermits", semaphore.availablePermits());
semaphoreMap.put("queueLength", semaphore.getQueueLength());
Map<String, Object> metricMap = new LinkedHashMap<>();
metricMap.put("semaphore", semaphoreMap);
return metricMap;
}
@PostMapping("/pdf2html")
public void pdf2html(@RequestParam("files") MultipartFile[] files,
HttpServletResponse response) {
if (ArrayUtil.isEmpty(files)) {
log.warn("文件數爲0");
return;
}
File dir = FileUtil.mkdir(FileUtil.file(pdf2HtmlProperties.getWorkDir(), IdUtil.simpleUUID()));
try (ServletOutputStream outputStream = response.getOutputStream()) {
List<File> fileList = new ArrayList<>(files.length);
for (MultipartFile f : files) {
if (f == null || f.isEmpty()) {
continue;
}
// 寫入本地工作目錄
File localFile = FileUtil.writeFromStream(f.getInputStream(), FileUtil.file(dir, f.getOriginalFilename()));
// 只處理pdf文件
if (isPdf(localFile)) {
fileList.add(localFile);
}
}
if (CollUtil.isEmpty(fileList)) {
return;
}
long start = System.currentTimeMillis();
int size = fileList.size();
CountDownLatch latch = new CountDownLatch(size);
// 處理失敗的pdf統計
Map<String, Throwable> failedMap = new ConcurrentHashMap<>();
for (File file : fileList) {
// 這裏限制啓動子進程的數量
// 因爲後面的調用是異步的, 防止瞬間產生大量子進程
semaphore.acquire();
// 異步調用pdf2htmlEX命令行工具
invokeCommand(dir, file, latch, failedMap);
}
// 等待所有子進程結束
latch.await();
log.info("pdf2html一共耗時{}ms, pdf數量爲{}", System.currentTimeMillis() - start, size);
// 記錄 統計數據寫入文件000-task.txt, 轉換html失敗的pdf寫入固定目錄
recordTaskResult(size, failedMap, dir, fileList);
// 將生成的html文件以及task.txt壓縮, 並寫入response
ZipUtil.zip(outputStream, CharsetUtil.CHARSET_UTF_8, true, new FileFilter() {
@Override
public boolean accept(File pathname) {
if (pathname.isDirectory()) {
return true;
}
String name = pathname.getName().toLowerCase();
return name.endsWith(".html") || name.endsWith(".txt");
}
}, dir);
response.addHeader("Content-Disposition",
"attachment;fileName=" + URLEncoder.encode(dir.getName() + ".zip", "UTF-8"));
response.addHeader("Content-type", "application/zip");
} catch (Throwable e) {
log.error("pdf2html error", e);
} finally {
FileUtil.del(dir);
}
}
/**
* 這裏使用apache的commons-exec執行pdf2htmlEX命令行工具
* 詳情見: https://commons.apache.org/proper/commons-exec/tutorial.html
*/
public void invokeCommand(File workDir, File file, CountDownLatch latch, Map<String, Throwable> failedMap) {
String filePath = file.getAbsolutePath();
String line = String.format("%s --dest-dir %s %s", pdf2HtmlProperties.getCommand(), workDir.getAbsolutePath(), filePath);
CommandLine commandLine = CommandLine.parse(line);
// 命令行的超時處理
ExecuteWatchdog watchdog = new ExecuteWatchdog(1000 * pdf2HtmlProperties.getCommandTimeout().getSeconds());
// 命令行 執行完成的回調
ResultHandler resultHandler = new ResultHandler(file, latch, failedMap);
Executor executor = new DefaultExecutor();
executor.setExitValue(0);
executor.setWatchdog(watchdog);
try {
executor.execute(commandLine, resultHandler);
} catch (Throwable e) {
semaphore.release();
String fileName = file.getName();
if (!failedMap.containsKey(fileName)) {
failedMap.put(fileName, e);
}
latch.countDown();
log.error("invokeCommand failed, command: {}, error:{}", line, e);
}
}
public static boolean isPdf(File file) {
try {
return PDF.equalsIgnoreCase(FileTypeUtil.getType(file));
} catch (Exception e) {
log.error("識別pdf類型失敗, 文件名:{}, error: {}", file.getAbsolutePath(), e);
return false;
}
}
public static void recordTaskResult(int total, Map<String, Throwable> failedMap, File workDir, List<File> pdfs) {
List<String> list = new ArrayList<>();
list.add("total:" + total);
list.add("success:" + (total - failedMap.size()));
list.add("failed:" + failedMap.size());
list.add("");
list.add("failed-pdfs:");
list.add("");
Set<String> failedNames = failedMap.keySet();
list.addAll(failedNames);
// 記錄任務完成大致情況
FileUtil.writeLines(list, FileUtil.file(workDir, TASK_FILE), CharsetUtil.CHARSET_UTF_8);
// 轉換失敗的pdf寫入其他目錄,後續可能需要進一步處理
if (CollUtil.isNotEmpty(failedNames)) {
for (File pdf : pdfs) {
String name = pdf.getName();
if (failedNames.contains(name)) {
File dest = FileUtil.file(failedPdfDir, name);
if (dest.exists()) {
dest = FileUtil.file(failedPdfDir, IdUtil.simpleUUID() + "-" + name);
}
FileUtil.copyFile(pdf, dest);
}
}
}
}
/**
* 根據具體的業務邏輯做相應的實現, 這裏會打印一下錯誤日誌
*/
public static class ResultHandler implements ExecuteResultHandler {
private final File file;
private final CountDownLatch latch;
private final Map<String, Throwable> failedMap;
@Getter
private int exitValue = -8686;
public ResultHandler(File file, CountDownLatch latch, Map<String, Throwable> failedMap) {
this.file = file;
this.latch = latch;
this.failedMap = failedMap;
}
@Override
public void onProcessComplete(int exitValue) {
semaphore.release();
this.latch.countDown();
this.exitValue = exitValue;
}
@Override
public void onProcessFailed(ExecuteException e) {
semaphore.release();
this.failedMap.put(this.file.getName(), e);
this.latch.countDown();
log.error("pdf2html failed, file: {}, error:{}", this.file.getAbsolutePath(), e);
}
}
}
寫在後面
由於本人對前端開發不太熟悉,就沒有花時間做個簡單的頁面了。如果你瞭解前端開發而且對此工具有點興趣,可以順手寫個頁面出來,那就更好了!!!另外,如果你知道PDF
轉HTML
有更好的工具或實現,歡迎評論區留言!!!
隨手記錄,方便你我他。