React+TS+Tauri环境下PDF精准渲染与可编辑组件化实现求助
技术解决方案
一、替换精准PDF解析依赖
- 弃用原有简易解析逻辑,采用
pdfjs-dist作为核心解析库——它能完整提取PDF的文本、路径、图像等所有底层元素,原生支持TypeScript类型定义。 - 安装依赖:
npm install pdfjs-dist @types/pdfjs-dist
二、定义标准TS元素类型
统一PDF元素的TypeScript类型,为后续解析、绘制和编辑提供统一结构:
// src/types/pdf-elements.ts type PDFElementType = 'text' | 'path' | 'image' | 'rect' | 'circle'; interface BasePDFElement { id: string; type: PDFElementType; x: number; y: number; transform?: number[]; // PDF变换矩阵 } export interface TextElement extends BasePDFElement { type: 'text'; content: string; fontSize: number; fontFamily: string; fillColor: string; fontWeight?: string; } export interface PathElement extends BasePDFElement { type: 'path'; d: string; // SVG路径指令 strokeColor: string; fillColor: string; lineWidth: number; } export interface ImageElement extends BasePDFElement { type: 'image'; width: number; height: number; src: string; // base64格式图像 } export interface RectElement extends BasePDFElement { type: 'rect'; width: number; height: number; strokeColor: string; fillColor: string; lineWidth: number; } export type PDFElement = TextElement | PathElement | ImageElement | RectElement;
三、解析PDF为标准TS对象
利用pdfjs的getPage和getOperatorList遍历PDF底层操作指令,转换为自定义的PDFElement数组:
// src/utils/pdf-parser.ts import * as pdfjsLib from 'pdfjs-dist'; import type { PDFElement, TextElement, RectElement, ImageElement } from '../types/pdf-elements'; // 本地worker适配(Tauri环境优先用本地文件) pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js'; export async function parsePDFToElements(pdfSource: string | Uint8Array, pageNum: number): Promise<PDFElement[]> { const pdfDoc = await pdfjsLib.getDocument({ data: pdfSource }).promise; const page = await pdfDoc.getPage(pageNum); const operatorList = await page.getOperatorList(); const viewport = page.getViewport({ scale: 1 }); const elements: PDFElement[] = []; let currentTextProps: Partial<TextElement> = {}; for (const op of operatorList.fnArray) { const args = operatorList.argsArray[operatorList.argsIndex++]; switch (op) { case 'Tj': // 文本绘制指令 const textContent = args[0]; const textMatrix = page.getTextMatrix(); elements.push({ id: `text-${Date.now()}-${Math.random()}`, type: 'text', x: textMatrix[4], y: viewport.height - textMatrix[5], // 转换为Canvas坐标(Y轴反向) content: textContent, fontSize: currentTextProps.fontSize || 12, fontFamily: currentTextProps.fontFamily || 'sans-serif', fillColor: currentTextProps.fillColor || '#000' }); break; case 're': // 矩形绘制指令 const [rx, ry, rw, rh] = args; elements.push({ id: `rect-${Date.now()}-${Math.random()}`, type: 'rect', x: rx, y: viewport.height - ry - rh, width: rw, height: rh, strokeColor: '#000', fillColor: 'transparent', lineWidth: 1 } as RectElement); break; case 'Do': // 图像绘制指令 const imgRef = args[0]; const img = await page.obtainImage(imgRef); const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); ctx?.drawImage(img, 0, 0); elements.push({ id: `image-${Date.now()}-${Math.random()}`, type: 'image', x: 0, y: 0, width: img.width, height: img.height, src: canvas.toDataURL() } as ImageElement); break; case 'RG': // 填充颜色设置 const [r, g, b] = args; currentTextProps.fillColor = `rgb(${r*255}, ${g*255}, ${b*255})`; break; case 'Tf': // 字体字号设置 const [fontName, fontSize] = args; currentTextProps.fontFamily = fontName; currentTextProps.fontSize = fontSize; break; } } return elements; }
四、Canvas复现PDF内容
在React组件中,基于解析后的PDFElement数组,在Canvas上精准绘制原PDF内容:
// src/components/PDFCanvas.tsx import React, { useEffect, useRef, useState } from 'react'; import type { PDFElement } from '../types/pdf-elements'; import { parsePDFToElements } from '../utils/pdf-parser'; const PDFCanvas: React.FC<{ pdfSource: string | Uint8Array; pageNum: number }> = ({ pdfSource, pageNum }) => { const canvasRef = useRef<HTMLCanvasElement>(null); const [pdfElements, setPdfElements] = useState<PDFElement[]>([]); useEffect(() => { const loadAndRender = async () => { const elements = await parsePDFToElements(pdfSource, pageNum); setPdfElements(elements); renderElements(elements); }; loadAndRender(); }, [pdfSource, pageNum]); const renderElements = (elements: PDFElement[]) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); elements.forEach(el => { switch (el.type) { case 'text': ctx.fillStyle = el.fillColor; ctx.font = `${el.fontWeight || 'normal'} ${el.fontSize}px ${el.fontFamily}`; ctx.fillText(el.content, el.x, el.y); break; case 'rect': ctx.strokeStyle = el.strokeColor; ctx.fillStyle = el.fillColor; ctx.lineWidth = el.lineWidth; ctx.rect(el.x, el.y, el.width, el.height); ctx.fill(); ctx.stroke(); break; case 'image': const img = new Image(); img.onload = () => ctx.drawImage(img, el.x, el.y, el.width, el.height); img.src = el.src; break; case 'path': ctx.strokeStyle = el.strokeColor; ctx.fillStyle = el.fillColor; ctx.lineWidth = el.lineWidth; const path = new Path2D(el.d); ctx.fill(path); ctx.stroke(path); break; } }); }; return <canvas ref={canvasRef} width={800} height={1131} />; // A4标准尺寸 }; export default PDFCanvas;
五、实现交互编辑功能
1. 基础绘图工具(铅笔、矩形)
通过监听Canvas鼠标事件,维护编辑元素数组,与原PDF元素合并绘制:
// 扩展PDFCanvas组件的交互逻辑 const PDFCanvas: React.FC<{ pdfSource: string | Uint8Array; pageNum: number }> = ({ pdfSource, pageNum }) => { // ... 原有代码 const [editElements, setEditElements] = useState<PDFElement[]>([]); const [isDrawing, setIsDrawing] = useState(false); const [currentTool, setCurrentTool] = useState<'pen' | 'rect' | 'text'>('pen'); const [tempElement, setTempElement] = useState<PDFElement | null>(null); const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; setIsDrawing(true); if (currentTool === 'rect') { setTempElement({ id: `temp-rect-${Date.now()}`, type: 'rect', x, y, width: 0, height: 0, strokeColor: '#ff0000', fillColor: 'transparent', lineWidth: 2 }); } else if (currentTool === 'pen') { setTempElement({ id: `temp-path-${Date.now()}`, type: 'path', x, y, d: `M ${x} ${y}`, strokeColor: '#0000ff', fillColor: 'transparent', lineWidth: 2 }); } }; const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => { if (!isDrawing || !tempElement) return; const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; let updatedTemp: PDFElement; if (currentTool === 'rect') { updatedTemp = { ...tempElement, width: x - tempElement.x, height: y - tempElement.y }; } else { updatedTemp = { ...tempElement, d: `${(tempElement as any).d} L ${x} ${y}` }; } setTempElement(updatedTemp); renderElements([...pdfElements, ...editElements, updatedTemp]); }; const handleMouseUp = () => { if (tempElement) { setEditElements([...editElements, tempElement]); setTempElement(null); } setIsDrawing(false); renderElements([...pdfElements, ...editElements]); }; return ( <div> <div className="tool-bar"> <button onClick={() => setCurrentTool('pen')}>铅笔</button> <button onClick={() => setCurrentTool('rect')}>矩形</button> <button onClick={() => setCurrentTool('text')}>文本</button> </div> <canvas ref={canvasRef} width={800} height={1131} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} /> </div> ); };
2. 文本编辑功能
添加文本输入逻辑,生成新的TextElement并入编辑元素数组:
// 在PDFCanvas组件中添加文本编辑状态 const [textInput, setTextInput] = useState(''); const [isTextEditing, setIsTextEditing] = useState(false); const [textPos, setTextPos] = useState({ x: 0, y: 0 }); const handleTextToolClick = () => { setIsTextEditing(true); }; const handleCanvasClickForText = (e: React.MouseEvent<HTMLCanvasElement>) => { if (!isTextEditing) return; const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); setTextPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); }; const handleTextSubmit = () => { if (!textInput) return; const newText: any = { id: `edit-text-${Date.now()}`, type: 'text', x: textPos.x, y: textPos.y, content: textInput, fontSize: 16, fontFamily: 'sans-serif', fillColor: '#000' }; setEditElements([...editElements, newText]); setTextInput(''); setIsTextEditing(false); renderElements([...pdfElements, ...editElements]); }; // 更新tool-bar和Canvas事件 // tool-bar部分: {isTextEditing ? ( <div> <input type="text" value={textInput} onChange={(e) => setTextInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleTextSubmit()} /> <button onClick={handleTextSubmit}>确认</button> </div> ) : ( <button onClick={handleTextToolClick}>文本</button> )} // Canvas添加点击事件: onClick={handleCanvasClickForText}
六、Tauri环境适配
- 本地Worker配置:将
node_modules/pdfjs-dist/build/pdf.worker.min.js复制到项目public目录,避免CDN依赖。 - 本地PDF读取:利用Tauri的
fsAPI读取本地文件:import { readBinaryFile } from '@tauri-apps/api/fs'; const loadLocalPDF = async (filePath: string) => { const data = await readBinaryFile(filePath); const pdfUint8 = new Uint8Array(data); // 将pdfUint8传入PDFCanvas组件的pdfSource属性 };
内容的提问来源于stack exchange,提问作者Антон Тиунов
相关产品推荐
相关产品推荐

