2835 字
14 分钟
React Hook PDF 生成
背景
在项目开发过程中,我们经常需要生成 PDF 文件。但是,生成 PDF 文件的流程比较复杂,需要处理很多细节问题。
通常情况下如果按照页面原样生成 PDF 文件,只需要使用 html2canvas 和 jsPDF 即可。
代码也和简单,如下:
import html2canvas from 'html2canvas';import jsPDF from 'jspdf';
const Example = () => { const onDownload = () => { html2canvas(document.body).then((canvas) => { const imgData = canvas.toDataURL('image/png'); const pdf = new jsPDF(); pdf.addImage(imgData, 'PNG', 0, 0); pdf.save('example.pdf'); }); };
return <button onClick={onDownload}>Download PDF</button>;};
export default Example;
这只是一份最简单的代码,但是实际项目中,我们还需要处理很多细节问题,如:
- PDF 中的超链接可以点击 (实现 PDF 中的超链接可以点击 - 通过遍历页面中所有 a 标签,并添加蒙层,设置对应的位置,点击蒙层时,跳转到对应的超链接)
- PDF 文件体积较大 (文件体积非常大, > 80 MB - 降低分辨率,压缩图片,压缩文件)
- PDF 与页面样式不一致 (antd 表格中的额外行的 + 按钮样式错位问题,导出的 PDF 中变为了纵向排列的一个横和一个竖 - 隐藏原 ::before/::after 伪元素,用一个内联 SVG 覆盖,导出后再还原)
解决方案
为了简化使用方式并解决上述问题,我封装了一个 hook,用于生成 PDF 文件。
hook 使用方式如下:
import { usePdfGenerator } from '@/src/hooks/usePdfGenerator';import { Button } from 'antd';
const Example = () => { const { contentRef, generatePdf, isLoading } = usePdfGenerator(); const onDownload = () => { generatePdf('example'); // 如果你需要获取到导出的文件,可以传入一个回调函数,或者直接使用 generatePdf 的返回值 const file = generatePdf('example', {}, (file) => { console.log('file', file); }); };
return <div ref={contentRef}> <h1>Hello World</h1> <Button loading={isLoading} onClick={onDownload}>Download PDF</Button> </div>;};
export default Example;
WARNING回调函数和返回值返回的是生成的图片文件,这是由于我们项目有一个导出 word 的需求,需要前端将图片传给后端使用。
hook 源码如下:
import { useRef, useCallback, useState } from 'react';import html2canvas from 'html2canvas';import jsPDF from 'jspdf';
type GeneratePdfOptions = { // 输出图片类型:JPEG 体积更小,PNG 无损但体积更大 imageType?: 'jpeg' | 'png'; // 当 imageType 为 JPEG 时生效,范围 0-1,建议 0.6~0.85 jpegQuality?: number; // 二次缩放比例,0-1。用于在不明显损伤清晰度的前提下降低像素数,建议 0.7~0.9。 downscaleRatio?: number; // 目标最大文件体积(KB),用于自适应压缩 PNG(通过逐步降低分辨率实现,尽量不损伤清晰度) maxFileSizeKB?: number; // 是否下载 isDownload?: boolean; // 回调函数 callback?: (file: File) => void;};
export const usePdfGenerator = () => { // 1. 创建一个 ref 来附加到需要导出的 DOM 节点上 const contentRef = useRef(null); const [isLoading, setIsLoading] = useState(false); // 2. 使用 state 来管理生成过程中的加载状态,提升用户体验 // const [isLoading, setIsLoading] = useState(false);
// 3. 使用 useCallback 来包装我们的核心函数,以避免不必要的重渲染 const generatePdf = useCallback(async ( fileName = 'document', options: GeneratePdfOptions = {}, callback: (file: File) => void = () => { } ): Promise<File | undefined> => { const { imageType = 'png', jpegQuality = 0.82, downscaleRatio = 0.88, isDownload = true } = options; const element: HTMLElement = contentRef.current!;
if (!element) { console.error("无法生成 PDF,因为找不到引用的内容元素。"); return undefined; }
setIsLoading(true); // 开始生成,设置加载状态为 true // 预先准备 a 标签样式恢复器,并在渲染前将计算后的颜色写入内联,确保导出跟页面一致 const anchorsInDom = Array.from(element.querySelectorAll('a')) as HTMLAnchorElement[]; const restoreAnchorInlineStyles: Array<() => void> = []; anchorsInDom.forEach((a) => { const prevColor = a.style.color; const prevTdColor = (a.style as any).textDecorationColor as string; const prevTextDecoration = a.style.textDecoration; const cs = window.getComputedStyle(a); const computedColor = cs.color; const computedTdColor = (cs as any).textDecorationColor || computedColor; a.style.color = computedColor; try { (a.style as any).textDecorationColor = computedTdColor; } catch (_) { } if ((cs.textDecorationLine || '').includes('underline') && !a.style.textDecoration) { a.style.textDecoration = 'underline'; } restoreAnchorInlineStyles.push(() => { a.style.color = prevColor; try { (a.style as any).textDecorationColor = prevTdColor; } catch (_) { } a.style.textDecoration = prevTextDecoration; }); });
// 修复 antd Table 展开/收起图标(+/-)在 html2canvas 下的渲染异常: // 思路:隐藏原 ::before/::after 伪元素,用一个内联 SVG 覆盖,导出后再还原 const cleanupExpandIconFixers: Array<() => void> = []; const styleTag = document.createElement('style'); styleTag.setAttribute('data-pdf-export-fix', 'expand-icon'); styleTag.textContent = ` .ant-table-row-expand-icon.__pdf_export_fix::before, .ant-table-row-expand-icon.__pdf_export_fix::after { content: none !important; background: none !important; border: 0 !important; } `; document.head.appendChild(styleTag); cleanupExpandIconFixers.push(() => { try { document.head.removeChild(styleTag); } catch (_) { } });
const expandButtons = Array.from( element.querySelectorAll('button.ant-table-row-expand-icon') ) as HTMLButtonElement[]; expandButtons.forEach((btn) => { const cs = window.getComputedStyle(btn); const beforeStyle = window.getComputedStyle(btn, '::before'); const afterStyle = window.getComputedStyle(btn, '::after'); const isTransparent = (c: string | null | undefined) => !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)'; const pickColor = (...colors: Array<string | null | undefined>) => colors.find((c) => !isTransparent(c)); const strokeColor = (pickColor( beforeStyle.backgroundColor, (beforeStyle as any).borderColor, afterStyle.backgroundColor, (afterStyle as any).borderColor, cs.backgroundColor, cs.color, 'rgba(0,0,0,0.45)' ) || 'rgba(0,0,0,0.45)') as string; const prevPosition = btn.style.position; const prevClassName = btn.className; btn.style.position = prevPosition || 'relative'; if (!btn.classList.contains('__pdf_export_fix')) btn.classList.add('__pdf_export_fix');
const overlay = document.createElement('span'); overlay.style.position = 'absolute'; overlay.style.inset = '0'; overlay.style.display = 'flex'; overlay.style.alignItems = 'center'; overlay.style.justifyContent = 'center'; overlay.style.pointerEvents = 'none'; overlay.style.zIndex = '1';
const expanded = btn.getAttribute('aria-expanded') === 'true'; // 使用 16x16 视口绘制 + 或 -,线宽 1.5,颜色取自按钮 color const svg = expanded ? `<?xml version="1.0" encoding="UTF-8"?> <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> <line x1="3" y1="8" x2="13" y2="8" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round"/> </svg>` : `<?xml version="1.0" encoding="UTF-8"?> <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> <line x1="3" y1="8" x2="13" y2="8" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round"/> <line x1="8" y1="3" x2="8" y2="13" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round"/> </svg>`; overlay.innerHTML = svg; btn.appendChild(overlay);
cleanupExpandIconFixers.push(() => { try { btn.removeChild(overlay); } catch (_) { } try { btn.className = prevClassName; } catch (_) { } btn.style.position = prevPosition; }); });
try {
// 调用 html2canvas。设置 scale 可以提高清晰度。 const canvasScale = 2; // html2canvas 渲染比例,需要参与后续坐标换算 const canvas = await html2canvas(element, { scale: canvasScale, // 提高分辨率 useCORS: true, // 允许加载跨域图片 logging: false, // 不在控制台打印日志 backgroundColor: '#FFFFFF', });
// 对原始画布做高质量下采样,必要时自适应压缩(针对 PNG 通过降低分辨率实现) const exportCanvas = document.createElement('canvas'); const ctx = exportCanvas.getContext('2d'); if (!ctx) throw new Error('无法获取 2D 上下文');
const originalWidth = canvas.width; const originalHeight = canvas.height; let currentDownscale = Math.min(1, Math.max(0.5, downscaleRatio)); const minDownscale = 0.6; // 限制最小降采样比例,避免明显模糊 const maxFileSizeBytes = Math.max(1, Math.round((options.maxFileSizeKB ?? 320) * 1024));
const redrawWithDownscale = (ratio: number) => { exportCanvas.width = Math.max(1, Math.round(originalWidth * ratio)); exportCanvas.height = Math.max(1, Math.round(originalHeight * ratio)); ctx.imageSmoothingEnabled = true; // @ts-ignore: 浏览器支持性检测 if (ctx.imageSmoothingQuality) ctx.imageSmoothingQuality = 'high'; ctx.clearRect(0, 0, exportCanvas.width, exportCanvas.height); ctx.drawImage( canvas, 0, 0, originalWidth, originalHeight, 0, 0, exportCanvas.width, exportCanvas.height ); };
// 初次绘制 redrawWithDownscale(currentDownscale);
// 针对 PNG,自适应降低分辨率以控制文件体积;JPEG 则交给质量参数处理 const getPngBlob = async (): Promise<Blob> => new Promise((resolve, reject) => { try { exportCanvas.toBlob((blob) => { if (blob) resolve(blob); else reject(new Error('无法导出 PNG Blob')); }, 'image/png'); } catch (e) { reject(e); } });
let tentativePngBlob: Blob | undefined; for (let attempt = 0; attempt < 6; attempt += 1) { // 生成 PNG Blob 并检测体积 // eslint-disable-next-line no-await-in-loop const blob = await getPngBlob(); tentativePngBlob = blob; if (blob.size <= maxFileSizeBytes || currentDownscale <= minDownscale) { break; } // 进一步轻微缩小 8-12% 之间,尽量减少清晰度损失 currentDownscale = Math.max(minDownscale, Math.round(currentDownscale * 0.9 * 100) / 100); redrawWithDownscale(currentDownscale); }
const mime = imageType === 'png' ? 'image/png' : 'image/jpeg'; const quality = imageType === 'png' ? undefined : Math.min(1, Math.max(0.5, jpegQuality)); const imgData = exportCanvas.toDataURL(mime, quality as any);
// 创建 jsPDF 实例 (A4 尺寸) const pdf = new jsPDF({ orientation: 'portrait', // p: 纵向, l: 横向 unit: 'mm', format: 'a4', }); // console.log(canvas);
// 计算 PDF 页面尺寸和图片尺寸 const pdfWidth = pdf.internal.pageSize.getWidth(); const pdfHeight = pdf.internal.pageSize.getHeight(); const imgWidth = exportCanvas.width; const imgHeight = exportCanvas.height;
// 设置边距:左右 36px,上下 20px(转换为毫米) const marginLeft = 36 * 0.264583; // 转换像素为毫米 (1px = 0.264583mm) const marginTop = 20 * 0.264583;
// 计算考虑边距后的可用空间 const availableWidth = pdfWidth - (marginLeft * 2); const availableHeight = pdfHeight - (marginTop * 2);
// 重新计算比例以适应边距 const ratioWithMargins = Math.min(availableWidth / imgWidth, availableHeight / imgHeight); const finalWidth = imgWidth * ratioWithMargins; const finalHeight = imgHeight * ratioWithMargins;
// 计算图片在 PDF 页面中的位置(考虑边距) const x = marginLeft; const y = marginTop;
// 将图片添加到 PDF const addImageType = imageType === 'png' ? 'PNG' : 'JPEG'; pdf.addImage(imgData, addImageType as any, x, y, finalWidth, finalHeight);
// 在 PDF 上叠加可点击链接区域 // 思路: // 1) 遍历 element 内所有 <a> 标签 // 2) 使用 getClientRects() 获取每个可见行的矩形(处理换行) // 3) 将 DOM CSS 像素 -> 画布像素(乘以 canvasScale)-> PDF 毫米(乘以 ratioWithMargins) // 4) 叠加到 (x, y) 的偏移位置 const elementRect = element.getBoundingClientRect(); // 使用 html2canvas 实际产出的画布尺寸与元素 CSS 尺寸,构建精准的 CSSpx->Canvaspx 比例 const elementCssWidth = Math.max(1, element.scrollWidth || elementRect.width); const elementCssHeight = Math.max(1, element.scrollHeight || elementRect.height); const cssToCanvasScaleX = imgWidth / elementCssWidth; const cssToCanvasScaleY = imgHeight / elementCssHeight;
// 由最终绘制尺寸得到 Canvaspx->PDFmm 的比例(分别保留 X/Y,避免浮点误差累积) const canvasToPdfScaleX = finalWidth / imgWidth; const canvasToPdfScaleY = finalHeight / imgHeight;
// 辅助:累加从目标节点到根容器(element)之间所有可滚动祖先的滚动偏移 const getAccumulatedScrollOffset = (node: Element, root: Element) => { let cur: Element | null = node.parentElement; let accLeft = 0; let accTop = 0; while (cur && cur !== root && cur !== document.body && cur !== document.documentElement) { const style = window.getComputedStyle(cur); const overflowY = style.overflowY; const overflowX = style.overflowX; const isScrollableY = overflowY === 'auto' || overflowY === 'scroll'; const isScrollableX = overflowX === 'auto' || overflowX === 'scroll'; if (isScrollableY) accTop += (cur as HTMLElement).scrollTop; if (isScrollableX) accLeft += (cur as HTMLElement).scrollLeft; cur = cur.parentElement; } return { accLeft, accTop }; }; const anchors = Array.from(element.querySelectorAll('a[href]')) as HTMLAnchorElement[];
const isValidHref = (href: string | null): href is string => { if (!href) return false; const lowered = href.trim().toLowerCase(); return ( lowered.startsWith('http://') || lowered.startsWith('https://') || lowered.startsWith('mailto:') || lowered.startsWith('tel:') ); };
anchors.forEach((anchor) => { if (!isValidHref(anchor.getAttribute('href'))) return; const url = anchor.getAttribute('href') as string;
const clientRects = anchor.getClientRects(); if (!clientRects || clientRects.length === 0) return;
// 滚动累加(处理 antd Table 等内部可滚动容器) const { accLeft, accTop } = getAccumulatedScrollOffset(anchor, element);
for (let i = 0; i < clientRects.length; i += 1) { const rect = clientRects[i];
// DOM CSS px -> 相对 element 的 px // 额外加上所有中间可滚动容器的滚动偏移,映射到内容原点坐标系 const relLeftPx = (rect.left - elementRect.left) + accLeft; const relTopPx = (rect.top - elementRect.top) + accTop; const widthPx = rect.width; const heightPx = rect.height;
if (widthPx <= 0 || heightPx <= 0) continue;
// CSS px -> 画布 px(使用实际比例,避免仅依赖配置 scale 导致的偏差) const leftCanvasPx = relLeftPx * cssToCanvasScaleX; const topCanvasPx = relTopPx * cssToCanvasScaleY; const widthCanvasPx = widthPx * cssToCanvasScaleX; const heightCanvasPx = heightPx * cssToCanvasScaleY;
// 画布 px -> PDF mm,并考虑边距偏移 (x, y) const leftMm = x + leftCanvasPx * canvasToPdfScaleX; const topMm = y + topCanvasPx * canvasToPdfScaleY; const widthMm = widthCanvasPx * canvasToPdfScaleX; const heightMm = heightCanvasPx * canvasToPdfScaleY; const overlayHeightMm = heightMm * 2; // 扩大高度以增强可点击容错
// 叠加链接注解(使用 any 以兼容类型定义差异) try { (pdf as any).link(leftMm, topMm, widthMm, overlayHeightMm, { url }); } catch (e) { // 某些版本也可用 pdf.link(x, y, w, h, urlNumber) 或 textWithLink try { // 退化方案:放置一个透明的文本链接(尽量不影响视觉) const fontSize = 0.01; // 极小字号,尽量不可见 const prevSize = (pdf as any).getFontSize?.() ?? 16; (pdf as any).setFontSize?.(fontSize); (pdf as any).textWithLink?.(' ', leftMm, topMm + fontSize, { url }); (pdf as any).setFontSize?.(prevSize); } catch (_) { // 忽略 } } } });
if (isDownload) { // 保存 PDF pdf.save(`${fileName}.pdf`); } // 返回 PNG 图片文件(而非 PDF),若之前已进行自适应压缩则优先使用该 Blob const pngBlob: Blob = tentativePngBlob ?? await new Promise((resolve, reject) => { try { exportCanvas.toBlob((blob) => { if (blob) resolve(blob); else reject(new Error(`无法导出 ${imageType.toUpperCase()} Blob`)); }, mime); } catch (e) { reject(e); } }); const pngFile = new File([pngBlob], `${fileName}.${imageType}`, { type: mime }); callback(pngFile); return pngFile; } catch (error) { console.error("生成 PDF 时出错:", error); } finally { // 还原 a 标签的临时内联颜色,避免影响真实页面 try { restoreAnchorInlineStyles.forEach((fn) => fn()); } catch (_) { } // 还原 antd 展开图标修复 try { cleanupExpandIconFixers.forEach((fn) => fn()); } catch (_) { } setIsLoading(false); // 结束生成,无论成功或失败都将加载状态设为 false } return undefined; }, []); // 空依赖数组意味着此函数在组件生命周期内不会改变
// 4. 返回 ref、生成函数和加载状态,以便在组件中使用 return { contentRef, generatePdf, isLoading };};
React Hook PDF 生成
https://www.mihouo.com/posts/front/react-hook-pdf-generation/