☰
URL转PDF与HTML转PDF:无头浏览器选型与避坑指南
2026/10/14 9:49:59 网站建设 项目流程

简介:面向Java开发者的URL转PDF/HTML转PDF轻量级Demo包,基于PhantomJS实现。该方案体积小于wkhtmltopdf,且相比IText能直接转换URL并保持较高完整度,适合需要快速生成网页快照、报表导出或文档预览的Web应用场景。资源共85个文件,压缩包约34.65MB,包含5个Java源码、5个编译后的class、2个PhantomJS可执行exe与核心js脚本,以及67个Maven依赖描述xml和2个properties配置文件,完整呈现一个Spring Boot风格的工程结构。通过源码与脚本对照,可学习PhantomJS调用参数的封装、静态页面与在线URL两种转换入口的实现,以及如何将转换能力嵌入Java后端。已有1108人学习下载,适合熟悉Spring Boot、希望低成本集成PDF生成功能的初中级开发者。

1. URL转PDF与HTML转PDF:先想清楚你到底是哪种需求

这类需求通常来自三个场景:给网页存档、把内部报表导出给客户、还有把HTML模板变成正式文档。很多人一开始直接用浏览器Ctrl+P另存为PDF,然后发现页面被切成好几段、背景色变淡、表格错位。这并不怪浏览器,而是把两种需求混在了一起:一种是“把网页原样存档”,必须等动态内容加载完再打印;另一种是“把HTML模板输出成正式文档”,要控制分页、字体和边距,需要从头就按打印媒体来写样式。选错方案的直接后果,就是生成出来能看但没法用。本文不扯理论,直接把四种主流工具放在一起对比,从一行命令到批量化脚本,讲清楚怎么选、怎么做、以及翻车前都踩在哪几个坑上。无论你是被临时拉来生成报告的新手,还是要做批量导出服务的开发者,都能从这里找到可用方案和边界。

2. 四种主流转换方案:原理对比与选型边界

2.1 无头浏览器打印链路:Chromium的CDP打印是怎么跑通的

URL转PDF听起来是把网页截个图,实际上走的是完整的打印链路:无头Chromium启动后先像普通浏览器那样请求页面、执行JavaScript、加载字体和图片,等布局完成之后,再通过CDP(Chrome DevTools Protocol)调用Page.printToPDF把当前文档按指定纸张尺寸重新排版输出。这就是它与右键截图最本质的差别——截图是屏幕像素的采样,打印是文档的重新分页。

这条链路上有三个因素会直接决定PDF长什么样:媒体类型、背景和加载时机。媒体类型决定页面是按屏幕样式渲染还是按打印样式渲染,position: fixed的头部、渐变背景这类视觉元素在打印输出里默认被过滤;背景决定是否保留background-color和背景图;加载时机则决定异步图表、懒加载图片有没有进入文档。明白这三点,后面所有调参就都有依据了。

我一般会在写代码之前先在浏览器里做一次快速验证:打开DevTools,按Ctrl+Shift+P调出命令面板,输入Rendering打开渲染标签页,把CSS媒体类型切到print,再按Ctrl+Shift+E切换设备模拟,就能预览页面在打印输出里大概长什么样。这一步习惯性先做,能省去很多来回转PDF再打开的等待时间。

2.2 wkhtmltopdf与WeasyPrint:轻量方案的适用边界

wkhtmltopdf 是用Qt WebKit做渲染的老牌方案。它不需要启动一个完整浏览器进程,而是自己构建渲染环境,所以启动快、内存占用低,一条命令就能完成转换。但它也有明显的时代印记:内核里的JavaScript引擎版本停留在几年以前,现代语法、async/await、以及依赖canvas的图表库经常直接让内容缺块或报错。所以它更适合对交互要求低、内容偏静态的页面,比如把后台系统里某个表单打印成PDF。

WeasyPrint 走的是纯Python路线,不跑JavaScript,直接用CSS布局引擎把HTML文档重排。它的强项是对CSS打印规范的支持非常完整,@page分页规则、running header、脚注、目录生成这些精细控制都能做,这是浏览器引擎反而会比较吃力的领域。缺点是慢,尤其是一份几百页的排版文档,渲染耗时可能到几十秒甚至分钟级,且任何动态数据都必须提前在HTML里就绪,它不负责等你。

到这一步可以把技术黑匣子拆开了:要执行JavaScript才能看到内容的页面,选无头Chromium;不需要JavaScript且要精细排版,选WeasyPrint;任务量大、对排版要求不高,选wkhtmltopdf最省资源。如果硬要把这三者的能力调转来用,不是不能跑,而是你会花大量时间在“为什么这里缺一块”上。

