ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

URL转PDF与HTML转PDF:基于Playwright的完整方案解析

URL转PDF与HTML转PDF:基于Playwright的完整方案解析 简介一套面向 Java 开发人员的 URL/HTML 转 PDF 轻量实现方案基于 PhantomJS 完成。作者对比了 wkhtmltopdf 与 IText 后认为 PhantomJS 在体积、转换完整度及 URL 直转支持上更有优势因此整理成可直接运行的 demo。资源包含 85 个文件约 34.65MB其中 67 个 XML 构成 Maven 依赖与项目配置5 个 Java 源文件与 5 个 class 实现核心转换逻辑另有 2 个 JS 脚本、2 个 properties 和 2 个 exe 用于 PhantomJS 调用整体结构清晰便于二次开发。demo 基于 Spring Boot 构建集成了 Web 环境读者可快速启动并通过接口将指定链接或 HTML 转为 PDF也可参考源码调整转换参数以适配实际业务场景。已有 1108 人学习适合需要低成本实现网页打印、报告导出等功能的 Java 工程师。 做网页转PDF这个功能最早是因为项目里要批量生成电子发票存档。客户要求把一个带参数的URL丢过来服务端自动渲染成PDF并归档每天上万次的调用量跑了一段时间踩了不少坑后来沉淀成一套相对稳定的方案。这篇就围绕URL转PDF和HTML转PDF这两个方向把选型逻辑、核心实现、以及生产环境里最容易出的问题一次说清楚。1. 为什么这个需求会反复出现先搞清楚要解决的本质问题做技术方案之前我习惯先反问一句用户到底要什么URL转PDF这个需求听起来简单但拆开看背后至少有三种完全不同的诉求。第一种是网页归档。比如电商平台的订单详情页、新闻页面、风控系统的证据留存需要把某个时刻的页面状态完整保存下来要求页面里的图片、样式、排版都和浏览器里看到的一致。第二种是报告生成。内部系统里统计好的图表页面通过URL转PDF变成周报月报这种场景对页面的打印样式要求很高经常需要专门写一套media print的CSS。第三种是单据票据。电子发票、合同、物流面单这类PDF不能只是看起来像就行文字必须能选中、能检索尺寸要固定纸张大小要可控。不同诉求对应的技术路线完全不一样。如果只是网页归档截图型方案就够如果要生成可复用的正式文档就得走打印型渲染让浏览器按打印样式重新排版。最忌讳的是不做区分一套方案打天下最后要么PDF里文字全是图片没法检索要么排版和线上页面差距过大被业务方打回。第二个维度是源头形态。URL转PDF意味着目标页面在远程服务器上访问它可能涉及登录态、跨域资源、重定向、反爬策略。HTML转PDF则是本地有HTML字符串或文件没有网络依赖出问题排查起来更快。这两者的调试难度差一个量级后面我会详细说。第三个维度是触发方式。是用户在页面上点一个按钮实时生成还是服务端批量异步处理实时生成对延迟敏感一个页面3秒内必须出PDF批量处理则更看重吞吐量和稳定性单张慢一点没关系但不能因为某一张卡死拖垮整个队列。这个选择直接决定了你要不要把渲染进程常驻、要不要做任务队列。想清楚这三件事工具选型就顺理成章了。2. 方案选型无头浏览器、命令行工具、纯Python渲染三条路怎么挑我前后试过三条主流路线以Playwright和Puppeteer为代表的无头浏览器方案以wkhtmltopdf为代表的命令行工具以及以WeasyPrint为代表的纯Python渲染方案。先给结论通用性最好的是无头浏览器二次开发成本最低HTML转PDF场景下WeasyPrint在某些场景反而更省事wkhtmltopdf除非维护老项目否则不建议新启用。三条路线的对比如下维度Playwright / PuppeteerwkhtmltopdfWeasyPrint渲染内核Chromium老版WebKit自研HTML/CSS解析器JavaScript执行完整支持支持但兼容性差不支持CSS支持度和Chrome一致只支持旧CSS支持部分CSS打印样式友好部署体积300MB左右几十MB轻量中文与字体依赖系统字体依赖系统字体依赖系统字体适合场景任意复杂网页简单静态页面规范化报告/文档选无头浏览器时Playwright和Puppeteer之间我推荐Playwright。原因有几个它的page.pdf()对打印样式的处理更稳定自动等待机制比手写延时靠谱Python和Node两套API我都在用整体一致性做得好。Puppeteer的优势是生态老、资料多但Playwright的wait_for_load_state和page.route拦截机制在解决实际问题时明显更好用。WeasyPrint单独说一下。它不跑JavaScript所以动态页面没法处理。但反过来如果你的HTML是后端模板渲染出来的静态结构它对分页、页眉页脚、目录生成的支持反而是三者里最省心的。比如生成一份带页码、带水印、每页固定表头的财务报告WeasyPrint几行代码就能搞定无头浏览器反而要写一堆分页逻辑。wkhtmltopdf我最初也试过但它的渲染内核停留在老WebKit同一个页面在Chrome里正常在它那边经常出现flex布局错乱、CSS变量不识别的问题。现在已经放弃在生产环境使用只在极少数老服务器上没有图形库部署条件时才会考虑它。3. Playwright核心实现从URL到PDF的关键代码与参数逻辑选定了Playwright之后核心代码其实不长但每个参数背后都有讲究。下面这段是我在Python服务里沉淀下来的版本import asyncio from playwright.async_api import async_playwright async def url_to_pdf(url: str, output_path: str, options: dict None): async with async_playwright() as p: browser await p.chromium.launch( args[--no-sandbox, --force-device-scale-factor1] ) context await browser.new_context( viewport{width: 1280, height: 720}, localezh-CN, timezone_idAsia/Shanghai, ) page await context.new_page() response await page.goto( url, wait_untilnetworkidle, timeout30_000, ) # 检查HTTP状态码404这类错误要提前暴露 if response and response.status 400: raise RuntimeError(f页面访问失败: HTTP {response.status} {url}) # 等待关键元素出现比固定延时可靠 await page.wait_for_selector(#main-content, timeout10_000) await page.pdf( pathoutput_path, formatA4, margin{top: 12mm, bottom: 12mm, left: 10mm, right: 10mm}, print_backgroundTrue, display_header_footerTrue, header_templatediv/div, footer_template div stylewidth:100%;text-align:center;font-size:8px;color:#999; span classpageNumber/span / span classtotalPages/span /div , ) await browser.close()几个关键点展开说。wait_untilnetworkidle表示等网络空闲再执行PDF但实际操作中不能完全依赖它。有些页面会持续轮询接口永远不空闲这时30秒超时直接抛错。我的经验是改成wait_untilload再针对具体页面用wait_for_selector等待业务关键元素渲染出来。这样既不会等死又能保证内容是真的出来了。print_backgroundTrue必须开。很多页面用背景色做视觉分区不开这个参数PDF里的背景色、渐变、圆角样式全部丢失页面瞬间从精致变白板。页眉页脚模板里Playwright默认生成的页眉页脚带日期和标题丑而且容易泄露内部信息。我一般用空的header_template屏蔽页眉页脚只保留页码。注意模板里的CSS只能用内联样式外部样式表不生效这是很多新手踏进去的坑。还有一个容易被忽略的点localezh-CN和timezone_idAsia/Shanghai。不设置的话页面里的日期格式化、数字千分位都可能按默认美国时区渲染对生成财务票据类PDF是致命伤。4. 从HTML字符串生成PDF离线方案与动态内容的取舍有相当一部分需求HTML并不在远程URL上而是业务系统后端拼好的字符串。这个场景省去了页面访问的网络开销但要另外解决资源引用和动态渲染两个问题。先看一个典型的HTML转PDF代码路径async def html_to_pdf(html_content: str, output_path: str): async with async_playwright() as p: browser await p.chromium.launch(args[--no-sandbox]) page await browser.new_page() await page.set_content(html_content, wait_untilload) await page.pdf(pathoutput_path, formatA4, print_backgroundTrue) await browser.close()这里最头疼的问题通常是图片和CSS的引用路径。HTML字符串里如果写的是相对路径/static/img/logo.png在无头浏览器里解析时没有baseURL直接失效。解决方案有两个要么在处理HTML时把所有相对路径替换成绝对URL要么用page.route拦截请求统一改写async def rewrite_assets(route): req_url route.request.url new_url req_url.replace(http://internal.local, https://cdn.example.com) await route.continue_(urlnew_url) await page.route(**/*, rewrite_assets)route拦截还有一个高级用法把图片请求直接替换成本地base64数据。有些内网系统的图片需要带Header才能访问无头浏览器直接加载会403用route.fulfill填一个带图片字节的响应就能绕过去。这个技巧在处理带鉴权的图片资源时非常实用。再一个容易踩的坑是如果HTML里包含script标签page.set_content默认会执行里面的JavaScript但很多时候本地HTML字符串里的脚本并不想在PDF渲染时执行。我建议在set_content之前用page.add_init_script去屏蔽或者直接把script标签在服务端加工时去掉。两种方式我用下来后一种更可控。至于真正的SPA应用比如Vue或React构建的页面HTML字符串里通常只有一个空壳root节点所有内容靠JS渲染。这种就别用HTML转PDF了老老实实起个服务把页面跑起来走URL转PDF方案更合适。HTML转PDF的适用边界就是服务端渲染完成的静态页面。5. 生产环境的多页场景分页控制、页眉页脚与打印样式平时写网页的人很少关注分页但转PDF后内容从一个长页面变成多张纸在哪里断开就成了一个大问题。默认情况下浏览器会把内容从中间拦腰截断出现标题在页面底部、表格跨页被切割、代码块一半上一半下的情况。解决办法是给业务页面补一套专门的打印样式。几个我用得最多的CSS规则media print { .card, .section { break-inside: avoid; } h1, h2, h3 { break-after: avoid; } table, pre, blockquote { break-inside: avoid; } }break-inside: avoid表示这块内容尽量不被切开break-after: avoid表示标题后面不要紧跟分页这样标题不会孤零零出现在页面底部。这套规则对表格、代码块、图片容器尤其关键一个带边框的表格被拦腰劈开非常难看。如果需要在每页固定显示公司名称、标题、页码优先用Playwright的页眉页脚模板而不是往页面里塞固定定位的div。原因很简单打印模式下固定定位的元素在不同浏览器里行为不一致而页眉页脚模板是Chromium原生支持的每页都会自动复现。模板里还能用span classtitle/span直接引用页面的title标签内容。多页文档还有一个容易忽略的点页边距。默认打印边距偏大商业报告一般需要自定义。我在上面的代码里用了12mm上下边距、10mm左右边距这个尺寸配合页脚页码观感接近日常办公文档。要注意的是页眉页脚模板的尺寸不会自动跟随边距需要自己在模板里预留高度。更进一步如果要做多级PDF合并比如多个URL分别生成PDF再合并为一个文件推荐先用Playwright分别渲染出单片PDF再用pypdf库做合并。比在同一个页面里循环添加内容可靠因为每个URL的页面结构独立合并后即使某一页内容异常也不影响其他部分。6. 常见错误与稳定性加固404、502、超时和内存问题的排查链路这一节重点说说线上跑起来之后的幺蛾子。搜索引擎热词里经常看到unexpected status 404 not found、unexpected status 502 bad gateway这类报错我在实际调用URL转PDF接口时也经常撞上。这些错误表面上是请求失败但根因各不相同一条条排查下来基本有规律。404问题最常见的两个来源一是URL本身拼错比如参数没编码、带中文没做URLEncode二是业务系统对无头浏览器有判断返回了伪404。排查时先拿curl直接访问同一个URL看状态码如果curl正常而Playwright报404大概率是UA或请求头被拦截给browser.new_context配上真实的user_agent就能解决。502和504基本是服务端网关超时。页面本身逻辑重3秒内渲染不完网关先断了。我的处理方式是先把page.goto的timeout调到60秒再把请求头的Accept-Language和Referer伪装成正常浏览器。如果还不行就要考虑是不是目标服务对特定IP段有限流。还有一种情况特别迷惑人page.goto返回正常但关键数据迟迟不渲染。这不是网络错误是页面里的异步请求挂住了。排查手法是在等待阶段给页面拍快照把page.content()打出来看DOM结构到底加载到哪里比猜快得多。内存问题是批量转PDF的最大隐患。Chromium每个标签页占内存并不均匀页面越复杂占用越高。跑批量任务时我发现长时间运行的浏览器实例内存会慢慢涨上去即使关闭了页面内存也不会完全释放。最终采上生产环境的策略是每处理N个任务就重启一次浏览器上下文并且给每个任务单独建context任务结束立即关闭。牺牲一点启动效率换内存稳定。最后再放一个压箱底的经验给所有外部资源请求加超时和失败容错。page.route里遇到图片或字体加载失败时不要抛异常直接让请求失败并继续渲染这样才能保证主内容已经生成其他辅助资源失败不阻塞整个PDF任务。我在处理大量真实网页后确认很多客户提供的URL里都存在部分资源404的情况过于严格反而会频繁中断任务。7. 一些能直接提升体验的补充鉴权页面、滚动加载、动态图表处理最后分享几个在真实业务里高频出现、但常规文档不常提到的场景。带登录态的页面转PDF是政务、金融类系统的标配需求。不要尝试在无头浏览器里模拟登录流程那会遇到验证码、短信验证等一堆麻烦。正确做法是让业务系统提供一个带有效Token的临时访问链接或者把Token附加到Cookie里灌进context。具体操作是用context.add_cookies将一个短期有效的会话Cookie注入浏览器这样既能访问受保护页面又能保证安全时效。滚动懒加载的页面典型如长列表、瀑布流networkidle等不到低部内容。我的做法是先设置一个很大的视口高度比如viewport{width: 1280, height: 2000}然后滚动多次强制触发懒加载await page.evaluate( async () { const step 400; for (let y 0; y document.body.scrollHeight; y step) { window.scrollTo(0, y); await new Promise(r setTimeout(r, 50)); } window.scrollTo(0, 0); } )注意滚完之后要回到顶部否则PDF只从当前滚动位置开始渲染。这个细节我一开始忽略连续出了好几张半空白的票据后才意识到。动态图表的处理思路是完全相反的。ECharts这类图表库在PDF里的表现取决于渲染时机wait_untilnetworkidle不代表图表动画已经结束。稳妥的做法是把动画时间缩短或直接禁用我在页面里注入CSS把transition和animation全部置为none同时等固定时间让Canvas最终状态稳定下来。图表转出来的PDF最怕的就是线条还在渐变过程中被截住。关于中文字体Linux服务器上部署无头浏览器默认经常没有中文字体渲染结果全是豆腐块。安装fonts-noto-cjk这类字体包是最快的解法但要注意服务器上新增字体后必须重启浏览器进程才能生效这个坑藏在进程缓存里排查起来很隐蔽。整个方案从最初的单一脚本演进到现在的稳定服务最大的心得是永远不要高估一个远程页面的友好程度。你无法控制对方的HTML怎么写、接口响应有多快、有没有反爬拦截唯一能做的是把每一步的失败路径都想到把错误暴露在日志里而不是吞掉。做到这一点URL转PDF和HTML转PDF就真的只是输入地址、输出文件这么简单了。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进