使用无头浏览器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的技术方案值得考虑。

复制代码
相关推荐
Silver〄line几秒前
以鼠标位置为中心进行滚动缩放
前端
LaiYoung_2 分钟前
深入解析 single-spa 微前端框架核心原理
前端·javascript·面试
Danny_FD1 小时前
Vue2 + Node.js 快速实现带心跳检测与自动重连的 WebSocket 案例
前端
uhakadotcom1 小时前
将next.js的分享到twitter.com之中时,如何更新分享卡片上的图片?
前端·javascript·面试
韦小勇1 小时前
el-table 父子数据层级嵌套表格
前端
奔赴_向往1 小时前
为什么 PWA 至今没能「掘进」主流?
前端
小小愿望1 小时前
微信小程序开发实战:图片转 Base64 全解析
前端·微信小程序
掘金安东尼1 小时前
2分钟创建一个“不依赖任何外部库”的粒子动画背景
前端·面试·canvas
电商API大数据接口开发Cris1 小时前
基于 Flink 的淘宝实时数据管道设计:商品详情流式处理与异构存储
前端·数据挖掘·api
小小愿望1 小时前
解锁前端新技能:让JavaScript与CSS变量共舞
前端·javascript·css