2.3 场景对号入座:输入源决定选型

输入源特征推荐方案理由
静态HTML模板,无异步逻辑WeasyPrint / wkhtmltopdf资源占用低,排版可控
动态报表页,含图表、懒加载无头Chromium(Puppeteer / Playwright)完整执行JavaScript
内网系统页面,带登录态无头Chromium能带Cookie和会话信息
大批量简单转换wkhtmltopdf / 无头Chromium脚本各自都能并发,前者更轻
需要严格控制页眉页脚、目录WeasyPrint 或 无头Chromium + CSS打印CSS支持最全

还有一个常见选项是在线转换API。对完全公开的网页它是省事的,但报表、发票、合同这类页面几乎都带着访问控制或敏感数据,把页面内容交给第三方服务之前,先想清楚数据边界。我一般只在完全公开的内容上考虑它,内部系统一律本地转换。

决策逻辑不复杂:先问你的页面离开JavaScript能不能正确显示。能,就在WeasyPrint和wkhtmltopdf之间按排版要求选;不能,就直奔无头Chromium。然后再问要不要批量、要不要做成服务:批量就脚本化,做服务就再加队列和临时目录清理。顺序对了,后面基本不用推翻重来。

3. 本地跑通最小可复现:从一行命令到可维护脚本

3.1 Chrome headless一行命令生成PDF:先把链路跑通

最小可复现的命令行方案是用系统里已经装好的Chrome或Chromium直接转。这是排查环境问题最快的方式,因为只要这条命令能出文件,说明字体、网络、权限基本没问题,问题多半出在页面本身。

chrome --headless=new --disable-gpu --no-sandbox \ --print-to-pdf=out.pdf \ --no-pdf-header-footer \ --virtual-time-budget=10000 \ http://localhost:8080/report.html

--headless=new是较新的无头模式,对现代CSS特性的支持比老模式完整;--no-sandbox在容器和CI环境里经常需要,去掉它反而会因为权限问题启动失败;--print-to-pdf指定输出文件路径;--no-pdf-header-footer用于去掉默认页眉页脚,否则输出里会多出“file:///…”这样的灰条;--virtual-time-budget=10000是给页面10秒的虚拟时间,让它跑完异步请求和延时渲染,值太小懒加载内容会缺,值太大会拖慢整体速度。

如果你要转的是本地HTML文件,把最后一个参数换成file:///tmp/report.html即可,其余不变。URL转PDF和HTML转PDF在这条命令上的差别,仅仅是入口参数不同,后面所有脚本化方案也都延续这个规律。

3.2 Puppeteer脚本化:等到网络空闲、打印背景、设好页边距

命令行适合一次性验证和CI里的简单步骤,但只要涉及动态等待、动态文件名、错误重试,就该换成脚本。Puppeteer是Node生态里最成熟的方案,下面这段是URL转PDF的最小脚本:

// 安装:npm install puppeteer const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] }); const page = await browser.newPage(); await page.goto('http://localhost:8080/report.html', { waitUntil: 'networkidle0', timeout: 60000, }); await page.emulateMediaType('print'); await page.pdf({ path: 'out.pdf', format: 'A4', printBackground: true, margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }, }); await browser.close(); })();

goto里的waitUntil: 'networkidle0'表示等页面所有网络请求完成后才继续,适合大多数报表页;遇到长轮询或WebSocket页面时它会一直等,这时要换成'domcontentloaded'再配合下面讲到的显式等待。emulateMediaType('print')等价于DevTools里切换CSS媒体类型,网页里的@media print规则会在这一行之后生效。printBackground: true决定PDF里是否保留背景色和背景图,发票、签章、表格斑马纹都依赖它,建议默认打开。

margin用mm单位对应真实纸张。如果你要的是HTML转PDF,把page.goto换成page.setContent(htmlString, { waitUntil: 'networkidle0' })即可。需要注意setContent之后页面里相对路径的资源会失效,因为它们没有基准URL;常见做法是把HTML写成临时文件再用file://协议访问,或者把所有资源改成绝对路径/内联。我一般会优先用file://方式,因为资源路径最不容易出幺蛾子。

3.3 Python生态:Playwright与wkhtmltopdf的两种写法

Python场景下最顺手的无头方案是Playwright,它和Puppeteer师出同门,API更现代,支持Chromium、Firefox和WebKit三套内核。最小可复现脚本如下:

