跳到主要内容
前端 PDF 导出实战:JSPDF 与 HTML2Canvas 深度解析 | 极客日志
JavaScript 大前端
前端 PDF 导出实战:JSPDF 与 HTML2Canvas 深度解析 综述由AI生成 深入解析前端 PDF 导出的核心技术,重点介绍 JSPDF 和 HTML2Canvas 两个库。涵盖 JSPDF 的初始化、文本处理、中文支持、图片及表格生成等 API 详解,以及 HTML2Canvas 的渲染原理与配置参数。通过实战案例展示了基础集成方案、智能分页保护样式、响应式适配等高级功能,帮助开发者在浏览器端实现高质量的网页内容 PDF 导出。
涅槃凤凰 发布于 2026/4/5 更新于 2026/5/23 33 浏览前言:为什么需要前端 PDF 导出?
在现代 Web 开发中,将网页内容导出为 PDF 是一项常见但颇具挑战的需求。无论是生成报告、发票、合同还是数据可视化图表,前端 PDF 导出都能为用户提供便捷的离线查看和打印体验。传统的 PDF 生成通常需要在后端完成,但随着前端技术的发展,现在我们可以直接在浏览器中实现高质量的 PDF 导出功能。
本文将深入探讨两个核心工具——JSPDF 和 HTML2Canvas,通过详细的原理分析、实战案例和最佳实践,帮助您掌握前端 PDF 导出的核心技术。
第一部分:工具介绍与技术选型
1.1 JSPDF:轻量级 PDF 生成库
JSPDF 是一个纯 JavaScript 实现的 PDF 生成库,它不依赖任何服务器端组件,完全在客户端运行。这个库最初发布于 2010 年,经过多年的发展,已经成为前端 PDF 生成的事实标准。
主要特性:
纯客户端运行 :不需要服务器支持
轻量级 :压缩后仅约 60KB
丰富的 API :支持文本、图片、形状、字体等
多语言支持 :包括中文在内的多种语言
插件系统 :可通过插件扩展功能
基本使用:
const doc = new jsPDF ();
doc.text ('Hello World!' , 10 , 10 );
doc.save ('document.pdf' );
1.2 HTML2Canvas:网页截图神器
HTML2Canvas 是一个强大的 JavaScript 库,它可以将 HTML 元素渲染为 Canvas。虽然名字中包含"canvas",但它实际上是通过模拟浏览器渲染引擎来实现 HTML 到 Canvas 的转换。
工作原理:
解析目标元素的 CSS 样式
克隆 DOM 节点并应用样式
使用 Canvas 2D API 绘制每个节点
处理图片、渐变、阴影等复杂样式
技术特点:
基于 Canvas :输出为标准 Canvas 元素
样式支持 :支持大部分 CSS 属性
跨域处理 :可以配置跨域图片加载
异步处理 :使用 Promise API
1.3 为什么选择这两个库?
组合优势 :
JSPDF 擅长 PDF 操作 :创建、编辑、保存 PDF 文档
HTML2Canvas 擅长网页渲染 :准确捕获网页视觉状态
完美互补 :HTML2Canvas 生成图片,JSPDF 将图片转为 PDF
场景 推荐方案 理由 简单文本 PDF 纯 JSPDF 轻量、快速、代码简洁 表格报表 JSPDF + 表格插件 结构化数据友好 复杂网页截图 HTML2Canvas + JSPDF 保留视觉样式 大量数据导出 后端生成 性能更好
第二部分:JSPDF 深度解析
2.1 核心 API 详解
2.1.1 初始化与页面设置
const doc = new jsPDF ({ orientation : 'p' ,
unit : 'mm' ,
format : 'a4' ,
compress : true ,
precision : 16
const pageWidth = doc.internal .pageSize .getWidth ();
const pageHeight = doc.internal .pageSize .getHeight ();
doc.addPage ();
doc.deletePage (2 );
doc.setFillColor (240 , 240 , 240 );
doc.rect (0 , 0 , pageWidth, pageHeight, 'F' );
2.1.2 文本处理
doc.setFont ("helvetica" );
doc.setFontSize (16 );
doc.setTextColor (0 , 0 , 0 );
doc.setFontStyle ("bold" );
doc.text ("单行文本" , x, y);
doc.text ("多行文本" , x, y, { maxWidth : 100 ,
align : 'left' ,
baseline : 'top'
const lines = doc.splitTextToSize (
"这是一个很长的文本,需要自动换行显示" ,
pageWidth - 40
);
doc.text (lines, 20 , 30 );
const text = "第一行\n第二行\n第三行" ;
doc.text (text, 20 , 50 , { lineHeightFactor : 1.5 });
doc.textWithRotation ("旋转文本" , 100 , 100 , 45 );
2.1.3 中文支持
const font = 'AAEAAAAQAQAABAAAR0RFRgE...' ;
doc.addFileToVFS ('chinese-normal.ttf' , font);
doc.addFont ('chinese-normal.ttf' , 'chinese' , 'normal' );
doc.setFont ('chinese' );
doc.text ('中文字体测试' , 20 , 20 );
doc.setFont ('simhei' );
doc.setFontSize (16 );
doc.text ('使用黑体显示中文' , 20 , 40 );
2.1.4 图片处理
const imgData = 'data:image/png;base64,iVBORw0KGgo...' ;
doc.addImage (imgData, 'PNG' , 15 , 40 , 180 , 160 );
doc.addImage ({ imageData : imgData,
x : 15 ,
y : 40 ,
width : 180 ,
height : 160 ,
compression : 'FAST' ,
rotation : 0 ,
alias : 'myImage' ,
format : 'PNG'
const imgProps = doc.getImageProperties (imgData);
console .log (`图片尺寸:${imgProps.width} x${imgProps.height} ` );
const scaleToFit = (imgWidth, imgHeight, maxWidth, maxHeight ) => {
const widthRatio = maxWidth / imgWidth;
const heightRatio = maxHeight / imgHeight;
const ratio = Math .min (widthRatio, heightRatio);
return { width : imgWidth * ratio, height : imgHeight * ratio };
};
2.1.5 图形绘制
doc.setLineWidth (0.5 );
doc.setDrawColor (0 , 0 , 255 );
doc.line (20 , 20 , 100 , 20 );
doc.setFillColor (255 , 0 , 0 );
doc.rect (20 , 30 , 50 , 30 , 'F' );
doc.rect (80 , 30 , 50 , 30 , 'S' );
doc.rect (140 , 30 , 50 , 30 , 'FD' );
doc.circle (60 , 80 , 20 , 'FD' );
doc.ellipse (120 , 80 , 30 , 20 , 'FD' );
const triangle = [[100 , 120 ], [120 , 100 ], [140 , 120 ]];
doc.setFillColor (0 , 255 , 0 );
doc.poly (triangle, 'F' );
doc.setDrawColor (128 , 0 , 128 );
doc.setLineWidth (2 );
doc.path ('M 160 100 L 180 120 L 160 140 Z' );
doc.setLineDashPattern ([5 , 5 ], 0 );
doc.line (20 , 150 , 200 , 150 );
doc.setLineDashPattern ([], 0 );
2.1.6 表格生成
import jsPDF from 'jspdf' ;
import 'jspdf-autotable' ;
const doc = new jsPDF ();
doc.autoTable ({ head : [['ID' , '姓名' , '年龄' , '城市' ]], body : [
['1' , '张三' , '28' , '北京' ],
['2' , '李四' , '32' , '上海' ],
['3' , '王五' , '25' , '广州' ]
], startY : 20 ,
theme : 'grid' ,
styles : { fontSize : 10 , cellPadding : 3 , overflow : 'linebreak' },
headStyles : { fillColor : [22 , 160 , 133 ], textColor : 255 , fontStyle : 'bold' },
columnStyles : {
0 : { cellWidth : 20 },
1 : { cellWidth : 40 }
},
margin : { top : 20 },
didDrawPage : function (data ) {
doc.setFontSize (10 );
doc.text (`第 ${data.pageNumber} 页` , data.settings .margin .left , doc.internal .pageSize .height - 10 );
}
});
const largeData = [];
for (let i = 0 ; i < 100 ; i++) {
largeData.push ([i + 1 , `用户${i} ` , Math .floor (Math .random () * 50 + 18 ), '城市' ]);
}
doc.autoTable ({ head : [['ID' , '姓名' , '年龄' , '城市' ]], body : largeData,
startY : 20 ,
pageBreak : 'auto' ,
rowPageBreak : 'avoid' ,
showHead : 'everyPage'
2.2 高级功能
2.2.1 页码和页眉页脚 function addHeaderFooter (doc, totalPages ) {
const pageWidth = doc.internal .pageSize .getWidth ();
const pageHeight = doc.internal .pageSize .getHeight ();
doc.setFontSize (10 );
doc.setTextColor (100 , 100 , 100 );
doc.text ('公司名称' , 20 , 15 );
doc.text (new Date ().toLocaleDateString (), pageWidth - 20 , 15 , { align : 'right' });
doc.setFontSize (9 );
doc.text (`第 ${doc.internal.getCurrentPageInfo().pageNumber} 页 / 共 ${totalPages} 页` , pageWidth / 2 , pageHeight - 10 , { align : 'center' });
doc.setDrawColor (200 , 200 , 200 );
doc.line (20 , pageHeight - 15 , pageWidth - 20 , pageHeight - 15 );
}
const totalPages = 5 ;
for (let i = 1 ; i <= totalPages; i++) {
if (i > 1 ) doc.addPage ();
addHeaderFooter (doc, totalPages);
}
2.2.2 水印功能 function addWatermark (doc, text ) {
const pageWidth = doc.internal .pageSize .getWidth ();
const pageHeight = doc.internal .pageSize .getHeight ();
doc.saveGraphicsState ();
doc.setFontSize (40 );
doc.setTextColor (200 , 200 , 200 , 0.3 );
doc.setFont ('helvetica' , 'bold' );
const textWidth = doc.getStringUnitWidth (text) * doc.internal .getFontSize () / doc.internal .scaleFactor ;
const angle = -45 * Math .PI / 180 ;
const spacing = 150 ;
doc.saveGraphicsState ();
for (let x = -pageWidth; x < pageWidth * 2 ; x += spacing) {
for (let y = -pageHeight; y < pageHeight * 2 ; y += spacing) {
doc.saveGraphicsState ();
doc.translate (x, y);
doc.rotate (angle, { origin : [0 , 0 ] });
doc.text (text, 0 , 0 );
doc.restoreGraphicsState ();
}
}
doc.restoreGraphicsState ();
}
const doc = new jsPDF ();
doc.text ('文档内容' , 20 , 20 );
addWatermark (doc, '机密文件' );
2.2.3 链接和书签
doc.textWithLink ('点击跳转到第 2 页' , 20 , 20 , { pageNumber : 2 });
doc.textWithLink ('访问官网' , 20 , 40 , { url : 'https://example.com' });
doc.textWithLink ('发送邮件' , 20 , 60 , { url : 'mailto:[email protected] ' });
doc.outline .add (null , "封面" , 1 );
doc.outline .add (null , "第一章" , 2 );
doc.outline .add (1 , "1.1 简介" , 3 );
const tocY = 30 ;
const chapters = [
{ title : "第一章 简介" , page : 1 },
{ title : "第二章 基础" , page : 3 },
{ title : "第三章 高级" , page : 5 }
];
chapters.forEach ((chapter, i ) => {
doc.text (chapter.title , 20 , tocY + i * 10 );
doc.textWithLink (`第${chapter.page} 页` , 150 , tocY + i * 10 , { pageNumber : chapter.page });
});
第三部分:HTML2Canvas 深度解析
3.1 核心原理与架构 HTML2Canvas 的工作原理相当复杂,它本质上是一个简化的浏览器渲染引擎 。让我们深入了解它的工作流程:
3.1.2 核心组件
class HTML2CanvasRenderer {
constructor (element, options ) {
this .element = element;
this .options = options;
this .canvas = document .createElement ('canvas' );
this .ctx = this .canvas .getContext ('2d' );
}
async render ( ) {
const root = this .parseDOM (this .element );
this .calculateStyles (root);
const renderTree = this .createRenderTree (root);
this .calculateLayout (renderTree);
await this .draw (renderTree);
return this .canvas ;
}
}
3.2 配置参数详解 const options = {
allowTaint : false ,
useCORS : true ,
backgroundColor : '#ffffff' ,
scale : 2 ,
width : null ,
height : null ,
logging : true ,
onclone : null ,
imageTimeout : 15000 ,
proxy : null ,
removeContainer : true ,
foreignObjectRendering : false ,
ignoreElements : (element ) => false ,
onrendered : null ,
async : true ,
cacheBust : false ,
letterRendering : false ,
canvas : null ,
x : 0 ,
y : 0 ,
scrollX : 0 ,
scrollY : 0 ,
windowWidth : window .innerWidth ,
windowHeight : window .innerHeight
};
第四部分:JSPDF 与 HTML2Canvas 集成实战
4.1 基础集成方案
async function exportToPDF (elementId, fileName = 'document.pdf' , options = {} ) {
const defaultOptions = {
pdf : { orientation : 'p' , unit : 'mm' , format : 'a4' , compress : true },
html2canvas : { scale : 2 , useCORS : true , logging : false },
margin : { top : 15 , right : 15 , bottom : 15 , left : 15 }
};
const config = {
pdf : { ...defaultOptions.pdf , ...options.pdf },
html2canvas : { ...defaultOptions.html2canvas , ...options.html2canvas },
margin : { ...defaultOptions.margin , ...options.margin }
};
try {
const element = document .getElementById (elementId);
if (!element) {
throw new Error (`元素 #${elementId} 未找到` );
}
console .log ('开始生成 canvas...' );
const canvas = await html2canvas (element, config.html2canvas );
const imgData = canvas.toDataURL ('image/png' , 1.0 );
const imgProps = { width : canvas.width , height : canvas.height };
const pdfWidth = config.pdf .format === 'a4' ? 210 : 297 ;
const pdfHeight = config.pdf .format === 'a4' ? 297 : 210 ;
const usableWidth = pdfWidth - config.margin .left - config.margin .right ;
const pixelsPerMM = imgProps.width / usableWidth;
const scale = usableWidth / imgProps.width ;
const imgHeightMM = imgProps.height * scale;
console .log ('创建 PDF 文档...' );
const doc = new jsPDF (config.pdf );
const pageHeight = pdfHeight - config.margin .top - config.margin .bottom ;
let position = 0 ;
if (imgHeightMM <= pageHeight) {
doc.addImage (imgData, 'PNG' , config.margin .left , config.margin .top , usableWidth, imgHeightMM);
} else {
while (position < imgHeightMM) {
if (position > 0 ) {
doc.addPage ();
}
doc.addImage (imgData, 'PNG' , config.margin .left , config.margin .top - position, usableWidth, imgHeightMM);
position += pageHeight;
}
}
doc.save (fileName);
console .log ('PDF 导出完成' );
return doc;
} catch (error) {
console .error ('导出 PDF 失败:' , error);
throw error;
}
}
document .getElementById ('export-btn' ).addEventListener ('click' , async () => {
try {
await exportToPDF ('content' , '报告.pdf' , {
pdf : { format : 'a4' , orientation : 'portrait' },
html2canvas : { scale : 3 ,
backgroundColor : '#ffffff'
},
margin : { top : 20 , right : 20 , bottom : 20 , left : 20 }
});
} catch (error) {
alert ('导出失败:' + error.message );
}
});
4.2 高级集成:智能分页与表格保护 class SmartPDFExporter {
constructor (options = {} ) {
this .options = this .mergeOptions (options);
this .pageBreaks = [];
}
mergeOptions (options ) {
const defaults = {
elementId : null ,
fileName : 'export.pdf' ,
pdf : { orientation : 'p' , unit : 'mm' , format : 'a4' },
html2canvas : { scale : 2 , logging : false , useCORS : true },
styling : { tableProtection : true , keepTogetherClass : 'keep-together' , avoidBreakClass : 'avoid-break' },
margins : { top : 15 , right : 15 , bottom : 15 , left : 15 },
features : { pageNumbers : true , header : true , footer : true , watermark : false }
};
return this .deepMerge (defaults, options);
}
deepMerge (target, source ) {
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array .isArray (source[key])) {
if (!target[key]) target[key] = {};
this .deepMerge (target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
async export ( ) {
try {
this .validateInput ();
if (this .options .styling .tableProtection ) {
this .applyProtectionStyles ();
}
const analysis = await this .analyzeDOM ();
this .calculatePageBreaks (analysis);
const canvas = await this .generateCanvas ();
const pdf = await this .generatePDF (canvas, analysis);
pdf.save (this .options .fileName );
return { success : true , pdf };
} catch (error) {
console .error ('导出失败:' , error);
throw error;
}
}
validateInput ( ) {
if (!this .options .elementId ) {
throw new Error ('elementId 必须提供' );
}
const element = document .getElementById (this .options .elementId );
if (!element) {
throw new Error (`元素 #${this .options.elementId} 未找到` );
}
this .element = element;
}
applyProtectionStyles ( ) {
const style = document .createElement ('style' );
style.textContent = `
.${this .options.styling.keepTogetherClass} { page-break-inside: avoid !important; break-inside: avoid !important; }
.${this .options.styling.avoidBreakClass} { page-break-before: avoid !important; break-before: avoid !important; }
table { page-break-inside: avoid !important; break-inside: avoid !important; border-collapse: collapse !important; }
tr { page-break-inside: avoid !important; break-inside: avoid !important; }
` ;
const oldStyles = this .element .querySelectorAll ('style[data-pdf-protection]' );
oldStyles.forEach (s => s.remove ());
style.setAttribute ('data-pdf-protection' , 'true' );
this .element .appendChild (style);
const tables = this .element .querySelectorAll ('table' );
tables.forEach (table => {
table.classList .add (this .options .styling .keepTogetherClass );
});
const importantElements = this .element .querySelectorAll ('h1, h2, .important' );
importantElements.forEach (el => {
el.classList .add (this .options .styling .avoidBreakClass );
});
}
async analyzeDOM ( ) {
const tables = this .element .querySelectorAll ('table' );
const importantElements = this .element .querySelectorAll (`.${this .options.styling.keepTogetherClass} ` );
const elementRect = this .element .getBoundingClientRect ();
const analysis = {
tables : [],
importantElements : [],
containerHeight : this .element .offsetHeight ,
containerWidth : this .element .offsetWidth
};
tables.forEach ((table, index ) => {
const rect = table.getBoundingClientRect ();
analysis.tables .push ({
index,
element : table,
top : rect.top - elementRect.top ,
bottom : rect.bottom - elementRect.top ,
height : rect.height ,
rowCount : table.querySelectorAll ('tr' ).length ,
canBreak : !table.classList .contains (this .options .styling .keepTogetherClass )
});
});
importantElements.forEach ((el, index ) => {
if (el.tagName !== 'TABLE' ) {
const rect = el.getBoundingClientRect ();
analysis.importantElements .push ({
index,
element : el,
top : rect.top - elementRect.top ,
bottom : rect.bottom - elementRect.top ,
height : rect.height ,
tagName : el.tagName
});
}
});
analysis.tables .sort ((a, b ) => a.top - b.top );
analysis.importantElements .sort ((a, b ) => a.top - b.top );
return analysis;
}
calculatePageBreaks (analysis ) {
const pdf = new jsPDF (this .options .pdf );
const pdfWidth = pdf.internal .pageSize .getWidth ();
const pdfHeight = pdf.internal .pageSize .getHeight ();
const usableWidth = pdfWidth - this .options .margins .left - this .options .margins .right ;
const usableHeight = pdfHeight - this .options .margins .top - this .options .margins .bottom ;
const pxToMM = usableWidth / analysis.containerWidth ;
const breaks = [];
let currentPageHeightMM = 0 ;
const allElements = [...analysis.tables , ...analysis.importantElements ].sort ((a, b ) => a.top - b.top );
allElements.forEach ((element, index ) => {
const elementHeightMM = element.height * pxToMM;
if (currentPageHeightMM + elementHeightMM > usableHeight && currentPageHeightMM > 0 ) {
if (index > 0 ) {
const prevElement = allElements[index - 1 ];
breaks.push ({ type : 'before' , element : element, position : element.top , reason : `${element.tagName || 'table' } 无法放入当前页` });
currentPageHeightMM = elementHeightMM;
}
} else {
currentPageHeightMM += elementHeightMM;
}
});
this .pageBreaks = breaks;
console .log ('计算出的分页点:' , this .pageBreaks );
}
async generateCanvas ( ) {
const originalStyle = {
width : this .element .style .width ,
height : this .element .style .height ,
position : this .element .style .position
};
this .element .style .width = `${this .element.offsetWidth} px` ;
this .element .style .height = `${this .element.offsetHeight} px` ;
this .element .style .position = 'relative' ;
try {
const canvas = await html2canvas (this .element , this .options .html2canvas );
return canvas;
} finally {
Object .assign (this .element .style , originalStyle);
}
}
async generatePDF (canvas, analysis ) {
const pdf = new jsPDF (this .options .pdf );
const pdfWidth = pdf.internal .pageSize .getWidth ();
const pdfHeight = pdf.internal .pageSize .getHeight ();
const usableWidth = pdfWidth - this .options .margins .left - this .options .margins .right ;
const usableHeight = pdfHeight - this .options .margins .top - this .options .margins .bottom ;
const scale = usableWidth / canvas.width ;
const totalHeightMM = canvas.height * scale;
if (this .pageBreaks .length > 0 ) {
await this .generatePDFWithBreaks (pdf, canvas, scale, usableWidth, usableHeight);
} else {
await this .generatePDFSimple (pdf, canvas, scale, usableWidth, usableHeight);
}
if (this .options .features .header || this .options .features .footer ) {
this .addHeaderFooter (pdf, this .pageBreaks .length + 1 );
}
if (this .options .features .watermark ) {
this .addWatermark (pdf);
}
return pdf;
}
async generatePDFWithBreaks (pdf, canvas, scale, usableWidth, usableHeight ) {
const imgData = canvas.toDataURL ('image/png' , 1.0 );
const breakPoints = this .pageBreaks .map (breakInfo => breakInfo.position );
breakPoints.sort ((a, b ) => a - b);
const allPoints = [0 , ...breakPoints, canvas.height ];
let startY = 0 ;
for (let i = 1 ; i < allPoints.length ; i++) {
const segmentHeight = allPoints[i] - startY;
if (segmentHeight <= 0 ) continue ;
const segmentCanvas = document .createElement ('canvas' );
segmentCanvas.width = canvas.width ;
segmentCanvas.height = segmentHeight;
const ctx = segmentCanvas.getContext ('2d' );
ctx.drawImage (
canvas,
0 , startY, canvas.width , segmentHeight,
0 , 0 , canvas.width , segmentHeight
);
const segmentImgData = segmentCanvas.toDataURL ('image/png' , 1.0 );
const segmentHeightMM = segmentHeight * scale;
if (i > 1 ) {
pdf.addPage ();
}
pdf.addImage (
segmentImgData, 'PNG' , this .options .margins .left , this .options .margins .top , usableWidth, segmentHeightMM
);
startY = allPoints[i];
}
}
generatePDFSimple (pdf, canvas, scale, usableWidth, usableHeight ) {
const imgData = canvas.toDataURL ('image/png' , 1.0 );
const totalHeightMM = canvas.height * scale;
if (totalHeightMM <= usableHeight) {
pdf.addImage (
imgData, 'PNG' , this .options .margins .left , this .options .margins .top , usableWidth, totalHeightMM
);
} else {
let position = 0 ;
while (position < totalHeightMM) {
if (position > 0 ) {
pdf.addPage ();
}
pdf.addImage (
imgData, 'PNG' , this .options .margins .left , this .options .margins .top - position, usableWidth, totalHeightMM
);
position += usableHeight;
}
}
}
addHeaderFooter (pdf, totalPages ) {
const pdfWidth = pdf.internal .pageSize .getWidth ();
const pdfHeight = pdf.internal .pageSize .getHeight ();
const currentPage = pdf.internal .getCurrentPageInfo ().pageNumber ;
const originalFont = pdf.internal .getFont ();
const originalSize = pdf.internal .getFontSize ();
const originalColor = pdf.internal .getTextColor ();
if (this .options .features .header ) {
pdf.setFontSize (10 );
pdf.setTextColor (100 , 100 , 100 );
pdf.text (
this .options .fileName .replace ('.pdf' , '' ),
this .options .margins .left ,
this .options .margins .top - 10
);
const dateStr = new Date ().toLocaleDateString ();
pdf.text (
dateStr,
pdfWidth - this .options .margins .right ,
this .options .margins .top - 10 ,
{ align : 'right' }
);
pdf.setDrawColor (200 , 200 , 200 );
pdf.line (
this .options .margins .left ,
this .options .margins .top - 5 ,
pdfWidth - this .options .margins .right ,
this .options .margins .top - 5
);
}
if (this .options .features .footer ) {
pdf.setDrawColor (200 , 200 , 200 );
pdf.line (
this .options .margins .left ,
pdfHeight - this .options .margins .bottom + 5 ,
pdfWidth - this .options .margins .right ,
pdfHeight - this .options .margins .bottom + 5
);
if (this .options .features .pageNumbers ) {
pdf.setFontSize (9 );
pdf.setTextColor (100 , 100 , 100 );
const pageText = `第 ${currentPage} 页 / 共 ${totalPages} 页` ;
pdf.text (
pageText,
pdfWidth / 2 ,
pdfHeight - this .options .margins .bottom + 10 ,
{ align : 'center' }
);
}
}
pdf.setFont (originalFont[0 ], originalFont[1 ]);
pdf.setFontSize (originalSize);
pdf.setTextColor (originalColor[0 ], originalColor[1 ], originalColor[2 ]);
}
addWatermark (pdf ) {
const pdfWidth = pdf.internal .pageSize .getWidth ();
const pdfHeight = pdf.internal .pageSize .getHeight ();
pdf.saveGraphicsState ();
pdf.setFontSize (40 );
pdf.setTextColor (200 , 200 , 200 , 0.1 );
pdf.setFont ('helvetica' , 'bold' );
const angle = -45 * Math .PI / 180 ;
const text = this .options .features .watermark .text || 'CONFIDENTIAL' ;
for (let x = -pdfWidth; x < pdfWidth * 2 ; x += 150 ) {
for (let y = -pdfHeight; y < pdfHeight * 2 ; y += 150 ) {
pdf.saveGraphicsState ();
pdf.translate (x, y);
pdf.rotate (angle, { origin : [0 , 0 ] });
pdf.text (text, 0 , 0 );
pdf.restoreGraphicsState ();
}
}
pdf.restoreGraphicsState ();
}
}
const exporter = new SmartPDFExporter ({
elementId : 'report-content' ,
fileName : '智能报告.pdf' ,
pdf : { format : 'a4' , orientation : 'portrait' },
html2canvas : { scale : 2 , useCORS : true , backgroundColor : '#ffffff' },
features : {
pageNumbers : true ,
header : true ,
footer : true ,
watermark : { text : '公司机密' , enabled : true }
},
styling : { tableProtection : true , keepTogetherClass : 'pdf-keep-together' }
});
document .getElementById ('smart-export' ).addEventListener ('click' , async () => {
try {
await exporter.export ();
alert ('导出成功!' );
} catch (error) {
alert ('导出失败:' + error.message );
}
});
4.3 响应式与移动端适配 class ResponsivePDFExporter {
constructor (options = {} ) {
this .options = {
breakpoints : {
mobile : 768 ,
tablet : 1024 ,
desktop : 1200
},
scaling : {
mobile : 1 ,
tablet : 1.5 ,
desktop : 2
},
...options
};
}
detectDeviceType ( ) {
const width = window .innerWidth ;
if (width < this .options .breakpoints .mobile ) {
return 'mobile' ;
} else if (width < this .options .breakpoints .tablet ) {
return 'tablet' ;
} else {
return 'desktop' ;
}
}
async exportResponsive (elementId, fileName ) {
const deviceType = this .detectDeviceType ();
const scale = this .options .scaling [deviceType] || 1.5 ;
console .log (`设备类型:${deviceType} , 使用缩放:${scale} ` );
const element = document .getElementById (elementId);
const originalStyles = this .backupStyles (element);
try {
this .applyResponsiveStyles (element, deviceType);
const canvas = await html2canvas (element, {
scale,
useCORS : true ,
backgroundColor : '#ffffff'
});
const pdf = new jsPDF ({ orientation : 'p' , unit : 'mm' , format : 'a4' });
const pdfWidth = pdf.internal .pageSize .getWidth ();
const imgWidth = canvas.width ;
const imgHeight = canvas.height ;
const margin = 10 ;
const usableWidth = pdfWidth - 2 * margin;
const scaleFactor = usableWidth / imgWidth;
pdf.addImage (
canvas.toDataURL ('image/png' , 1.0 ),
'PNG' ,
margin,
margin,
usableWidth,
imgHeight * scaleFactor
);
pdf.save (fileName);
} finally {
this .restoreStyles (element, originalStyles);
}
}
backupStyles (element ) {
return {
width : element.style .width ,
height : element.style .height ,
fontSize : element.style .fontSize ,
padding : element.style .padding ,
margin : element.style .margin ,
display : element.style .display
};
}
applyResponsiveStyles (element, deviceType ) {
const styles = {
mobile : { width : '100%' , fontSize : '12px' , padding : '10px' , margin : '0' , display : 'block' },
tablet : { width : '90%' , fontSize : '13px' , padding : '15px' , margin : '0 auto' , display : 'block' },
desktop : { width : '80%' , fontSize : '14px' , padding : '20px' , margin : '0 auto' , display : 'block' }
};
const deviceStyles = styles[deviceType] || styles.desktop ;
Object .assign (element.style , deviceStyles);
const tables = element.querySelectorAll ('table' );
tables.forEach (table => {
if (deviceType === 'mobile' ) {
table.style .fontSize = '10px' ;
table.style .width = '100%' ;
}
});
}
restoreStyles (element, originalStyles ) {
Object .assign (element.style , originalStyles);
}
}
const responsiveExporter = new ResponsivePDFExporter ();
document .getElementById ('responsive-export' ).addEventListener ('click' , () => {
responsiveExporter.exportResponsive ('content' , '响应式报告.pdf' );
});
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online