在Next.js中基于html2canvas和jsPDF导出带A4样式分页的Lexical编辑器为PDF的实现方案
在Next.js中基于html2canvas和jsPDF导出带A4样式分页的Lexical编辑器为PDF的实现方案
嘿,我刚好之前在Next.js里做过类似的Lexical编辑器分页导出PDF的需求,踩了好几个样式和测量的坑,给你整理一套亲测可行的实现方案,应该能解决你的问题!
一、核心思路
我们的目标是先把Lexical的富文本内容按照A4尺寸自动拆分成独立的页面块,再逐个把这些页面转成高清图片,最后用jsPDF拼接成完整的PDF。关键在于准确的内容高度测量和样式一致性,不然分页会乱套。
二、具体实现步骤
1. 准备DOM容器与样式定义
先准备两个关键容器:一个隐藏的测量容器(用来计算内容高度,不影响页面布局),一个用来渲染实际分页块的容器。同时严格定义A4的尺寸(按96DPI换算,宽794px,高1122px),并且保证所有容器的样式完全统一。
2. 同步Lexical内容到测量容器
Lexical的内容是内部状态管理的,不能直接拿编辑器的DOM节点,得通过它的API获取最新的HTML内容,同步到隐藏容器里,这样才能准确测量高度。
3. 实现自动分页逻辑
遍历隐藏容器里的所有内容节点,逐个往当前页面块里添加,每次添加后测量页面高度,如果超过A4的高度,就新建一个页面块,把节点移到新页面里。这里要注意克隆节点,避免修改原隐藏容器的内容。
4. 用html2canvas+jsPDF导出PDF
把每个分页块转成高清图片(提高scale值避免模糊),然后逐个添加到jsPDF实例里,最后保存成PDF文件。
三、完整代码示例
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getRoot } from 'lexical'; import { useRef } from 'react'; // 替换成你自己的Lexical编辑器组件 const YourLexicalEditorComponent = () => { const [editor] = useLexicalComposerContext(); return <div className="lexical-editor" contentEditable />; }; export default function DocumentEditor() { const [editor] = useLexicalComposerContext(); const hiddenMeasuringRef = useRef<HTMLDivElement>(null); const renderedPagesRef = useRef<HTMLDivElement>(null); // A4尺寸配置(96DPI) const A4_CONFIG = { WIDTH: 794, HEIGHT: 1122, PADDING: 20, FONT: 'Arial, sans-serif', FONT_SIZE: '16px', LINE_HEIGHT: 1.5, }; // 同步Lexical最新内容到隐藏测量容器 const syncContentToMeasuringContainer = () => { editor.update(() => { const rootNode = $getRoot(); const contentHTML = rootNode.innerHTML; if (hiddenMeasuringRef.current) { hiddenMeasuringRef.current.innerHTML = contentHTML; } }); }; // 创建标准A4页面块的工具函数 const createA4Page = () => { const page = document.createElement('div'); page.className = 'a4-page'; page.style.width = `${A4_CONFIG.WIDTH}px`; page.style.height = `${A4_CONFIG.HEIGHT}px`; page.style.overflow = 'hidden'; page.style.margin = '0 auto 20px'; page.style.padding = `${A4_CONFIG.PADDING}px`; page.style.border = '1px solid #e0e0e0'; // 开发调试用,导出时可移除 page.style.fontFamily = A4_CONFIG.FONT; page.style.fontSize = A4_CONFIG.FONT_SIZE; page.style.lineHeight = `${A4_CONFIG.LINE_HEIGHT}`; return page; }; // 执行内容拆分,生成A4分页块 const splitContentIntoA4Pages = () => { if (!hiddenMeasuringRef.current || !renderedPagesRef.current) return; const measuringContainer = hiddenMeasuringRef.current; const pagesContainer = renderedPagesRef.current; // 清空现有分页 pagesContainer.innerHTML = ''; // 初始化第一个A4页面 let currentPage = createA4Page(); pagesContainer.appendChild(currentPage); const contentNodes = Array.from(measuringContainer.childNodes) as HTMLElement[]; for (const node of contentNodes) { const clonedNode = node.cloneNode(true) as HTMLElement; currentPage.appendChild(clonedNode); // 检查当前页面高度是否超出A4限制 const currentPageHeight = currentPage.scrollHeight; if (currentPageHeight > A4_CONFIG.HEIGHT) { // 移除刚添加的节点,因为它导致页面超了 currentPage.removeChild(clonedNode); // 创建新的A4页面 currentPage = createA4Page(); pagesContainer.appendChild(currentPage); // 把节点加到新页面 currentPage.appendChild(clonedNode); } } }; // 导出PDF的核心函数 const exportToPDF = async () => { // 先同步最新内容到测量容器 syncContentToMeasuringContainer(); // 等待DOM更新完成再执行分页 requestAnimationFrame(async () => { splitContentIntoA4Pages(); // 等待分页渲染完成 requestAnimationFrame(async () => { // 动态导入依赖,减少首屏体积 const { default: jsPDF } = await import('jspdf'); const { default: html2canvas } = await import('html2canvas'); const pdfDoc = new jsPDF('p', 'px', 'a4'); const allPages = document.querySelectorAll('.a4-page'); for (let i = 0; i < allPages.length; i++) { const pageElement = allPages[i]; // 生成高清canvas,scale=2避免模糊 const canvas = await html2canvas(pageElement, { scale: 2, logging: false, useCORS: true, // 有跨域图片时开启 backgroundColor: '#ffffff', }); const imageData = canvas.toDataURL('image/png'); // 除了第一页,每加一页都要新增页面 if (i !== 0) pdfDoc.addPage(); pdfDoc.addImage(imageData, 'PNG', 0, 0, A4_CONFIG.WIDTH, A4_CONFIG.HEIGHT); } pdfDoc.save('document.pdf'); }); }); }; return ( <div className="document-editor-wrapper"> {/* 你的Lexical编辑器 */} <YourLexicalEditorComponent /> {/* 隐藏的内容测量容器 - 不可见,仅用于计算高度 */} <div ref={hiddenMeasuringRef} style={{ position: 'absolute', visibility: 'hidden', top: 0, left: 0, width: `${A4_CONFIG.WIDTH}px`, padding: `${A4_CONFIG.PADDING}px`, fontFamily: A4_CONFIG.FONT, fontSize: A4_CONFIG.FONT_SIZE, lineHeight: `${A4_CONFIG.LINE_HEIGHT}`, }} /> {/* 分页预览容器(可选,开发时可以看分页效果,生产环境可以隐藏) */} <div ref={renderedPagesRef} style={{ marginTop: '30px', padding: '0 10px' }} /> {/* 导出按钮 */} <button onClick={exportToPDF} style={{ marginTop: '20px', padding: '10px 24px', backgroundColor: '#2563eb', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '16px', }} > 导出为PDF </button> </div> ); }
四、关键注意事项
- 样式绝对一致:隐藏测量容器和A4页面块的样式必须完全相同,包括字体、内边距、行高这些细节,不然测量的高度会不准,分页直接乱掉。
- Lexical内容同步:一定要用Lexical的
editor.update()API获取最新内容,不能直接拿编辑器的DOM,因为Lexical内部有虚拟DOM机制,直接取DOM可能拿到旧内容或者不完整的内容。 - 跨页元素处理:如果有单个元素(比如长图片、大表格)超过A4高度,上面的代码会把整个元素放到新页面,但如果元素本身高度超过A4,会超出页面边界。这种情况可以加额外逻辑:判断单个节点的高度,如果超过A4高度,就缩放元素(比如图片设
max-height: 100%; width: auto;),或者提示用户调整内容。 - 清晰度优化:html2canvas的
scale参数设为2或3,导出的PDF会清晰很多,默认scale=1的话会模糊。 - Next.js动态导入:把html2canvas和jsPDF用动态导入,避免把大体积的库打包进首屏代码,优化页面加载速度。
内容来源于stack exchange