# 安装:pip install playwright && playwright install chromium from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto("http://localhost:8080/report.html", wait_until="networkidle") page.pdf( path="out.pdf", format="A4", print_background=True, margin={"top": "20mm", "bottom": "20mm", "left": "15mm", "right": "15mm"} ) browser.close()

注意Playwright里page.pdf目前只能在无头模式下调用,传了headless=False会直接报错。Python端的HTML转PDF同样可以用page.set_content(html_string, wait_until="networkidle")。

如果你的页面本身不依赖JavaScript,或者想要更轻量的方案,wkhtmltopdf仍然值得用。它的命令式参数比Puppeteer直观很多,典型的静态页面转换长这样:

wkhtmltopdf --print-media-type --encoding utf-8 \ --margin-top 20mm --margin-bottom 20mm \ --margin-left 15mm --margin-right 15mm \ --enable-local-file-access \ http://localhost:8080/report.html out.pdf

--print-media-type对应前面说的print媒体类型;--enable-local-file-access在新版本里默认关闭,不加的话本地字体、图片、CSS都读不进来,这是从旧版本升级后最容易翻车的一个参数。wkhtmltopdf对现代CSS的flex/grid布局支持很弱,页面里大量使用这些特性的,还是优先走无头Chromium。

4. 转换避坑指南:字体、分页、异步加载与CSS媒体类型

4.1 PDF里中文全是方块:服务器缺字体

现象:本机预览一切正常,代码部署到服务器之后,生成的PDF里中文全部变成方框。这是URL转PDF和HTML转PDF场景里出现频率最高的一个问题,而且通常只在环境迁移时暴露。

原因:无头Chromium的字体渲染完全依赖系统字体库,很多精简版Linux系统和基础Docker镜像默认不装CJK(中日韩)字体。浏览器在页面上能正常显示是因为开发者本机装了全套字体,而服务器上找不到中文字体时,字形回退就直接画成了空方块。

解决:在服务器或镜像里安装一套中文字体。Debian系系统可以用以下命令一次搞定:

apt-get update && apt-get install -y fonts-noto-cjk && fc-cache -f

装完后再用fc-list :lang=zh检查,能看到中文字体记录就说明系统认了。如果走Docker,记得把这一层commit进镜像,否则每次重建容器都会丢失。这个坑我踩过好几次,每次都是本机好好的、一上容器就全变成方块,后来养成了“先看字体再调样式”的习惯。

4.2 样式跟浏览器看到的不一样:媒体类型与背景没对上

现象:页面里的导航栏、固定提示条、按钮背景色都出现在PDF里,或者反过来,明明写了打印样式却完全没生效。

原因:这是媒体类型和背景设置两件事叠在一起。浏览器默认以screen媒体类型渲染,页面里@media screen的样式会被应用;而打印场景需要的是@media print规则。同时,即使媒体类型对了,如果没开启打印背景,所有background-color和background-image依然会被丢弃。

解决:在脚本里做两件事,一是调用emulateMediaType('print')/--print-media-type切换媒体类型,二是打开背景开关。Puppeteer和Playwright里对应printBackground: true,wkhtmltopdf没有直接的背景开关,但它在结构上会把背景色按打印规则处理,所以静态页面通常不受影响。调试时先在DevTools的Rendering面板里切到print预览,确认页面本身在打印媒体下是对的,再跑转换脚本,这样能快速定位是样式问题还是参数问题。

4.3 表格被拦腰截断、表头不重复

现象:跨页表格的某一行只渲染了上半部分,下半部分跑到下一页,而且第二页的表头消失了。生成的PDF看起来像被裁过一样,发给客户非常掉价。

原因:打印分页时,浏览器默认允许在任意行边界处断页,表格行被切断、表头没有跨页重复,都是默认行为。

解决:在打印样式里加三条规则,覆盖表格断行和表头重复:

thead { display: table-header-group; } tr, td, th { break-inside: avoid; } td { overflow-wrap: anywhere; }

break-inside: avoid让每一行尽量保持完整,table-header-group让表头在每页自动重复,overflow-wrap: anywhere解决长串文本把单元格撑破的问题。Chrome、wkhtmltopdf和WeasyPrint对这三条的支持度都不错,是打印CSS里通用性很高的一组配置。

4.4 图表和懒加载图片没渲染出来:等待策略不够

现象:页面里用ECharts或其余图表库画的图,生成的PDF里是一块空白;懒加载图片在页面滚动时才加载,转出来也是缺的。

