随着古茗的日益成长壮大,有了越来越多的伙伴加入。这其中不乏各方专业人人员、投资商、加盟商、供应商等等。这其中,为了保障双方合法权利,必然少不了合同的签订。而如果能够将合同生成 PDF 并预览的工作在线上完成,无疑将能够减少不少沟通和时间成本。
一般合同的展示信息,包含页眉,页脚以及合同本身的内容(比如各项条款及法规等)。合同会有多页,也就避免不了要对合同生成后的电子文档(如PDF文件等)内容进行分页处理。
今天我们就来聊聊如何生成将文档内容生成 PDF 文档并进行分页处理。
对于文档内容生成 PDF 的场景,我们大致可以分为两大步:
以下,我们先从如何生成 PDF 开始着手。
目前主流的方案有两种:
本文采用的是第二种方案,原因如下:
综上所述,使用html2canvas + jspdf的方案生成PDF文件具有更高的灵活性、更好的兼容性、更易于处理复杂布局、更好的跨浏览器支持以及更优的性能和用户体验。这些优势使得该方案成为前端生成 PDF 文件的较好选择。
其代码实现也较为简单,其主要代码实现如下:
声明 ref 方便拿到元素内容。
const contentRef = useRef(null);
...
...
<div ref={contentRef} className="pdf-reviewer"> // PDF HTML 内容区块
...
</div>
将元素放入到 html2canvas 中,得到 canvas 数据。
import html2canvas from 'html2canvas';
const canvas = await html2canvas(element, { // element 即 contentRef.current
allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 2, // 增加清晰度window.devicePixelRatio * 2
useCORS: true, // 允许跨域
windowHeight: element.scrollHeight,
});
// 获取canvas转化后的宽度
const canvasWidth = canvas.width;
// 获取canvas转化后的高度
const canvasHeight = canvas.height;
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvasWidth, canvasHeight);
通过 jspdf 提供的 api 进行绘制
const pdf = new jsPDF({
unit: 'pt',
format: 'a4',
orientation: 'p',
});
pdf.addImage(canvas, 'JPEG', x, y, width, height);
打开并预览 PDF 内容
const pdfBlob = pdf.output('blob');
const pdfUrl = URL.createObjectURL(pdfBlob);
window.open(pdfUrl);
至此,我们完成了最简单的一步,将 PDF 成功生成了出来。但是在某些特定场景,竟然出现了内容截断。如下图:
从图中可以看到,在两页 PDF 的接缝处,文字内容被截断了。这确实是难以被接受的。
为什么会出现截断呢?
原因就在于,html2canvas 帮我们生成了一整个 Canvas 图像,当我们将一整个图像放入 PDF 中时,PDF 会根据每个页面的高度直接将内容分割。
所以,我们需要想个办法,在 Canvas 图像放入 PDF 之前,将内容元素避开页面分割的地方(下称分页点)。
综上,本文将继续介绍第二种方案。
在了解如何分页之前,我们需要知道 PDF 的生成流程以知晓高度和位置的计算逻辑。
纵观整体流程,将 HTML 内容转变成 PDF 的文件内容,其实本身是非常简单的,通过简单的 API 调用就可以完成。难点在于,如何将每页的内容合理分配且不产生截断。我们需要分为多种场景进行分别处理。
:::info
如以上生成流程中所说,我们可以通过每页内容的实际高度以及总高度,计算得出分页点。
当然,这只是最理想的情况:我们的文档内容恰巧都没有处在页面被分割的位置。
但是,当分割处有内容时该怎么办呢?
:::
此时,我们就需要一些特殊的处理:
普通元素只需要考虑到是否到达了分页点,如果当前元素距离当前页顶部的高度加上元素自身的高度大于 PDF 一页内容的高度(页面高度), 则证明当前元素跨页,将当前元素顶部作为分页点位置。
因为表格本身受到不同三方 UI 库的影响,表格行可能会有不同的 ClassName。
比如 antd 的表格行的 ClassName 就是 "ant-table-row"。
当我们遇到相应的 ClassName,则不再进行向下遍历。其实,其判断分页点条件与普通元素类似,只不过普通元素是以最小元素单位作来进行判断,表格是以表格行作为最小元素来进行判断,不再向下进行子元素遍历。
文字相较普通元素,则更深一层。复杂点在于,被截断的文字可能是多行文字的文本元素。
if (one.nodeType === 3) {
const { offsetHeight } = one.parentNode;
const offsetTop = getBaseElementTop(one.parentNode);
const top = Math.max(0, rate * offsetTop);
const lineHeightString = window.getComputedStyle(one.parentNode).lineHeight;
const lineHeightMatch = lineHeightString.match(/\d+(\.\d+)?/);
const lineHeightValue = lineHeightMatch ? parseFloat(lineHeightMatch[0]) : 0;
const lineHeight = lineHeightValue * rate;
const elementHeight = rate * offsetHeight;
const previousPoint = pages.length > 0 ? pages[pages.length - 1] : 0;
...
if (top + elementHeight - previousPoint > originalPageHeight) {
const currentRemainHeight = previousPoint + originalPageHeight - top; // 当前元素顶部距离当前 PDF 页底部的高度
const remainder = currentRemainHeight % lineHeight;
pages.push(previousPoint + originalPageHeight - remainder);
}
}
还有一些场景,我们希望直接另起一页。这种情况,可以直接指定一个 ClassName,放到想要分页位置的元素上面。当遍历时遇到此 ClassName,则直接将此元素顶部位置作为分页点位置。
除以上提到的内容之外,我们可能还会遇到其他比较棘手的问题。
在导出 PDF 并进行预览时,当 PDF 内容过大,可能会导致页面无法加载。此时,我们需要进行一层转换。
const pdfBlob = obj.getPDF().output('blob');
const pdfUrl = URL.createObjectURL(pdfBlob);
window.open(pdfUrl);
const blob = dataURLtoBlob(obj.getPDF().output('datauristring'));
const pdfUrl = URL.createObjectURL(blob);
window.open(pdfUrl);
// 当 base64 过大时会导致页面无法加载,需要转化成 blob 格式
const dataURLtoBlob = (dataurl: any) => {
const arr = dataurl.split(',');
// 注意base64的最后面中括号和引号是不转译的
const _arr = arr[1].substring(0, arr[1].length - 2);
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(_arr);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], {
type: mime,
});
};
在生成 PDF 时,可能会出现大量空白页的情况,这可能是触碰到了浏览器的限制。
在浏览器中,Canvas是存在尺寸限制的,主要原因是浏览器为了防止内存溢出和性能问题。不同浏览器对Canvas的最大尺寸有不同的限制:
对于这个问题,我们可以考虑将整个大的 Canvas 进行切割,再分段渲染到 PDF 文档上。
具体分割逻辑代码如下:
async function toCanvasAll(element, width) {
// canvas元素
const canvas = await html2canvas(element, {
allowTaint: true, // 允许渲染跨域图片
scale: window.devicePixelRatio * 2, // 增加清晰度
useCORS: true, // 允许跨域
windowHeight: element.scrollHeight,
});
// 获取canvas转化后的宽度
const canvasWidth = canvas.width;
// 获取canvas转化后的高度
const canvasHeight = canvas.height;
// 高度转化为PDF的高度
const rate = width / canvasWidth;
const height = rate * canvasHeight;
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvasWidth, canvasHeight);
if (canvasData === 'data:,') {
const canvasDataArr = await toCanvasSplit(element, rate);
return { totalHeight: height, data: canvasDataArr.sort((a, b) => a.index - b.index) };
}
return {
totalHeight: height,
data: [{ width, height, index: 0, data: canvasData, start: 0, end: height }],
};
}
async function toCanvasSplit(element, rate, parts = 2) {
const yOffsets = distributeEvenlySimple(element.scrollHeight, parts);
let res;
try {
const arr = [];
for (let index = 0; index < yOffsets.length; index++) {
const previous = yOffsets[index - 1] || 0;
const canvas = await html2canvas(element, {
allowTaint: true,
scale: window.devicePixelRatio * 2,
useCORS: true,
// windowHeight: element.scrollHeight,
y: previous,
height: yOffsets[index] - previous,
});
const width = rate * canvas.width;
const height = rate * canvas.height;
// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0);
if (canvasData === 'data:,') {
throw new Error('canvasData is empty');
}
const context = canvas.getContext('2d');
context.clearRect(0, 0, canvas.width, canvas.height);
const start = arr[index - 1]?.end || 0;
arr.push({
width,
height,
index,
data: canvasData,
start,
end: start + height,
});
res = arr;
}
} catch (e) {
console.warn('error', e);
res = await toCanvasSplit(element, rate, parts + 1);
}
return res;
}
分段渲染时,后续分段的数据要考虑拿到到第一个数据渲染后的结束位置,进行拼接。
在开发过程中,还有两个问题稍微难解,等待后续完善。大家也可以讨论更好的解决方案。
以上,就是我对 HTML 生成 PDF 的探索。当然,在当下大家日益增长的业务中,我们可能会遇到更为复杂的场景,可能也会需要更为完善的方案来应对。大家可以多多发表真知灼见,共同学习,一起进步!