使用无头浏览器Playwright解决Web打印生成PDF的问题

引言

在Web应用开发中,将HTML内容转换为PDF并进行打印一直是一个技术难点。传统的解决方案如window.print()存在样式丢失、兼容性差等问题。最近在研究一个基于Electron的Web打印项目npm包web-print-pdf,发现它巧妙地使用了Playwright无头浏览器来解决这个问题

传统Web打印的痛点

1. 浏览器兼容性问题

javascript 复制代码
// 传统的打印方式
window.print();

这种方式存在以下问题:

  • 不同浏览器渲染效果差异巨大
  • CSS样式支持不完整
  • 无法精确控制打印参数

2. 样式控制困难

css 复制代码
@media print {
    /* 打印样式经常失效 */
    .no-print { display: none; }
    .print-only { display: block; }
}

3. 功能单一

  • 无法批量处理
  • 缺乏预览功能
  • 打印参数配置有限

Playwright的解决方案

核心思路

使用Playwright无头浏览器来渲染HTML内容,然后生成高质量的PDF文档。这种方式完美解决了传统Web打印的各种痛点。

技术架构

css 复制代码
HTML内容 → Playwright无头浏览器 → PDF生成 → 打印服务

关键技术实现

1. Playwright浏览器启动

javascript 复制代码
const { chromium } = require('playwright');

class GeneratePdf {
    constructor() {
        this.browser = null;
        this.context = null;
        this.page = null;
    }

    async _getLaunchOptions() {
        return {
            headless: true,
            ignoreHTTPSErrors: true,
            args: [
                '--no-sandbox',
                '--disable-setuid-sandbox',
                '--disable-dev-shm-usage',
                '--disable-accelerated-2d-canvas',
                '--no-first-run',
                '--no-zygote',
                '-disable-web-security',
                "--disable-audio-output"
            ],
        };
    }

    async _launchBrowser() {
        const launchOptions = this._getLaunchOptions();
        this.browser = await chromium.launch(launchOptions);
        this.context = await this.browser.newContext();
        this.page = await this.context.newPage();
    }
}

2. HTML内容渲染

javascript 复制代码
async _generatePdfByChrome(url, args = {}, extraOptions = {}) {
    try {
        await this._launchBrowser();
        
        // 设置页面内容
        await this.page.setContent(htmlContent, {
            timeout: extraOptions.requestTimeout || 15000,
            waitUntil: 'networkidle'
        });

        // 等待页面完全加载
        await this.page.waitForLoadState('networkidle');
        
        // 生成PDF
        const pdfBuffer = await this.page.pdf({
            ...args,
            format: 'A4',
            printBackground: true,
            margin: {
                top: '20px',
                bottom: '20px',
                left: '20px',
                right: '20px'
            }
        });

        return pdfBuffer;
    } finally {
        await this.cleanup();
    }
}

3. 高级PDF配置

javascript 复制代码
const pdfOptions = {
    // 纸张格式
    format: 'A4', // A0, A1, A2, A3, A4, A5, A6, Letter, Legal等
    
    // 自定义尺寸
    width: '210mm',
    height: '297mm',
    
    // 页边距
    margin: {
        top: '20px',
        bottom: '20px',
        left: '20px',
        right: '20px'
    },
    
    // 打印背景
    printBackground: true,
    
    // 横向/纵向
    landscape: false,
    
    // 页面范围
    pageRanges: '1-5, 7, 9-12',
    
    // 缩放
    scale: 1.0,
    
    // 首选项
    preferCSSPageSize: false
};

4. 网络请求处理

javascript 复制代码
// 设置请求头
await this.page.setExtraHTTPHeaders({
    'Authorization': 'Bearer token123',
    'User-Agent': 'Custom Agent'
});

// 设置Cookie
await this.context.addCookies([
    {
        name: 'sessionId',
        value: 'abc123',
        domain: '.example.com',
        path: '/'
    }
]);

// 设置本地存储
await this.page.evaluate(() => {
    localStorage.setItem('theme', 'dark');
    localStorage.setItem('language', 'zh-CN');
});

5. 错误处理与重试机制

javascript 复制代码
async generatePdfWithRetry(htmlContent, options, maxRetries = 3) {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
            return await this._generatePdfByChrome(htmlContent, options);
        } catch (error) {
            console.error(`PDF生成失败 (尝试 ${attempt}/${maxRetries}):`, error);
            
            if (attempt === maxRetries) {
                throw new Error(`PDF生成失败,已重试${maxRetries}次: ${error.message}`);
            }
            
            // 等待后重试
            await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
        }
    }
}

性能优化策略

1. 浏览器实例复用

javascript 复制代码
class BrowserManager {
    constructor() {
        this.browser = null;
        this.contextPool = [];
        this.maxPoolSize = 5;
    }

    async getBrowser() {
        if (!this.browser) {
            this.browser = await chromium.launch(this._getLaunchOptions());
        }
        return this.browser;
    }

    async getContext() {
        if (this.contextPool.length > 0) {
            return this.contextPool.pop();
        }
        
        const browser = await this.getBrowser();
        const context = await browser.newContext();
        return context;
    }

    async releaseContext(context) {
        if (this.contextPool.length < this.maxPoolSize) {
            this.contextPool.push(context);
        } else {
            await context.close();
        }
    }
}