原因:转换脚本在异步图表和图片还没完成渲染时就调用了pdf()。networkidle0等的是网络请求,图表库的动画和图片解码不一定会产生新的网络请求,所以它等得到不代表渲染完成。

解决:在调用pdf()之前加显式等待,等关键节点出现再输出:

// 等图表容器渲染出 canvas 或 svg await page.waitForSelector('.report-chart canvas, .report-chart svg', { timeout: 20000 }); // 等所有图片真正解码完成 await page.waitForFunction(() => [...document.images].every(img => img.complete && img.naturalWidth > 0), { timeout: 20000 } );

第一行的选择器要跟页面实际输出结构对齐,图表库用canvas就等canvas,用svg就等svg;第二行img.complete && img.naturalWidth > 0是判断图片是否加载完成的可靠表达式,比单纯等网络空闲精确得多。如果页面里有长轮询导致networkidle0永远不触发,就把goto的waitUntil改成'domcontentloaded',然后完全依赖这种显式等待。

4.5 容器里内存暴涨或进程崩溃:/dev/shm太小

现象:在Docker容器里跑无头Chromium,转换一个内容较多、页面较长的URL时报错退出,日志里能看到Segmentation fault或者直接没有输出但生成的文件为空。

原因:Chromium默认把共享内存写在/dev/shm,而Docker容器的/dev/shm默认只有64MB。页面DOM一大,共享内存打满,进程就崩了。这个问题在本机跑不出来的原因,是本机的/dev/shm通常是物理内存的一半。

解决:两个方向,一是启动时加参数绕开共享内存限制,二是调大容器的共享内存。加参数的做法最省事:

const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] });

--disable-dev-shm-usage让Chromium改用/tmp写共享内存。如果页面确实很大,仍然建议在docker run时加--shm-size=2g给足内存。这两个手段我一般同时用,一是在脚本里加启动参数兜底,二是在编排文件里给容器显式声明内存上限。

5. 进阶定制:页眉页脚、目录、水印与批量流水线

5.1 用headerTemplate与footerTemplate生成页码、日期、水印

只导出正文PDF往往是第一步,真实业务里几乎都要带页眉页脚:页码、公司名称、日期、密级标记、水印。Puppeteer和Playwright都支持通过模板直接控制页眉页脚,代码比预想的简单:

await page.pdf({ path: 'out.pdf', format: 'A4', displayHeaderFooter: true, headerTemplate: `<div style="font-size:10px; margin:0 20mm; color:#666;">内部资料 | 仅限相关方</div>`, footerTemplate: `<div style="font-size:10px; margin:0 20mm; width:100%; text-align:center;"> <span class="pageNumber"></span> / <span class="totalPages"></span></div>`, margin: { top: '30mm', bottom: '30mm', left: '15mm', right: '15mm' }, });

displayHeaderFooter: true打开页眉页脚渲染,headerTemplate和footerTemplate里可以用pageNumber、totalPages、title、date这几个内置占位符。关键点是:页眉页脚区域默认没有边距,必须在margin里给足top和bottom空间,否则模板内容会贴到页面边缘甚至被裁掉。还有一个细节:模板里的字体和图片只能内联,不能引用外部资源文件,否则页眉里是个空框。

水印可以用同样的思路实现,在模板里放一个透明度极低的固定定位div,覆盖整个页面的文字内容。需要注意水印不要做成每页一个的大号图片,体积会迅速膨胀,用文字水印或SVG更划算。

5.2 CSS @page规则:分页位置与切边控制

除了脚本里的margin参数,页面本身的CSS也有一套独立的打印控制,两者叠加时容易产生混淆。CSS标准里的@page规则专门服务于打印,常见用法如下:

@page { size: A4; margin: 20mm; } @page :first { margin-top: 0; } .section { break-before: page; }

size: A4指定纸张尺寸,@page :first让首页不显示页眉页脚或者使用不同的边距,.section { break-before: page }相当于手动分页符。注意Chrome在同时收到page.pdf()的margin参数和CSS里的@page margin时,会以脚本参数为准,CSS里的margin被覆盖。所以要么全在脚本参数里控制边距,要么全在CSS里控制,混用的结果就是你调了半天的CSS不生效。WeasyPrint则完全以CSS@page为准,脚本层面不需要也不支持传margin,这是它与无头浏览器的一个关键差异。

5.3 批量转换:并发要限流,临时目录要清理

