You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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的fs API读取本地文件:
    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,提问作者Антон Тиунов

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.23 05:08:11