一、概述
html2pdf.js 是一个纯前端的 JavaScript 库,用于将 HTML 元素转换为可下载的 PDF 文件。它基于两个底层库:
-
html2canvas:将 DOM 元素渲染为 Canvas 图像12。
-
jsPDF :将图像或文本生成 PDF 文件26。
特点:完全在浏览器端运行,无需服务器支持,适合生成报告、发票等场景17。
二、工作原理
转换过程分为两步:
-
HTML → Canvas:
- 使用
html2canvas捕获目标 DOM 的快照,生成 Canvas 图像(本质是位图)26。
- 使用
-
Canvas → PDF:
- 将 Canvas 图像插入
jsPDF实例,按配置(如页面尺寸、方向)生成 PDF28。
- 将 Canvas 图像插入
图表
代码
下载
HTML元素
html2canvas生成Canvas图像
jsPDF创建PDF文档
保存或下载PDF文件
三、基本用法
1. 安装
bash
复制
下载
npm install html2pdf.js # 或通过 CDN 引入:cite[3]:cite[7]
2. 核心代码
html
复制
下载
运行
<button οnclick="generatePDF()">导出 PDF</button>
<div id="element-to-print">
<h1>标题</h1>
<p>待转换内容</p>
</div>
<script>
function generatePDF() {
const element = document.getElementById('element-to-print');
html2pdf()
.from(element)
.save('document.pdf'); // 保存文件:cite[1]:cite[8]
}
</script>
四、配置选项
通过链式调用自定义输出效果:
| 配置分类 | 常用参数 | 作用 |
|---|---|---|
| 全局设置 | margin |
PDF 页边距(单位:mm/pt/in)37 |
filename |
输出文件名(默认:file.pdf) |
|
| 图像质量 | image: { type: 'jpeg', quality: 0.98 } |
图片格式与质量(0-1)3 |
| html2canvas | scale: 2 |
渲染缩放倍数(提高清晰度)17 |
| jsPDF | orientation: 'portrait' |
页面方向(portrait/landscape) |
format: 'a4' |
纸张尺寸(如 a4, letter)38 |
示例配置:
javascript
复制
下载
html2pdf().set({
margin: [15, 10], // 上下15mm,左右10mm
filename: 'report.pdf',
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, dpi: 192 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
}).from(element).save();
五、高级技巧
-
处理长内容分页
-
问题:Canvas 有最大尺寸限制(如高度超 16384 像素会截断)16。
-
方案:
-
手动拆分内容为多个区块,分次渲染1。
-
使用
pagebreak参数自动分页:javascript
复制
下载
pagebreak: { mode: 'avoid-all', before: '.page-break' } // 避免元素截断,在指定类前分页:cite[7]
-
-
-
解决渲染不一致问题
-
原因:依赖浏览器渲染引擎,不同平台效果可能差异。
-
优化:
-
使用 Web Safe Fonts(如 Arial, Helvetica)2。
-
避免复杂 CSS 属性(如
position: fixed)3。
-
-
-
在 Vue/React 中的集成
-
通过
ref获取 DOM 元素,避免直接操作 DOM78:vue
复制
下载
<template> <div ref="pdfContent">...</div> <button @click="exportPDF">导出</button> </template> <script> import html2pdf from 'html2pdf.js'; export default { methods: { exportPDF() { html2pdf().from(this.$refs.pdfContent).save(); } } }; </script>
-
六、优缺点分析
| 优点 | 缺点 |
|---|---|
| ✅ 纯前端实现,无需后端支持1 | ❌ 复杂页面渲染耗时长(低端设备卡顿)2 |
| ✅ 配置灵活,支持自定义样式 | ❌ 文本为图像形式,无法复制/搜索23 |
| ✅ 开源免费,社区活跃 | ❌ 超大内容需手动分页(Canvas 尺寸限制)6 |
七、适用场景
-
生成电子发票或订单凭证2。
-
导出数据报表(如图表、表格)7。
-
博客/文章离线保存(一键下载为 PDF)28。
八、替代方案对比
| 库名 | 特点 | 适用场景 |
|---|---|---|
| jsPDF + html2canvas | 基础组合,需手动处理分页 | 简单内容导出 |
| Print.js | 直接调用浏览器打印,不支持复杂样式3 | 快速打印功能 |
| jsPDF-AutoTable | 仅支持表格导出3 | 纯表格数据报表 |
九、最佳实践建议
-
样式优化:
-
使用
@media print定义打印专用样式3。 -
避免背景图/渐变,防止渲染异常。
-
-
性能提升:
-
隐藏非必要元素(如按钮、广告)再生成 PDF。
-
分块渲染超长内容(循环调用
html2pdf)1。
-
-
兼容性处理:
-
在 Safari 中测试字体嵌入效果。
-
备用方案:服务端生成(如 Puppeteer)5。
-
💡 关键提示 :若需高质量可搜索文本 PDF,建议改用服务端方案(如
wkhtmltopdf或Puppeteer)56。html2pdf.js的核心价值在于客户端快速生成,适用于对文本可编辑性要求不高的场景。
通过合理配置与问题规避,html2pdf.js 仍是轻量级 PDF 导出需求的优选工具。完整示例参考 官方 GitHub。
vue示例
html
<template>
<div class="pdf-exporter-container">
<div class="header">
<h1><i class="fas fa-file-pdf"></i> Vue3 PDF导出工具</h1>
<p>使用html2pdf.js库将HTML内容转换为PDF文件</p>
</div>
<div class="content-container">
<!-- 编辑器区域 -->
<div class="editor-section">
<div class="section-header">
<h2><i class="fas fa-edit"></i> HTML内容</h2>
<div class="controls">
<button class="btn" @click="loadSample('report')">
<i class="fas fa-file-alt"></i> 示例报告
</button>
<button class="btn" @click="loadSample('invoice')">
<i class="fas fa-file-invoice"></i> 示例发票
</button>
<button class="btn" @click="resetContent">
<i class="fas fa-trash-alt"></i> 清空
</button>
</div>
</div>
<textarea
v-model="htmlContent"
class="html-editor"
placeholder="在此输入HTML内容..."
></textarea>
</div>
<!-- 预览区域 -->
<div class="preview-section">
<div class="section-header">
<h2><i class="fas fa-eye"></i> 预览</h2>
<div class="controls">
<button class="btn primary" @click="updatePreview">
<i class="fas fa-sync-alt"></i> 刷新预览
</button>
</div>
</div>
<div class="preview-container">
<div class="preview-content" v-html="previewContent"></div>
</div>
</div>
<!-- 配置区域 -->
<div class="config-section">
<div class="section-header">
<h2><i class="fas fa-cog"></i> PDF设置</h2>
</div>
<div class="config-grid">
<div class="config-group">
<h3><i class="fas fa-file"></i> 页面设置</h3>
<div class="form-group">
<label>页面方向:</label>
<select v-model="pdfOptions.orientation">
<option value="portrait">纵向</option>
<option value="landscape">横向</option>
</select>
</div>
<div class="form-group">
<label>页面格式:</label>
<select v-model="pdfOptions.format">
<option value="a4">A4</option>
<option value="letter">Letter</option>
<option value="a3">A3</option>
<option value="a5">A5</option>
</select>
</div>
<div class="form-group">
<label>页边距 (mm):</label>
<input type="number" v-model.number="pdfOptions.margin" min="0" max="50">
</div>
</div>
<div class="config-group">
<h3><i class="fas fa-image"></i> 渲染设置</h3>
<div class="form-group">
<label>图像质量:</label>
<select v-model="pdfOptions.imageQuality">
<option :value="0.8">高 (80%)</option>
<option :value="0.9">非常高 (90%)</option>
<option :value="1.0">最高 (100%)</option>
</select>
</div>
<div class="form-group">
<label>缩放比例:</label>
<select v-model="pdfOptions.scale">
<option :value="1">1x</option>
<option :value="2">2x (推荐)</option>
<option :value="3">3x</option>
</select>
</div>
<div class="form-group">
<label>文件名:</label>
<input type="text" v-model="pdfOptions.filename">
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="actions">
<button class="btn primary large" @click="exportPDF">
<i class="fas fa-download"></i> 导出PDF
</button>
<button class="btn secondary large" @click="openInNewWindow">
<i class="fas fa-external-link-alt"></i> 新窗口预览
</button>
</div>
<!-- 状态提示 -->
<div class="status" :class="status.type" v-if="status.show">
<i class="icon" :class="status.icon"></i>
<span>{{ status.message }}</span>
</div>
<div class="footer">
<p>使用 <strong>html2pdf.js</strong> 和 <strong>Vue3</strong> 构建 | 基于TypeScript实现</p>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, reactive, onMounted } from 'vue';
import html2pdf from 'html2pdf.js';
interface PdfOptions {
orientation: 'portrait' | 'landscape';
format: string;
margin: number;
imageQuality: number;
scale: number;
filename: string;
}
interface Status {
show: boolean;
type: 'success' | 'error' | 'loading';
message: string;
icon: string;
}
export default defineComponent({
name: 'PdfExporter',
setup() {
// HTML内容编辑器
const htmlContent = ref<string>('');
// 预览内容
const previewContent = ref<string>('');
// PDF配置选项
const pdfOptions = reactive<PdfOptions>({
orientation: 'portrait',
format: 'a4',
margin: 15,
imageQuality: 0.9,
scale: 2,
filename: 'document.pdf'
});
// 状态管理
const status = reactive<Status>({
show: false,
type: 'success',
message: '',
icon: 'fas fa-check-circle'
});
// 当前日期
const currentDate = new Date().toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
// 更新预览内容
const updatePreview = () => {
// 替换模板变量
let content = htmlContent.value.replace('{{ currentDate }}', currentDate);
previewContent.value = content;
};
// 导出PDF
const exportPDF = async () => {
showStatus('loading', '正在生成PDF...', 'fas fa-spinner fa-spin');
try {
const element = document.querySelector('.preview-content') as HTMLElement;
const opt = {
margin: pdfOptions.margin,
filename: pdfOptions.filename,
image: { type: 'jpeg', quality: pdfOptions.imageQuality },
html2canvas: { scale: pdfOptions.scale },
jsPDF: {
unit: 'mm',
format: pdfOptions.format,
orientation: pdfOptions.orientation
}
};
await html2pdf().set(opt).from(element).save();
showStatus('success', 'PDF已成功生成并下载!', 'fas fa-check-circle');
} catch (error) {
console.error('导出PDF失败:', error);
showStatus('error', `导出失败: ${(error as Error).message}`, 'fas fa-exclamation-circle');
}
};
// 在新窗口打开PDF
const openInNewWindow = async () => {
showStatus('loading', '正在生成预览...', 'fas fa-spinner fa-spin');
try {
const element = document.querySelector('.preview-content') as HTMLElement;
const opt = {
margin: pdfOptions.margin,
image: { type: 'jpeg', quality: pdfOptions.imageQuality },
html2canvas: { scale: pdfOptions.scale },
jsPDF: {
unit: 'mm',
format: pdfOptions.format,
orientation: pdfOptions.orientation
}
};
const blob = await html2pdf().set(opt).from(element).outputPdf('blob');
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
showStatus('success', 'PDF已在新窗口打开', 'fas fa-check-circle');
} catch (error) {
console.error('预览PDF失败:', error);
showStatus('error', `预览失败: ${(error as Error).message}`, 'fas fa-exclamation-circle');
}
};
// 加载示例内容
const loadSample = (type: 'report' | 'invoice') => {
if (type === 'report') {
htmlContent.value = `
<div>
<h1>项目进度报告 - 2023年10月</h1>
<p>报告日期: {{ currentDate }}</p>
<h3>项目概览</h3>
<p>项目目前进展顺利,整体完成度为 <strong>75%</strong>,预计将在11月底前完成。</p>
<h3>任务完成情况</h3>
<table>
<thead>
<tr>
<th>任务名称</th>
<th>负责人</th>
<th>状态</th>
<th>完成日期</th>
</tr>
</thead>
<tbody>
<tr>
<td>用户界面设计</td>
<td>张设计师</td>
<td>已完成</td>
<td>2023-09-15</td>
</tr>
<tr>
<td>后端API开发</td>
<td>李工程师</td>
<td>进行中 (90%)</td>
<td>2023-10-20</td>
</tr>
<tr>
<td>数据库优化</td>
<td>王工程师</td>
<td>进行中 (70%)</td>
<td>2023-10-25</td>
</tr>
<tr>
<td>用户测试</td>
<td>测试团队</td>
<td>未开始</td>
<td>2023-11-05</td>
</tr>
</tbody>
</table>
<h3>下月重点</h3>
<ul>
<li>完成所有核心功能开发</li>
<li>启动第一阶段用户测试</li>
<li>准备项目交付文档</li>
</ul>
<div style="margin-top: 30px; text-align: center; color: #7f8c8d; font-size: 0.9rem;">
<p>项目负责人: 王经理 | 下次报告日期: 2023-11-05</p>
</div>
</div>
`.trim();
} else if (type === 'invoice') {
htmlContent.value = `
<div style="max-width: 800px; margin: 0 auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
<div>
<h1 style="color: #2c3e50;">发票</h1>
<p>发票号: INV-2023-00142</p>
<p>日期: {{ currentDate }}</p>
</div>
<div style="text-align: right;">
<h2>科技有限公司</h2>
<p>北京市海淀区科技园88号</p>
<p>电话: (010) 1234-5678</p>
</div>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 30px; background: #f9f9f9; padding: 20px; border-radius: 8px;">
<div>
<h3>账单至:</h3>
<p>客户公司名称</p>
<p>联系人: 张先生</p>
<p>上海市浦东新区金融街100号</p>
</div>
<div style="text-align: right;">
<h3>付款信息</h3>
<p>金额: <strong style="font-size: 1.2rem;">¥18,750.00</strong></p>
<p>付款方式: 银行转账</p>
<p>付款期限: 30天</p>
</div>
</div>
<h3>收费项目</h3>
<table>
<thead>
<tr>
<th>项目描述</th>
<th>数量</th>
<th>单价</th>
<th>总价</th>
</tr>
</thead>
<tbody>
<tr>
<td>企业版软件许可 (年)</td>
<td>5</td>
<td>¥2,500.00</td>
<td>¥12,500.00</td>
</tr>
<tr>
<td>技术咨询服务</td>
<td>40小时</td>
<td>¥500.00</td>
<td>¥20,000.00</td>
</tr>
<tr>
<td>折扣</td>
<td>-</td>
<td>-</td>
<td>-¥13,750.00</td>
</tr>
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f1f2f6;">
<td colspan="3" style="text-align: right;">总计:</td>
<td>¥18,750.00</td>
</tr>
</tfoot>
</table>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; text-align: center; color: #7f8c8d;">
<p>感谢您的惠顾!如有任何问题,请随时联系我们的财务部门。</p>
<p>tech@company.com | (010) 1234-5678</p>
</div>
</div>
`.trim();
}
updatePreview();
showStatus('success', '示例已加载', 'fas fa-check-circle');
};
// 重置内容
const resetContent = () => {
htmlContent.value = '';
previewContent.value = '';
showStatus('success', '内容已清空', 'fas fa-check-circle');
};
// 显示状态消息
const showStatus = (type: 'success' | 'error' | 'loading', message: string, icon: string) => {
status.type = type;
status.message = message;
status.icon = icon;
status.show = true;
// 自动隐藏消息
setTimeout(() => {
status.show = false;
}, type === 'loading' ? 5000 : 3000);
};
// 初始化组件
onMounted(() => {
// 加载默认示例
loadSample('report');
});
return {
htmlContent,
previewContent,
pdfOptions,
status,
updatePreview,
exportPDF,
openInNewWindow,
loadSample,
resetContent
};
}
});
</script>
<style scoped>
.pdf-exporter-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #2c3e50;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
min-height: 100vh;
}
.header {
text-align: center;
padding: 30px 0;
margin-bottom: 20px;
}
.header h1 {
font-size: 2.5rem;
font-weight: 700;
color: #2c3e50;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.header p {
font-size: 1.1rem;
color: #6c757d;
max-width: 700px;
margin: 0 auto;
line-height: 1.6;
}
.content-container {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
gap: 25px;
margin-bottom: 30px;
}
.editor-section {
grid-column: 1;
grid-row: 1;
background: white;
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.preview-section {
grid-column: 2;
grid-row: 1 / span 2;
background: white;
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
display: flex;
flex-direction: column;
}
.config-section {
grid-column: 1;
grid-row: 2;
background: white;
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.section-header {
background: linear-gradient(to right, #3498db, #2c3e50);
color: white;
padding: 18px 25px;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-header h2 {
font-size: 1.4rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.controls {
display: flex;
gap: 10px;
}
.html-editor {
width: 100%;
min-height: 300px;
padding: 20px;
font-family: 'Courier New', Courier, monospace;
font-size: 0.95rem;
line-height: 1.5;
border: none;
resize: vertical;
background: #f8f9fa;
border-radius: 0 0 12px 12px;
}
.html-editor:focus {
outline: none;
background: #fff;
}
.preview-container {
flex: 1;
padding: 20px;
overflow: auto;
background: #f8f9fa;
border-radius: 0 0 12px 12px;
}
.preview-content {
background: white;
padding: 25px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
min-height: 100%;
}
.preview-content h1, .preview-content h2, .preview-content h3 {
color: #2c3e50;
margin-top: 0;
}
.preview-content table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.preview-content th {
background-color: #3498db;
color: white;
text-align: left;
padding: 12px 15px;
}
.preview-content td {
padding: 10px 15px;
border-bottom: 1px solid #e0e0e0;
}
.preview-content tr:nth-child(even) {
background-color: #f8f9fa;
}
.config-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 25px;
padding: 25px;
}
.config-group {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
}
.config-group h3 {
font-size: 1.2rem;
margin-top: 0;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #3498db;
display: flex;
align-items: center;
gap: 10px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #495057;
}
.form-group select, .form-group input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 6px;
background: white;
font-size: 1rem;
}
.actions {
display: flex;
justify-content: center;
gap: 20px;
margin: 30px 0;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 10px;
background: #e9ecef;
color: #495057;
}
.btn.primary {
background: linear-gradient(to right, #3498db, #2980b9);
color: white;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
}
.btn.primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(52, 152, 219, 0.4);
}
.btn.secondary {
background: linear-gradient(to right, #6c757d, #495057);
color: white;
}
.btn.secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.3);
}
.btn.large {
padding: 15px 30px;
font-size: 1.1rem;
}
.status {
padding: 15px 25px;
border-radius: 8px;
margin: 20px auto;
max-width: 600px;
display: flex;
align-items: center;
gap: 15px;
font-weight: 500;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
}
.status.success {
background: rgba(46, 204, 113, 0.15);
color: #27ae60;
border-left: 4px solid #27ae60;
}
.status.error {
background: rgba(231, 76, 60, 0.15);
color: #c0392b;
border-left: 4px solid #c0392b;
}
.status.loading {
background: rgba(52, 152, 219, 0.15);
color: #2980b9;
border-left: 4px solid #2980b9;
}
.footer {
text-align: center;
padding: 30px 0 20px;
color: #6c757d;
font-size: 0.95rem;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.content-container {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.editor-section {
grid-column: 1;
grid-row: 1;
}
.preview-section {
grid-column: 1;
grid-row: 2;
min-height: 500px;
}
.config-section {
grid-column: 1;
grid-row: 3;
}
}
@media (max-width: 768px) {
.config-grid {
grid-template-columns: 1fr;
}
.actions {
flex-direction: column;
align-items: center;
}
}
</style>
html示例
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue3 PDF导出工具</title>
<script src="https://unpkg.com/vue@3.2.47/dist/vue.global.js"></script>
<script src="https://unpkg.com/html2pdf.js@0.10.1/dist/html2pdf.bundle.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
body {
background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
min-height: 100vh;
padding: 20px;
color: #2c3e50;
}
#app {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
padding: 40px 0;
}
.header h1 {
font-size: 2.5rem;
font-weight: 700;
color: #2c3e50;
margin-bottom: 10px;
}
.header p {
font-size: 1.1rem;
color: #7f8c8d;
max-width: 700px;
margin: 0 auto;
line-height: 1.6;
}
.container {
display: flex;
gap: 30px;
margin-bottom: 40px;
}
@media (max-width: 900px) {
.container {
flex-direction: column;
}
}
.editor-section, .preview-section {
flex: 1;
background: white;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.section-header {
background: linear-gradient(to right, #3498db, #2c3e50);
color: white;
padding: 18px 25px;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-header h2 {
font-size: 1.3rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.section-content {
padding: 25px;
}
.editor {
width: 100%;
min-height: 300px;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
font-size: 1rem;
line-height: 1.6;
resize: vertical;
transition: border-color 0.3s;
}
.editor:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}
.preview-container {
background: #f9f9f9;
border-radius: 8px;
padding: 20px;
min-height: 350px;
border: 1px solid #eee;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 30px;
padding: 20px;
background: white;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
}
.control-group {
flex: 1;
min-width: 250px;
}
.control-group h3 {
margin-bottom: 15px;
color: #2c3e50;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.control-row {
display: flex;
align-items: center;
margin-bottom: 12px;
}
label {
width: 140px;
font-weight: 500;
color: #555;
}
select, input {
flex: 1;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
}
.buttons {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-top: 10px;
}
button {
padding: 14px 24px;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(to right, #3498db, #2980b9);
color: white;
box-shadow: 0 4px 10px rgba(52, 152, 219, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(52, 152, 219, 0.4);
}
.btn-secondary {
background: #f1f2f6;
color: #2c3e50;
}
.btn-secondary:hover {
background: #e0e3e9;
}
.btn-success {
background: linear-gradient(to right, #2ecc71, #27ae60);
color: white;
box-shadow: 0 4px 10px rgba(46, 204, 113, 0.3);
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(46, 204, 113, 0.4);
}
.btn-icon {
font-size: 1.2rem;
}
.footer {
text-align: center;
padding: 30px 0;
color: #7f8c8d;
font-size: 0.95rem;
}
.footer a {
color: #3498db;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.preview-content {
line-height: 1.7;
}
.preview-content h3 {
color: #2c3e50;
margin: 20px 0 10px;
padding-bottom: 8px;
border-bottom: 2px solid #3498db;
}
.preview-content p {
margin-bottom: 15px;
}
.preview-content ul {
padding-left: 25px;
margin-bottom: 20px;
}
.preview-content li {
margin-bottom: 8px;
}
.table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background: white;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.table th {
background: #3498db;
color: white;
text-align: left;
padding: 12px 15px;
}
.table td {
padding: 10px 15px;
border-bottom: 1px solid #eee;
}
.table tr:nth-child(even) {
background: #f9f9f9;
}
.table tr:hover {
background: #f1f8ff;
}
.highlight {
background: #f1f8ff;
padding: 3px 5px;
border-radius: 4px;
color: #2980b9;
font-weight: 500;
}
.status {
padding: 15px;
border-radius: 8px;
margin-top: 20px;
display: none;
align-items: center;
gap: 10px;
}
.status.visible {
display: flex;
}
.status.success {
background: rgba(46, 204, 113, 0.15);
color: #27ae60;
}
.status.error {
background: rgba(231, 76, 60, 0.15);
color: #c0392b;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid rgba(52, 152, 219, 0.3);
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<h1><i class="fas fa-file-pdf"></i> Vue3 PDF导出工具</h1>
<p>使用html2pdf.js库将HTML内容转换为PDF文件 - 基于Vue3和TypeScript实现</p>
</div>
<div class="container">
<div class="editor-section">
<div class="section-header">
<h2><i class="fas fa-edit"></i> HTML编辑器</h2>
<div class="buttons">
<button class="btn-secondary" @click="loadSample('report')">
<i class="fas fa-file-alt"></i> 示例报告
</button>
<button class="btn-secondary" @click="loadSample('invoice')">
<i class="fas fa-receipt"></i> 示例发票
</button>
</div>
</div>
<div class="section-content">
<textarea class="editor" v-model="htmlContent" placeholder="在此输入HTML内容..."></textarea>
</div>
</div>
<div class="preview-section">
<div class="section-header">
<h2><i class="fas fa-eye"></i> 实时预览</h2>
<div class="buttons">
<button class="btn-primary" @click="updatePreview">
<i class="fas fa-sync-alt"></i> 刷新预览
</button>
</div>
</div>
<div class="section-content">
<div class="preview-container">
<div class="preview-content" v-html="previewContent"></div>
</div>
</div>
</div>
</div>
<div class="controls">
<div class="control-group">
<h3><i class="fas fa-sliders-h"></i> PDF设置</h3>
<div class="control-row">
<label>页面方向:</label>
<select v-model="pdfOptions.orientation">
<option value="portrait">纵向</option>
<option value="landscape">横向</option>
</select>
</div>
<div class="control-row">
<label>页面格式:</label>
<select v-model="pdfOptions.format">
<option value="a4">A4</option>
<option value="letter">Letter</option>
<option value="a3">A3</option>
<option value="a5">A5</option>
</select>
</div>
<div class="control-row">
<label>页边距 (mm):</label>
<input type="number" v-model.number="pdfOptions.margin" min="0" max="50">
</div>
</div>
<div class="control-group">
<h3><i class="fas fa-paint-brush"></i> 渲染设置</h3>
<div class="control-row">
<label>图像质量:</label>
<select v-model="pdfOptions.imageQuality">
<option :value="0.8">高 (80%)</option>
<option :value="0.9">非常高 (90%)</option>
<option :value="1.0">最高 (100%)</option>
</select>
</div>
<div class="control-row">
<label>缩放比例:</label>
<select v-model="pdfOptions.scale">
<option :value="1">1x</option>
<option :value="2">2x (推荐)</option>
<option :value="3">3x</option>
</select>
</div>
<div class="control-row">
<label>文件名:</label>
<input type="text" v-model="pdfOptions.filename">
</div>
</div>
<div class="control-group">
<h3><i class="fas fa-file-export"></i> 导出操作</h3>
<div class="buttons">
<button class="btn-primary" @click="exportPDF">
<i class="fas fa-download"></i> 导出PDF
</button>
<button class="btn-success" @click="openPDF">
<i class="fas fa-external-link-alt"></i> 新窗口预览
</button>
<button class="btn-secondary" @click="reset">
<i class="fas fa-redo"></i> 重置
</button>
</div>
<div class="status" :class="{ visible: status.visible, success: status.type === 'success', error: status.type === 'error' }">
<div v-if="status.loading" class="spinner"></div>
<i v-else-if="status.type === 'success'" class="fas fa-check-circle"></i>
<i v-else-if="status.type === 'error'" class="fas fa-exclamation-circle"></i>
<span>{{ status.message }}</span>
</div>
</div>
</div>
<div class="footer">
<p>使用 <span class="highlight">html2pdf.js</span> 和 <span class="highlight">Vue3</span> 构建 | 本工具完全在浏览器中运行,无需服务器处理</p>
<p>提示:对于复杂布局,建议使用2x缩放比例以获得最佳效果</p>
</div>
</div>
<script>
const { createApp, ref, reactive, onMounted } = Vue;
createApp({
setup() {
// 初始HTML内容
const htmlContent = ref(`
<div>
<h1>销售报告 - 2023年第三季度</h1>
<p>报告生成日期: {{ currentDate }}</p>
<h3>季度概览</h3>
<p>本季度总收入为 <strong>$125,430</strong>,相比上季度增长 <strong>12.5%</strong>。</p>
<h3>产品表现</h3>
<table class="table">
<thead>
<tr>
<th>产品名称</th>
<th>销售量</th>
<th>收入</th>
<th>增长率</th>
</tr>
</thead>
<tbody>
<tr>
<td>高级订阅</td>
<td>1,240</td>
<td>$84,200</td>
<td>+15.2%</td>
</tr>
<tr>
<td>企业套件</td>
<td>480</td>
<td>$32,150</td>
<td>+8.7%</td>
</tr>
<tr>
<td>基础版</td>
<td>3,120</td>
<td>$9,080</td>
<td>+4.3%</td>
</tr>
</tbody>
</table>
<h3>下季度目标</h3>
<ul>
<li>总收入目标: $140,000</li>
<li>新客户增长: 20%</li>
<li>推出新产品线</li>
</ul>
<div style="margin-top: 30px; text-align: center; color: #7f8c8d; font-size: 0.9rem;">
<p>此报告由销售部门生成 - 机密文件</p>
</div>
</div>
`.trim());
const previewContent = ref('');
const currentDate = new Date().toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
// PDF配置选项
const pdfOptions = reactive({
orientation: 'portrait',
format: 'a4',
margin: 15,
imageQuality: 0.9,
scale: 2,
filename: 'document.pdf'
});
// 状态管理
const status = reactive({
visible: false,
type: '',
message: '',
loading: false
});
// 更新预览内容
const updatePreview = () => {
// 替换模板变量
let content = htmlContent.value.replace('{{ currentDate }}', currentDate);
previewContent.value = content;
};
// 导出PDF
const exportPDF = () => {
status.visible = true;
status.loading = true;
status.type = '';
status.message = '正在生成PDF...';
try {
const element = document.querySelector('.preview-content');
const opt = {
margin: pdfOptions.margin,
filename: pdfOptions.filename,
image: { type: 'jpeg', quality: pdfOptions.imageQuality },
html2canvas: { scale: pdfOptions.scale },
jsPDF: {
unit: 'mm',
format: pdfOptions.format,
orientation: pdfOptions.orientation
}
};
html2pdf()
.set(opt)
.from(element)
.save()
.then(() => {
status.loading = false;
status.type = 'success';
status.message = 'PDF已成功生成并下载!';
// 5秒后隐藏状态消息
setTimeout(() => {
status.visible = false;
}, 5000);
});
} catch (error) {
status.loading = false;
status.type = 'error';
status.message = `导出失败: ${error.message}`;
// 8秒后隐藏状态消息
setTimeout(() => {
status.visible = false;
}, 8000);
}
};
// 在新窗口打开PDF
const openPDF = () => {
status.visible = true;
status.loading = true;
status.message = '正在生成预览...';
try {
const element = document.querySelector('.preview-content');
const opt = {
margin: pdfOptions.margin,
image: { type: 'jpeg', quality: pdfOptions.imageQuality },
html2canvas: { scale: pdfOptions.scale },
jsPDF: {
unit: 'mm',
format: pdfOptions.format,
orientation: pdfOptions.orientation
}
};
html2pdf()
.set(opt)
.from(element)
.outputPdf('blob')
.then(blob => {
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
status.loading = false;
status.type = 'success';
status.message = 'PDF已在新窗口打开';
setTimeout(() => {
status.visible = false;
}, 4000);
});
} catch (error) {
status.loading = false;
status.type = 'error';
status.message = `预览失败: ${error.message}`;
setTimeout(() => {
status.visible = false;
}, 8000);
}
};
// 加载示例
const loadSample = (type) => {
if (type === 'report') {
htmlContent.value = `
<div>
<h1>项目进度报告 - 2023年10月</h1>
<p>报告日期: ${currentDate}</p>
<h3>项目概览</h3>
<p>项目目前进展顺利,整体完成度为 <strong>75%</strong>,预计将在11月底前完成。</p>
<h3>任务完成情况</h3>
<table class="table">
<thead>
<tr>
<th>任务名称</th>
<th>负责人</th>
<th>状态</th>
<th>完成日期</th>
</tr>
</thead>
<tbody>
<tr>
<td>用户界面设计</td>
<td>张设计师</td>
<td>已完成</td>
<td>2023-09-15</td>
</tr>
<tr>
<td>后端API开发</td>
<td>李工程师</td>
<td>进行中 (90%)</td>
<td>2023-10-20</td>
</tr>
<tr>
<td>数据库优化</td>
<td>王工程师</td>
<td>进行中 (70%)</td>
<td>2023-10-25</td>
</tr>
<tr>
<td>用户测试</td>
<td>测试团队</td>
<td>未开始</td>
<td>2023-11-05</td>
</tr>
</tbody>
</table>
<h3>下月重点</h3>
<ul>
<li>完成所有核心功能开发</li>
<li>启动第一阶段用户测试</li>
<li>准备项目交付文档</li>
</ul>
<div style="margin-top: 30px; text-align: center; color: #7f8c8d; font-size: 0.9rem;">
<p>项目负责人: 王经理 | 下次报告日期: 2023-11-05</p>
</div>
</div>
`.trim();
} else if (type === 'invoice') {
htmlContent.value = `
<div style="max-width: 800px; margin: 0 auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px;">
<div>
<h1 style="color: #2c3e50;">发票</h1>
<p>发票号: INV-2023-00142</p>
<p>日期: ${currentDate}</p>
</div>
<div style="text-align: right;">
<h2>科技有限公司</h2>
<p>北京市海淀区科技园88号</p>
<p>电话: (010) 1234-5678</p>
</div>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 30px; background: #f9f9f9; padding: 20px; border-radius: 8px;">
<div>
<h3>账单至:</h3>
<p>客户公司名称</p>
<p>联系人: 张先生</p>
<p>上海市浦东新区金融街100号</p>
</div>
<div style="text-align: right;">
<h3>付款信息</h3>
<p>金额: <strong style="font-size: 1.2rem;">¥18,750.00</strong></p>
<p>付款方式: 银行转账</p>
<p>付款期限: 30天</p>
</div>
</div>
<h3>收费项目</h3>
<table class="table">
<thead>
<tr>
<th>项目描述</th>
<th>数量</th>
<th>单价</th>
<th>总价</th>
</tr>
</thead>
<tbody>
<tr>
<td>企业版软件许可 (年)</td>
<td>5</td>
<td>¥2,500.00</td>
<td>¥12,500.00</td>
</tr>
<tr>
<td>技术咨询服务</td>
<td>40小时</td>
<td>¥500.00</td>
<td>¥20,000.00</td>
</tr>
<tr>
<td>折扣</td>
<td>-</td>
<td>-</td>
<td>-¥13,750.00</td>
</tr>
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f1f2f6;">
<td colspan="3" style="text-align: right;">总计:</td>
<td>¥18,750.00</td>
</tr>
</tfoot>
</table>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; text-align: center; color: #7f8c8d;">
<p>感谢您的惠顾!如有任何问题,请随时联系我们的财务部门。</p>
<p>tech@company.com | (010) 1234-5678</p>
</div>
</div>
`.trim();
}
updatePreview();
showStatus('success', '示例已加载');
};
// 重置内容
const reset = () => {
htmlContent.value = '';
previewContent.value = '';
pdfOptions.orientation = 'portrait';
pdfOptions.format = 'a4';
pdfOptions.margin = 15;
pdfOptions.imageQuality = 0.9;
pdfOptions.scale = 2;
pdfOptions.filename = 'document.pdf';
showStatus('success', '已重置所有设置');
};
// 显示状态消息
const showStatus = (type, message) => {
status.visible = true;
status.type = type;
status.message = message;
status.loading = false;
setTimeout(() => {
status.visible = false;
}, type === 'success' ? 4000 : 6000);
};
// 初始化
onMounted(() => {
updatePreview();
});
return {
htmlContent,
previewContent,
pdfOptions,
status,
updatePreview,
exportPDF,
openPDF,
loadSample,
reset
};
}
}).mount('#app');
</script>
</body>
</html>