2. 内存管理

javascript 复制代码
async cleanup() {
    if (this.page) {
        await this.page.close();
        this.page = null;
    }
    
    if (this.context) {
        await this.context.close();
        this.context = null;
    }
    
    if (this.browser) {
        await this.browser.close();
        this.browser = null;
    }
}

3. 并发控制

javascript 复制代码
class TaskQueue {
    constructor(maxConcurrent = 3) {
        this.maxConcurrent = maxConcurrent;
        this.running = 0;
        this.queue = [];
    }

    async addTask(task) {
        return new Promise((resolve, reject) => {
            this.queue.push({ task, resolve, reject });
            this.processQueue();
        });
    }

    async processQueue() {
        if (this.running >= this.maxConcurrent || this.queue.length === 0) {
            return;
        }

        this.running++;
        const { task, resolve, reject } = this.queue.shift();

        try {
            const result = await task();
            resolve(result);
        } catch (error) {
            reject(error);
        } finally {
            this.running--;
            this.processQueue();
        }
    }
}

实际应用场景

1. 企业报表生成

javascript 复制代码
async generateFinancialReport(data) {
    const htmlContent = `
        <!DOCTYPE html>
        <html>
        <head>
            <style>
                body { font-family: Arial, sans-serif; }
                table { width: 100%; border-collapse: collapse; }
                th, td { border: 1px solid #ddd; padding: 8px; }
                .header { text-align: center; margin-bottom: 20px; }
            </style>
        </head>
        <body>
            <div class="header">
                <h1>财务报表</h1>
                <p>生成时间: ${new Date().toLocaleString()}</p>
            </div>
            <table>
                <thead>
                    <tr>
                        <th>项目</th>
                        <th>金额</th>
                        <th>占比</th>
                    </tr>
                </thead>
                <tbody>
                    ${data.map(item => `
                        <tr>
                            <td>${item.name}</td>
                            <td>${item.amount}</td>
                            <td>${item.percentage}%</td>
                        </tr>
                    `).join('')}
                </tbody>
            </table>
        </body>
        </html>
    `;

    const pdfBuffer = await this.generatePdf(htmlContent, {
        format: 'A4',
        printBackground: true,
        margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' }
    });

    return pdfBuffer;
}

2. 电商订单打印

javascript 复制代码
async generateOrderInvoice(orderData) {
    const htmlContent = `
        <!DOCTYPE html>
        <html>
        <head>
            <style>
                .invoice { max-width: 800px; margin: 0 auto; }
                .header { text-align: center; border-bottom: 2px solid #333; padding: 20px 0; }
                .items { margin: 20px 0; }
                .total { text-align: right; font-weight: bold; }
            </style>
        </head>
        <body>
            <div class="invoice">
                <div class="header">
                    <h1>订单发票</h1>
                    <p>订单号: ${orderData.orderId}</p>
                    <p>日期: ${orderData.date}</p>
                </div>
                <div class="items">
                    ${orderData.items.map(item => `
                        <div style="display: flex; justify-content: space-between; margin: 10px 0;">
                            <span>${item.name}</span>
                            <span>¥${item.price}</span>
                        </div>
                    `).join('')}
                </div>
                <div class="total">
                    <p>总计: ¥${orderData.total}</p>
                </div>
            </div>
        </body>
        </html>
    `;

    return await this.generatePdf(htmlContent, {
        format: 'A5',
        printBackground: true
    });
}

技术优势

1. 完美样式还原

  • 支持所有现代CSS特性
  • 完美处理复杂布局
  • 保持字体和颜色一致性

2. 高性能处理

  • 无头浏览器,资源占用低
  • 并发处理能力
  • 内存优化管理

3. 灵活配置

  • 丰富的PDF生成选项
  • 支持自定义纸张尺寸
  • 精确的页边距控制

4. 稳定可靠

  • 完善的错误处理
  • 自动重试机制
  • 资源自动清理

总结

通过使用Playwright无头浏览器,我们成功解决了Web打印生成PDF的各种技术难题。这种方案具有以下优势:

  1. 技术先进性:基于最新的浏览器引擎
  2. 功能完整性:支持复杂的HTML渲染和PDF生成
  3. 性能优异:无头模式,资源占用低
  4. 稳定可靠:完善的错误处理和资源管理

这种技术方案为Web应用提供了高质量的PDF生成能力,特别适合需要精确控制打印效果的场景,如企业报表、电商订单、文档系统等。


Playwright无头浏览器为Web打印提供了完美的技术解决方案!

技术方案延伸

基于上述Playwright的技术实现,市面上已经有一些成熟的解决方案。比如web-print-pdf这个npm包,它将Playwright的PDF生成能力与WebSocket通信相结合,为开发者提供了一个完整的Web打印解决方案。

该方案的特点:

  • 基于Playwright实现高质量的PDF生成
  • 支持多种输入格式(HTML、URL、Base64、图片)
  • 提供丰富的PDF和打印配置选项
  • 支持批量处理和预览功能
  • 简单易用的API接口

对于有Web打印需求的开发者来说,这种基于Playwright的技术方案值得考虑。

复制代码
相关推荐
AlienZHOU7 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc10 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师12 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen12 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒12 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow13 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81813 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007813 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明13 小时前
第 6 章 组件进阶
前端·vue.js
江华森13 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端