简介:一份基于Java与PhantomJS实现的URL转PDF/HTML转PDF简易Demo,面向需要将网页内容或HTML模板转换为高质量PDF的Java开发人员。作者经过对比wkhtmltopdf与IText后,认为PhantomJS在体积、URL转换完整度方面更具优势,因此重点演示如何借助PhantomJS完成转换,适合用于报表导出、网页快照、文档归档等场景。压缩包内共85个文件,整体约34.65MB,其中67个xml为Maven依赖管理配置,5个java为Spring Boot核心源码,2个js为PhantomJS脚本,2个exe为PhantomJS可执行文件,另含class、properties等辅助文件,目录结构清晰,可直接导入项目运行。资源提供完整的Maven工程结构,包含核心源码与PhantomJS脚本示例,展示了从HTML/URL到PDF的调用流程,可帮助开发者快速理解并集成类似功能。已有1108人学习/下载,适合具备一定Java基础、希望在Spring Boot中快速实现PDF转换的开发者参考。 先说丑话:URL转PDF、HTML转PDF,看起来就是把网页“另存为PDF”的小工具,真做起来坑比你想象的多。最近我刚把一个网页批量转PDF的服务重写了一遍,从选型到落地折腾了小一周。这个需求在内部工具、爬虫、文档归档系统里几乎躲不开——运营周报要定时截图存档、电商后台要把订单页面导成对账单、法律或财务场景要做网页证据保全、甚至你只是想把一个HTML模板填充数据后生成合同或简历。网上转PDF的教程不少,但大多只扔给你一段代码,不解释为什么这么写,也不讲碰到404、502、乱码、空白页时怎么排查。这篇我把整个项目从需求、选型、代码到排查经验完整记录下来,适合正在做类似功能,或者准备把你那个“页面另存为PDF”的脚本升级成工程化服务的人。
1. 需求拆解:这个需求到底在解决什么问题
1.1 最常见的几类使用场景
URL转PDF、HTML转PDF,本质上要解决的是“把动态网页内容固化成不可变文档”的问题。我遇到的真实场景大概分这几类:
- 报表与工单归档:BI大屏、监控面板、运营看板,每天定时转成PDF存到OSS或发邮箱,留存历史版本。
- 文件凭证与证据保全:订单详情页、用户协议页、发票页面,在某个时间点转成PDF,作为合同或纠纷证据,页面改动不影响归档内容。
- HTML模板批量出件:简历、报价单、电子合同这类场景,后端用模板引擎(Velocity、FreeMarker、Jinja2)往HTML模板里塞数据,渲染完再转PDF,用于下载和打印。
- 离线阅读与分发:把长篇文章或帮助中心文档转成PDF,方便用户离线阅读,或者打包成知识库。
网上关于pdf转word、pdf转曲、pdf图片中文设置这类提问也特别多,说明PDF这个东西一旦进入业务链路,它前面是生成、后面必然接着转换、提取、编辑的整条生态,生成端做扎实了,后面能省很多事。
1.2 URL输入和HTML输入是两条不同的路
很多教程把URL转PDF和HTML转PDF混着讲,实际上这两者有本质区别。
URL输入的本质是“访问一个远程页面并复刻渲染结果”,这个过程涉及网络请求、重定向、登录态、懒加载、跨域资源、反爬策略,页面最终长什么样不光取决于你的代码,还取决于目标网站的当前状态。
HTML输入的本质是“把一段字符串排版成文档”,输入是无状态的,不依赖网络也能出文件,难点在别处。HTML字符串里很可能有相对路径的资源,比如<img src="/images/logo.png">,如果你不在某个有效页面上下文中去渲染它,浏览器根本不知道/images/logo.png的根域名是哪个,图片、CSS、JS全都会挂掉。
所以做方案的时候,第一件事就是明确:你的输入源是哪种?如果两种都要支持,代码必须分开设计,混在一块迟早出问题。
2. 选型对比:浏览器渲染还是纯解析渲染
2.1 两条技术路线的基本原理
把HTML转成PDF,业内基本是两大路线。
一条是浏览器渲染路线:用真实浏览器内核(Chromium或WebKit)去加载页面,执行JS、请求资源、等渲染完成,然后调用浏览器的打印引擎输出PDF。代表工具有Puppeteer、Playwright、wkhtmltopdf。优点是所见即所得,页面多复杂都能还原;缺点是重,要下载Chromium内核,内存占用高,而且浏览器本身是个吃资源的“胖子”。
另一条是纯解析渲染路线:不执行JS,直接用HTML/CSS解析器把DOM排版成PDF文档。代表工具有WeasyPrint、iText、飞书云文档导出之类的。优点是轻量、启动快、依赖少,适合排版讲究的正式文档;缺点也明显,遇到Flex/Grid布局、CSS变量、JS动态渲染就基本歇菜,页面保真度远不如浏览器渲染。
我见过有人试图直接用requests把HTML拉下来然后写进PDF,这个思路方向不对。HTML是排版描述语言,浏览器才是负责把它“画”出来的角色,requests拉回来的只是一堆标签字符串,离PDF还差一个渲染引擎。
2.2 主流方案横向对比
| 工具 | 渲染内核 | 适用场景 | 中文字体支持 | 部署体积 | 主要坑点 |
|---|---|---|---|---|---|
| wkhtmltopdf | Qt WebKit | 老项目、轻量服务 | 依赖系统字体 | 中 | 现代CSS3特性大量不支持 |
| Puppeteer/Playwright | Chromium | 复杂页面、高保真快照 | 需安装中文字体 | 大(几百MB) | 内核下载慢、内存占用高 |
| WeasyPrint | 自研HTML渲染 | 正式报告、简历、文档 | 需配置中文字体文件 | 小 | 不支持JS,复杂布局会崩 |
| 浏览器手动打印 | 原生 | 人工零星操作 | 不受影响 | 无 | 无法自动化,人力成本高 |
这套对比做完,结论已经很清晰了。如果目标是“复现一个复杂的现代网页”,除了Puppeteer/Playwright没有更好的选择;如果只是把自产自销的干净HTML模板变成PDF,WeasyPrint反而更实用,输出文件小、速度快、依赖轻,不会为了一个PDF把500MB的Chromium拖进来。
2.3 我的选型逻辑
这次我最终用了Playwright,原因是客户方需要高保真还原前端页面,而且那些页面里有大量ECharts图表,纯解析路线连图表都渲染不出来。选Playwright而不是Puppeteer,主要看中它同时支持同步和异步API,语言绑定覆盖Python和Node.js,还能处理多标签页、新窗口等弹窗场景,写起日志和重试也方便。
选型时另外考虑过wkhtmltopdf,它确实轻便,但它基于老旧的Qt WebKit,现代页面里的CSS Grid、flex布局支持不到位,稍微复杂一点的样式就乱了。这种兼容性债务越到后期越头疼,建议新项目别选它。
3. 实操落地:用Playwright把URL和HTML转成PDF
3.1 环境准备与依赖安装
我用的是Python版Playwright,安装很简单:
pip install playwright playwright install chromium第二步会下载Chromium内核,体积不小,网络不好的时候容易失败。如果你是在服务器上跑,一般还需要装几个基础系统库,官方文档有提供对应的安装命令,直接复制即可。
这里有一个默认没人提的坑:如果服务器是最小化安装的Linux,很可能是没有中文字体的,渲染出来的中文全是方块。这一条我放在最前面,因为它能让你在调试乱码问题时少走一晚上的弯路:
提示:渲染前先确认系统里有没有中文字体。Debian/Ubuntu可以执行
apt install fonts-noto-cjk,CentOS是yum install wqy-microhei或fonts-noto-cjk。装完重启服务进程再试。如果页面里用了特殊字体,还需要在CSS里把字体栈配置好,比如font-family: 'Noto Sans CJK SC', 'Microsoft YaHei', sans-serif;。
3.2 URL转PDF的完整代码
最简单的URL转PDF,用Playwright同步API写是这样:
from playwright.sync_api import sync_playwright def url_to_pdf(url: str, output_path: str): with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() response = page.goto(url, wait_until="networkidle", timeout=30_000) if response is None or not response.ok: raise RuntimeError(f"页面加载失败,状态码: {response.status if response else 'unknown'}") page.pdf( path=output_path, format="A4", print_background=True, margin={"top": "10mm", "bottom": "10mm", "left": "10mm", "right": "10mm"}, ) browser.close()几个参数我详细说明一下。
wait_until="networkidle"的意思是等页面所有网络请求都结束再继续。对大部分页面来说这是最稳的。但要注意,有些页面有轮询接口,永远不会进入networkidle状态,这种页面就会一直卡到超时。我在生产环境里的做法是:优先networkidle,加超时,超时后降级成domcontentloaded再等多几秒。
print_background=True这个参数如果你不设,页面上所有background-color、渐变、背景图全部都会被丢掉。这个也是很多人第一次转PDF时发现的诡异问题:页面看着好好的,转出来白花花一片。
format="A4"控制纸张大小,也可以设成A3、Letter,或者直接用width和height指定像素值。A4默认是210mm×297mm,如果页面比较宽,可以设置landscape=True(横向输入),否则右侧会被硬切掉。
3.3 HTML转PDF的差异点与代码
HTML转PDF和URL转PDF最大的差异在于资源定位。URL转PDF时浏览器知道当前页面地址,所有相对路径资源都能正确拼出来。而直接塞HTML字符串时,页面地址是about:blank,图片、CSS、JS等相对路径全部不知道怎么解析。
我的处理方式是:先把HTML塞给页面,再注入一个<base>标签,指定资源根路径。
from playwright.sync_api import sync_playwright def html_to_pdf(html: str, output_path: str, base_url: str = None): with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.set_content(html, wait_until="networkidle") if base_url: page.evaluate("""(url) => { const base = document.createElement('base'); base.href = url; document.head.appendChild(base); }""", base_url) page.pdf(path=output_path, format="A4", print_background=True) browser.close()base_url参数就是告诉浏览器“页面的根地址是这个,遇到相对路径一律以它为准”。这个技巧在官方文档里没有直接示例,但实际开发中非常常用。
另一点经验:如果你能控制HTML模板的生成,尽量在模板侧就把资源路径拼成绝对路径,或者干脆把CSS和图片转成base64内联。这样运行时完全不依赖外部环境,既快了又稳了,尤其是内网穿透或者登录态失效的情况下,外链资源最容易变成404。
3.4 打印参数怎么定:别忽略这些细节
只要页面是自己控制的,建议直接用CSS@page规则控制分页、页眉、页脚,然后在代码里传prefer_css_page_size=True让PDF引擎服从CSS。这样代码不用写死纸张大小,样式调整也灵活。
如果需要在每页底部显示页码或时间,可以设置display_header_footer=True,再通过header_template和footer_template自定义内容。默认的页眉页脚是Chrome那套“网页标题+日期+URL”的样式,在正式场景里一般是要改掉的。
另外分页问题迟早会遇到。表格被拦腰截断、卡片被劈成两半,这类问题多半要写打印样式:
@media print { .no-print { display: none !important; } body { -webkit-print-color-adjust: exact; } .card, table tr { break-inside: avoid; } }break-inside: avoid意思是“这个元素尽量不要被分页断开”,对表格行、卡片、标题这类元素非常有效。这个问题在纯屏幕展示时完全暴露不出来,只有在打印PDF时才会出现。
4. 前置校验:URL有效性检查不能省
4.1 为什么必须做两层校验
我在接这个需求时,第一版直接拿到URL就去page.goto(),结果发现只要URL格式不合法,浏览器会把输入当搜索词处理,最终生成的PDF是一个搜索引擎结果页,非常隐蔽。更危险的是,像file://、javascript:这类危险协议一旦被接进Web服务,就有被用来读取本地文件或执行脚本的风险。
所以URL转PDF服务必须做两层校验:第一层是格式校验,第二层是远程连通性探测。缺一层都会出大问题。
4.2 用JS做URL格式校验
格式校验我推荐直接用浏览器内置的URL构造函数,不要自己写正则。正则处理IDN域名、IPv6、带端口、query里带特殊字符这些情况又臭又容易出错,内置构造函数帮你处理好了:
function isValidHttpUrl(candidate) { let parsed; try { parsed = new URL(candidate); } catch (_) { return false; } return parsed.protocol === 'http:' || parsed.protocol === 'https:'; }这一个函数就挡住了大多数非法格式。注意协议白名单一定要限定在http:和https:,file:、data:、javascript:、ftp:这些协议一律不允许进入后续流程。
4.3 远程状态码探测与超时处理
格式校验过了,不代表URL真的能打开。批量任务里一个不可达的URL会白白浪费几十秒超时时间,所以我在真正调用浏览器之前,会先用HTTP HEAD请求探测一次:
async function probeUrl(url, timeoutMs = 5000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: controller.signal, }); return res.ok; } catch (_) { return false; } finally { clearTimeout(timer); } }个别后端服务器不支持HEAD请求,会返回404或403,这种情况下可以降级为带Range: bytes=0-0头的GET请求,只取第一个字节就断开连接,既能探测连通性,又不会下载完整页面。
注意,这一步探测通过并不代表浏览器里一定能打开。有些页面虽然返回2xx,但实际内容是被WAF拦下来的验证页面。所以生产代码里,我还会在渲染完成后检查PDF文件大小和页数,如果文件只有几百字节或者页数为0,直接判定失败,走重试或告警。
5. 常见报错排查:404、502、乱码和空白页实录
5.1 404 Not Found:八成不是目标网站的问题
很多人一看到404就以为是目标网站出问题了,但实际排查下来,大部分原因是调用方自己出的。
最常见的是URL拼接错误。业务方传过来的URL可能带中文参数、带空格、带{}这类特殊字符,直接丢给page.goto()会产生各种意外。正确做法是在拼接URL时对所有查询参数做encodeURIComponent,而不是对整个URL做编码。
另一种情况是重定向丢了。有些URL访问后会302到登录页或新的地址,如果服务没有跟随重定向(大部分浏览器默认跟随),最终结果就可能是404。Playwright的page.goto()返回的response对象会携带最终状态码,排查时先把它打印出来:
response = page.goto(url, wait_until="domcontentloaded", timeout=30_000) print(response.status, response.url)只看response.url就能知道浏览器最终停在了哪里,是重定向到404页,还是响应本身404,一目了然。
还有一种隐蔽的场景:页面本身能打开,但页面里某个接口404了,导致部分区域空白。这种最烦人,光看PDF看不出来,要把浏览器控制台日志、网络请求日志全部打出来分析:
page.on("console", lambda msg: print("[console]", msg.text)) page.on("requestfailed", lambda req: print("[failed]", req.url, req.failure))5.2 502 Bad Gateway:先查本地服务和网关
502 Bad Gateway和404完全是两个性质。404至少说明请求到达了目标服务器并得到响应,而502说明网关或代理拿不到上游响应,通常是上游宕机、启动失败、端口未监听导致的。
我在本地调试时遇到过http://127.0.0.1:1572返回502的场景,最终定位是后端服务进程没起来。排查第一步永远是确认服务是不是真的在监听:
curl -v http://127.0.0.1:1572/health如果curl都连不上,问题在服务本身,不在PDF转换代码。如果curl正常但浏览器访问502,那就要查看服务所在层级的访问日志和错误日志,确认是不是反向代理配置把某个路径转发错了。
批量调用第三方API时遇到502,一般需要做重试。重试不能死磕,用指数退避,第一次1秒、第二次2秒、第三次4秒,最多重试3次。如果对方网关持续502,大概率是对方服务故障或触发了限流,继续重试也没用,不如直接告警。
5.3 页面空白、乱码、打印不全的经典原因
乱码问题大多数是中文字体缺失,这个我在前面环境准备部分已经说过,这里不再重复。再补充一个容易踩的:如果页面用了自定义字体,而字体文件是通过懒加载加载的,PDF生成时字体可能还没加载完,文字会先用默认字体渲染,看起来像字体丢失。处理办法是等待字体加载完成:
page.evaluate("document.fonts.ready")空白页最常见的原因是SPA应用。现在的Web应用入口HTML基本都是空壳,内容全靠JavaScript渲染。如果wait_until="domcontentloaded"就去生成PDF,拿到的就是空白页。解决办法是等关键DOM元素出现:
page.wait_for_selector(".main-content", timeout=15_000)也可以强制滚动页面到底部,触发懒加载,让所有图片和组件渲染出来:
page.evaluate("window.scrollTo(0, document.body.scrollHeight)") page.wait_for_timeout(1000)这个技巧在处理无限滚动页面时几乎是必用的,不滚动到底部,后面一半内容永远是空的。
6. 扩展玩法:PDF生成后的二次处理
6.1 用PyMuPDF提取图片并生成预览
PDF生成之后,我通常还会做一层“回读校验”,手段是直接用PyMuPDF解析生成好的PDF,提取文本和图片,确认不是空壳。顺带还能做缩略图,方便后台快速预览。
import fitz # PyMuPDF def extract_pdf_assets(pdf_path, output_dir="."): doc = fitz.open(pdf_path) print(f"总页数: {doc.page_count}") for i, page in enumerate(doc): print(f"第{i+1}页文本预览: {page.get_text()[:100]!r}") for img in page.get_images(full=True): xref = img[0] pix = fitz.Pixmap(doc, xref) pix.save(f"{output_dir}/img_{xref}.png")上面这段就是提取PDF中图片的典型写法。实际业务里,我们用它来判断:如果PDF里没有任何图片,而且文本内容长度是0,那说明这次转换大概率失败了,需要告警重试。
6.2 批量转换的并发控制
批量处理多个URL时,不要频繁创建和销毁浏览器进程,这是性能杀手。正确做法是只启动一个浏览器实例,开多个页面并行处理。一个页面处理完关闭,再开新页面。
同时要注意并发上限,我建议并发控制在3到5个页面以内。Chromium很吃内存,开多了容易把服务器的内存打满,反而更慢。
大型批量任务还有一个隐蔽问题:长时间运行后,就算页面关闭了,渲染进程也可能残留。稳妥的做法是每处理几百个URL,主动重启一次浏览器实例,释放累积的内存碎片。
6.3 输出文件命名与存储时的细节
从页面标题自动生成文件名时,要过滤掉Windows文件系统不允许的字符,否则在Windows服务器上直接保存失败:
const safeName = title.replace(/[\\/:*?"<>|]/g, "_").slice(0, 80);中文文件名和中文PDF内容本身没有冲突,但PDF内部文字如果没有字体嵌入,换一台机器打开就可能乱码。Playwright的Chromium在Linux上渲染中文时,只要你系统字体装好了,它会自动嵌入字体子集,基本不用担心这个问题。
存储目录建议按日期分目录,比如pdfs/2025-06-01/,方便任务失败后按日期回查日志和产物。日志里一定要记录输入URL、开始时间、结束时间、状态码、输出文件大小,这些在排查疑难问题时能救命。
最后补一个我个人的强制习惯:所有转PDF任务,生成之后必须校验文件大小和页数,文件小于1KB或页数为0直接判定失败重试。这个逻辑看起来多余,实际上救过我很多次,因为有些页面看起来一切正常,导出来就是个空壳。如果你也准备在自己的项目里接入类似功能,建议从最小闭环开始:先拿一个URL手动转到能出PDF,再逐渐加批量、并发、重试这些复杂度。坑都是一步步踩出来的,越早踩,越不痛。
本文还有配套的精品资源,点击获取