批量生成PDF时最容易犯的错误是并发开太大。每个newPage()都是一个真实的渲染进程,并发10个意味着同时10个页面在做完整排版,内存立刻被打满,然后进程集体退出。合理做法是控制并发数,同时每个page用完立即关闭:

const fs = require('fs'); const puppeteer = require('puppeteer'); const urls = fs.readFileSync('urls.txt', 'utf-8').split('\n').filter(Boolean); (async () => { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] }); const maxConcurrency = 3; let active = 0; let index = 0; async function worker() { while (index < urls.length) { const url = urls[index++]; const file = `pdfs/${Date.now()}_${index}.pdf`; try { const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); await page.pdf({ path: file, format: 'A4', printBackground: true }); await page.close(); } catch (err) { console.error('failed:', url, err.message); } } } const workers = Array.from({ length: maxConcurrency }, () => worker()); await Promise.all(workers); await browser.close(); })();

这段脚本用index作为共享计数器,maxConcurrency个worker从同一个数组里轮流取任务,谁空谁处理,天然完成了线程池式的调度。文件名里的Date.now()保证不覆盖,最后browser.close()确保所有子进程退出。并发数一般建议3到5,具体看机器的CPU核数和内存大小,而不是拍脑袋设个100,那样的代价是换来一堆超时和崩溃。

5.4 封装成HTTP服务:排队、超时与临时文件清理

当转换不再是命令行任务而是被别的系统调用时,就需要把它包成HTTP接口。这里最核心的不是转换本身,而是并发控制和临时文件清理。直接在每个请求里newPage(),几十个请求能把浏览器进程拖垮。一个可用的简化版做法是全局计数器加轮询等待:

let inflight = 0; const CAP = 2; const wait = (ms) => new Promise(r => setTimeout(r, ms)); app.post('/to-pdf', async (req, res) => { while (inflight >= CAP) await wait(100); inflight++; const tmp = `/tmp/${Date.now()}_${Math.random().toString(36).slice(2)}.pdf`; try { const page = await browser.newPage(); try { await page.goto(req.body.url, { waitUntil: 'networkidle0', timeout: 30000 }); await page.pdf({ path: tmp, format: 'A4', printBackground: true }); } finally { await page.close(); } res.download(tmp); } catch (err) { res.status(502).json({ error: err.message }); } finally { inflight--; setTimeout(() => fs.unlink(tmp, () => {}), 60_000); } });

代码里的page.close()放在内层finally,保证goto出错时页面也不会泄漏;外层finally里用一个延迟60秒的unlink清理临时文件,避免请求结束半天后磁盘被占满。生产环境里可以把轮询等待换成信号量或队列库,但核心原则不变:并发上限必须存在,临时文件必须有清理逻辑,超时时间必须明确。

6. 收尾:验证PDF渲染结果的三个检查点

PDF生成出来了,不代表任务完成。我见过太多“本机能打开、客户那边全是乱码”的交付,问题多半出在验证环节。这里分享三个我固定会跑的检查点。

6.1 用命令行工具做PDF体检

pdfinfo out.pdf pdffonts out.pdf pdfimages -list out.pdf | head

pdfinfo看页数和页面尺寸,确认分页符合预期;pdffonts看字体嵌入情况,输出里的emb列必须是yes,出现no就说明字体没嵌进去,换一台机器PDF就可能变天书;pdfimages -list看图片列表,确认每张图的尺寸和压缩方式。

检查项方法通过标准
页数/尺寸pdfinfo out.pdf页数符合预期,尺寸为A4或指定规格
字体嵌入pdffonts out.pdf字体行emb标记为yes,无no embed告警
图片质量pdfimages -list out.pdf图片分辨率不低于打印需要的150dpi

6.2 把验证固化到流程里

本地手动检查只能保证这一次,流程固化才能保证以后每一次。我会在自动化脚本里把pdffonts的输出接到门禁逻辑里,检测到no embed就直接标记失败;对关键页面还会用pdftoppm -png -r 96把PDF逐页转成图片,和基准图做像素diff,颜色偏离超过阈值就报警。

pdftoppm -png -r 96 out.pdf page for f in page-*.png; do compare "$f" "baseline/${f}" -metric AE /dev/null || exit 1 done

我自己最后留的习惯是:任何PDF生成后先跑一遍pdffonts,看一眼输出里的embed标志,有no就不发出去;这个习惯救了我好几次,因为字体没嵌进去的文件换个阅读器就是天书。视觉回归和字体检查听起来麻烦,但只要把它们放进自动化的第一步,后面所有改动都有人兜底。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询